1

I am using java.util.Calendar to find the start of a given week using its set() methods.

  • This works perfectly on Android Nougat+, but not on any Android version below Marshmallow.

  • I have tested on both physical devices and emulators.

  • I have used the debugger to verify that the problem lies with the Calendar code, not some issue in displaying it.

  • I have used Kotlin and Java to create different minimal examples, and the issue persists in both.

Here is the Kotlin minimal example, where a TextView displays the date and a Button is used to increase that date by a week:

class MainActivity : AppCompatActivity() {

    var week = 10

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Set TextView to show the date of the 10th week in 2018.
        setCalendarText(week) 

        // Increase the week on every button click, and show the new date.
        button.setOnClickListener { setCalendarText(++week) }
    }

    /**
     * Set the text of a TextView, defined in XML, to the date of
     * a given week in 2018.
     */
    fun setCalendarText(week: Int) {
        val cal = Calendar.getInstance().apply {
            firstDayOfWeek = Calendar.MONDAY
            set(Calendar.YEAR, 2018)
            set(Calendar.WEEK_OF_YEAR, week)
            set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
            set(Calendar.HOUR_OF_DAY, 0)
            set(Calendar.MINUTE, 0)
            set(Calendar.SECOND, 1)
        }
        textView.text = SimpleDateFormat("dd MMMM yyyy", Locale.UK).format(cal.time)
    }
}

When working as expected, the activity launches with the TextView set to display "05 March 2018". This value changes to the first day of every successive week when the button is clicked.

On Android Marshmallow and below:

  • The TextView's initial value is set to the start of the current week (03 September 2018).
  • The date does not change when the button is clicked.
  • The Calendar can correctly retrieve the last day of the current week if the day is set to Calendar.SUNDAY. It will not work for any other weeks.

Edit: I have attempted to create a Java MVCE, which allows you to perform a quick check whether the basic problem appears by running CalendarTester.test().

import android.util.Log;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;

class CalendarTester {

    /**
     * Check that the Calendar returns the correct date for
     * the start of the 10th week of 2018 instead of returning
     * the start of the current week.
     */
    public static void test() {
        // en_US on my machine, but should probably be en_GB.
        String locale = Locale.getDefault().toString();
        Log.v("CalendarTester", "The locale is " + locale);

        Long startOfTenthWeek = getStartOfGivenWeek(10);
        String startOfTenthWeekFormatted = formatDate(startOfTenthWeek);

        boolean isCorrect = "05 March 2018".equals(startOfTenthWeekFormatted);

        Log.v("CalendarTester", String.format("The calculated date is %s, which is %s",
                startOfTenthWeekFormatted, isCorrect ? "CORRECT" : "WRONG"));
    }

    public static Long getStartOfGivenWeek(int week) {
        Calendar cal = Calendar.getInstance();
        cal.setFirstDayOfWeek(Calendar.MONDAY);
        cal.set(Calendar.YEAR, 2018);
        cal.set(Calendar.WEEK_OF_YEAR, week);
        cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY);
        cal.set(Calendar.HOUR_OF_DAY, 0);
        cal.set(Calendar.MINUTE, 0);
        cal.set(Calendar.SECOND, 1);

        return cal.getTimeInMillis();
    }

    public static String formatDate(Long timeInMillis) {
        return new SimpleDateFormat("dd MMMM yyyy", Locale.UK).format(timeInMillis);
    }
}
TheGreatCabbage
  • 155
  • 1
  • 2
  • 8
  • 1
    Have you tried using GregorianCalendar? – TheWanderer Sep 08 '18 at 14:02
  • Have you tried separating the date-manipulation code from the GUI code? Just dump values to the console to verify values. – Basil Bourque Sep 08 '18 at 15:00
  • @BasilBourque, I used the debugger to identify the root issue as being the time of the calendar. (I originally had supposed it was a bug with the TextSwitcher in Android.) – TheGreatCabbage Sep 08 '18 at 16:43
  • @TheWanderer, after changing `Calendar.getInstance()` to `GregorianCalendar()` the issue is unchanged. – TheGreatCabbage Sep 08 '18 at 16:47
  • 1
    If you write and post a block of console-only code without GUI, it will be much easier for others to verify your results. See: [MCVE](https://stackoverflow.com/help/mcve) – Basil Bourque Sep 08 '18 at 17:46
  • Also, in your [MCVE](https://stackoverflow.com/help/mcve), report your JVM’s current default `Locale` as the definition of a week in `GregorianCalendar` varies by Locale. – Basil Bourque Sep 08 '18 at 17:50
  • @BasilBourque My Locale is en_US, but I'm in the UK so I think it should be en_GB. I've tried to create an MVCE, you should be able to easily add it to a project and call `CalendarTester.test()` to check if yours works. Android Lollipop logs "The calculated date is 03 September 2018, which is WRONG" to my console, whereas Pie logs "The calculated date is 05 March 2018, which is CORRECT". Thanks :-) – TheGreatCabbage Sep 08 '18 at 19:24
  • What is the question here? You've demonstrated that it's broken. If it's broken in old versions and fixed in a new one, there's probably nothing to do in old versions to fix it -- except using a better API like Joda, because Calendar is rife with problems. – Louis Wasserman Sep 08 '18 at 21:18
  • If it's not too much a task, you could look in Joda time. It's a great library for handling time. https://github.com/JodaOrg/joda-time – TheRealChx101 Sep 08 '18 at 23:10
  • 2
    @TheRealChx101 The Joda-Time project is in maintenance mode and advises migration to the *java.time* classes. Back-ported to Java 6/7 in *ThreeTen-Backport* and further adapted to Android in *ThreeTenABP*. – Basil Bourque Sep 09 '18 at 06:38

1 Answers1

4

tl;dr

Use the java.time classes back-ported to early Android.

Problem statement: From current date, move to previous or same Monday, then move to Monday of standard ISO 8601 week number 10 of that date’s week-based year, add one week, and generate text in standard ISO 8601 format for the resulting date.

org.threeten.bp.LocalDate.now(         // Represent a date-only value, without time-of-day and without time zone.
    ZoneId.of( "Europe/London" )       // Determining current date requires a time zone. For any given moment, the date and time vary around the globe by zone.
)                                      // Returns a `LocalDate`. Per immutable objects pattern, any further actions generate another object rather than changing (“mutating”) this object.
.with(                          
    TemporalAdjusters.previousOrSame(  // Move to another date.
        DayOfWeek.MONDAY               // Specify desired day-of-week using `DayOfWeek` enum, with seven objects pre-defined for each day-of-week.
    ) 
)                                      // Renders another `LocalDate` object. 
.with( 
    IsoFields.WEEK_OF_WEEK_BASED_YEAR ,
    10
)
.plusWeeks( 1 )
.toString() 

2018-03-12

Simplify the problem

When tracking down mysterious or buggy behavior, simply the programming to the barest minimum needed to reproduce the problem. In this case, strip away the supposedly irrelevant GUI code to focus on the date-time classes.

As in a scientific experiment, control for various variables. In this case, both time zone and Locale affect the behavior of Calendar. For one thing, the definition of a week within Calendar varies by Locale. So specify these aspects explicitly by hard-coding.

Set a specific date and time, as different times on different days in different zones can affect the behavior.

Calendar is a superclass with various implementations. If you are expecting GregorianCalendar, use that explicitly while debugging.

So, trying running something like the following across your tool scenarios to troubleshoot your problem.

TimeZone tz = TimeZone.getTimeZone( "America/Los_Angeles" );
Locale locale = Locale.US;
GregorianCalendar gc = new GregorianCalendar( tz , locale );
gc.set( 2018 , 9- 1 , 3 , 0 , 0 , 0 );  // Subtract 1 from month number to account for nonsensical month numbering used by this terrible class.
gc.set( Calendar.MILLISECOND , 0 ); // Clear fractional second.
System.out.println( "gc (original): " + gc.toString() );
System.out.println( gc.toZonedDateTime() + "\n" );  // Generate a more readable string, using modern java.time classes. Delete this line if running on Android <26. 

int week = 10;
gc.set( Calendar.WEEK_OF_YEAR , week );
System.out.println( "gc (week=10): " + gc.toString() );
System.out.println( gc.toZonedDateTime() + "\n" );

int weekAfter = ( week + 1 );
gc.set( Calendar.WEEK_OF_YEAR , weekAfter );
System.out.println( "gc (weekAfter): " + gc.toString() );
System.out.println( gc.toZonedDateTime() + "\n" );

When run.

gc (original): java.util.GregorianCalendar[time=?,areFieldsSet=false,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=1,minimalDaysInFirstWeek=1,ERA=1,YEAR=2018,MONTH=8,WEEK_OF_YEAR=36,WEEK_OF_MONTH=2,DAY_OF_MONTH=3,DAY_OF_YEAR=251,DAY_OF_WEEK=7,DAY_OF_WEEK_IN_MONTH=2,AM_PM=1,HOUR=2,HOUR_OF_DAY=0,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=3600000]

2018-09-03T00:00-07:00[America/Los_Angeles]

gc (week=10): java.util.GregorianCalendar[time=?,areFieldsSet=false,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=1,minimalDaysInFirstWeek=1,ERA=1,YEAR=2018,MONTH=8,WEEK_OF_YEAR=10,WEEK_OF_MONTH=2,DAY_OF_MONTH=3,DAY_OF_YEAR=246,DAY_OF_WEEK=2,DAY_OF_WEEK_IN_MONTH=1,AM_PM=0,HOUR=0,HOUR_OF_DAY=0,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=3600000]

2018-03-05T00:00-08:00[America/Los_Angeles]

gc (weekAfter): java.util.GregorianCalendar[time=?,areFieldsSet=false,areAllFieldsSet=true,lenient=true,zone=sun.util.calendar.ZoneInfo[id="America/Los_Angeles",offset=-28800000,dstSavings=3600000,useDaylight=true,transitions=185,lastRule=java.util.SimpleTimeZone[id=America/Los_Angeles,offset=-28800000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]],firstDayOfWeek=1,minimalDaysInFirstWeek=1,ERA=1,YEAR=2018,MONTH=2,WEEK_OF_YEAR=11,WEEK_OF_MONTH=2,DAY_OF_MONTH=5,DAY_OF_YEAR=64,DAY_OF_WEEK=2,DAY_OF_WEEK_IN_MONTH=1,AM_PM=0,HOUR=0,HOUR_OF_DAY=0,MINUTE=0,SECOND=0,MILLISECOND=0,ZONE_OFFSET=-28800000,DST_OFFSET=0]

2018-03-12T00:00-07:00[America/Los_Angeles]

java.time

Really, your problem is moot because you should not be using the terrible old Calendar class at all. It is part of the troublesome old date-time classes that years ago were supplanted by the modern java.time classes. For early Android, see the last bullets at bottom below.

In Calendar/GregorianCalendar, the definition of a week varies by Locale, Not so in java.time by default, which uses the ISO 8601 standard definition of a week.

  • Week # 1 has the first Thursday of the calendar-year.
  • Monday is the first day of the week.
  • A week-based year has either 52 or 53 weeks.
  • The first/last few days of the calendar may appear in the previous/next week-based year.

LocalDate

The LocalDate class represents a date-only value without time-of-day and without time zone.

A time zone is crucial in determining a date. For any given moment, the date varies around the globe by zone. For example, a few minutes after midnight in Paris France is a new day while still “yesterday” in Montréal Québec.

If no time zone is specified, the JVM implicitly applies its current default time zone. That default may change at any moment during runtime(!), so your results may vary. Better to specify your desired/expected time zone explicitly as an argument.

Specify a proper time zone name in the format of continent/region, such as America/Montreal, Africa/Casablanca, or Pacific/Auckland. Never use the 3-4 letter abbreviation such as EST or IST as they are not true time zones, not standardized, and not even unique(!).

ZoneId z = ZoneId.of( "America/Montreal" ) ;  
LocalDate today = LocalDate.now( z ) ;

If you want to use the JVM’s current default time zone, ask for it and pass as an argument. If omitted, the JVM’s current default is applied implicitly. Better to be explicit, as the default may be changed at any moment during runtime by any code in any thread of any app within the JVM.

ZoneId z = ZoneId.systemDefault() ;  // Get JVM’s current default time zone.

Or specify a date. You may set the month by a number, with sane numbering 1-12 for January-December.

LocalDate ld = LocalDate.of( 1986 , 2 , 23 ) ;  // Years use sane direct numbering (1986 means year 1986). Months use sane numbering, 1-12 for January-December.

Or, better, use the Month enum objects pre-defined, one for each month of the year. Tip: Use these Month objects throughout your codebase rather than a mere integer number to make your code more self-documenting, ensure valid values, and provide type-safety.

LocalDate ld = LocalDate.of( 2018 , Month.SEPTEMBER , 3 ) ;

TemporalAdjuster

To move to a prior Monday, or stay on the date if already a Monday, use a TemporalAdjuster implementation provided in the TemporalAdjusters class. Specify desired day-of-week with DayOfWeek enum.

LocalDate monday = ld.with( TemporalAdjusters.previousOrSame( DayOfWeek.MONDAY ) ) ;

IsoFields

The java.time classes have limited support for weeks. Use the IsoFields class with its constants WEEK_OF_WEEK_BASED_YEAR & WEEK_BASED_YEAR.

LocalDate mondayOfWeekTen = monday.with( IsoFields.WEEK_OF_WEEK_BASED_YEAR , 10 ) ;

ISO 8601

The ISO 8601 standard defines many useful practical formats for representing date-time values as text. This includes weeks. Let's generate such text as output.

String weekLaterOutput = 
    weekLater
    .get( IsoFields.WEEK_BASED_YEAR ) 
    + "-W" 
    + String.format( "%02d" , weekLater.get( IsoFields.WEEK_OF_WEEK_BASED_YEAR ) ) 
    + "-" 
    + weekLater.getDayOfWeek().getValue()
; // Generate standard ISO 8601 output. Ex: 2018-W11-1

Dump to console.

System.out.println("ld.toString(): " + ld);
System.out.println("monday.toString(): " +monday);
System.out.println("weekLater.toString(): " + weekLater);
System.out.println( "weekLaterOutput: " + weekLaterOutput ) ;

When run.

ld.toString(): 2018-09-03

monday.toString(): 2018-09-03

weekLater.toString(): 2018-03-12

weekLaterOutput: 2018-W11-1

Tip for Java (not Android): If doing much work with weeks, consider adding the ThreeTen-Extra library to access its YearWeek class.


About java.time

The java.time framework is built into Java 8 and later. These classes supplant the troublesome old legacy date-time classes such as java.util.Date, Calendar, & SimpleDateFormat.

The Joda-Time project, now in maintenance mode, advises migration to the java.time classes.

To learn more, see the Oracle Tutorial. And search Stack Overflow for many examples and explanations. Specification is JSR 310.

You may exchange java.time objects directly with your database. Use a JDBC driver compliant with JDBC 4.2 or later. No need for strings, no need for java.sql.* classes.

Where to obtain the java.time classes?

Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154