Search code examples
javaandroidkotlinandroid-6.0-marshmallowjava.util.calendar

Calendar set() broken on Android API 23 and below - java.util.Calendar


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);
    }
}

Solution

  • 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?