6

I want to extend a discussion I started on the Reddit Android Dev community yesterday with a new question: How do you manage an up-to-date timezone database shipped with your app using the JodaTime library on a device that has outdated timezone information?

The problem

The specific issue at hand relates to a particular timezone, "Europe/Kaliningrad". And I can reproduce the problem: On an Android 4.4 device, if I manually set its time zone to the above, calling new DateTime() will set this DateTime instance to a time one hour before the actual time displayed on the phone's status bar.

I created a sample Activity to illustrate the problem. On its onCreate() I call the following:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ResourceZoneInfoProvider.init(getApplicationContext());
    
    ViewGroup v = (ViewGroup) findViewById(R.id.root);
    addTimeZoneInfo("America/New_York", v);
    addTimeZoneInfo("Europe/Paris", v);
    addTimeZoneInfo("Europe/Kaliningrad", v);
}

private void addTimeZoneInfo(String id, ViewGroup root) {
    AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
    am.setTimeZone(id);
    //Joda does not update its time zone automatically when there is a system change
    DateTimeZone.setDefault(DateTimeZone.forID(id));
    
    View v = getLayoutInflater().inflate(R.layout.info, root, false);
    
    TextView idInfo = (TextView) v.findViewById(R.id.id);
    idInfo.setText(id);
    
    TextView timezone = (TextView) v.findViewById(android.R.id.text1);
    timezone.setText("Time zone: " + TimeZone.getDefault().getDisplayName());
    
    TextView jodaTime = (TextView) v.findViewById(android.R.id.text2);
    //Using the same pattern as Date()
    jodaTime.setText("Time now (Joda): " + new DateTime().toString("EEE MMM dd HH:mm:ss zzz yyyy"));
    
    TextView javaTime = (TextView) v.findViewById(R.id.time_java);
    javaTime.setText("Time now (Java): " + new Date().toString());
    
    
    root.addView(v);
}

ResourceZoneInfoProvider.init() is part of the joda-time-android library and it is meant to initialize Joda's time zone database. addTimeZoneInfo overwrites the device's time zone and inflates a new view where the updated time zone information is displayed. Here is an example of result:

Same time at different time zones using Java

Note how for "Kaliningrad", Android maps it to "GMT+3:00" because that was the case until 26 October 2014 (see Wikipedia article). Even some web sites still show this time zone as GMT+3:00 because of how relatively recent this change is. The correct, however, is "GMT+2:00" as displayed by JodaTime.

Flawed possible solutions?

This is a problem because no matter how I try to circumvent it, in the end, I have to format the time to display it to the user in their time zone. And when I do that using JodaTime, the time will be incorrectly formatted because it will mismatch the expected time the system is displaying.

Alternatively, suppose I handle everything in UTC. When the user is adding an event in the calendar and picks a time for the reminder, I can set it to UTC, store it in the db like that and be done with it.

However, I need to set that reminder with Android's AlarmManager not at the UTC time I converted the time set by the user but at the one relative to the time they want the reminder to trigger. This requires the time zone info to come into play.

For example, if the user is somewhere at UTC+1:00 and he or she sets a reminder for 9:00am, I can:

  • Create a new DateTime instance set for 09:00am at the user's timezone and store its milliseconds in the db. I can also directly use the same milliseconds with the AlarmManager;
  • Create a new DateTime instance set for 09:00am at UTC and store its milliseconds in the db. This better addresses a few other issues not exactly related to this question. But when setting the time with the AlarmManager, I need to compute its millisecond value for 09:00am at the user's timezone;
  • Ignore completely Joda DateTime and handle the setting of the reminder using Java's Calendar. This will make my app rely on outdated time zone information when displaying the time but at least there won't be inconsistencies when scheduling with the AlarmManager or displaying the dates and times.

What am I missing?

I may be over thinking this and I'm afraid I might be missing something obvious. Am I? Is there any way I could keep using JodaTime on Android short of adding my own time zone management to the app and completely disregard all built-in Android formatting functions?

Community
  • 1
  • 1
Anyonymous2324
  • 250
  • 3
  • 12
  • You do know how to [keep joda-time updated](http://www.joda.org/joda-time/tz_update.html), right? The change for `Europe/Kaliningrad` was in [2014f](http://mm.icann.org/pipermail/tz-announce/2014-August/000023.html) – Matt Johnson-Pint Apr 17 '15 at 00:01
  • See also [this related post, about scheduling](http://stackoverflow.com/a/19170823/634824). – Matt Johnson-Pint Apr 17 '15 at 00:03
  • I know. If you read my post more carefully (sorry it is long), you will see at the end that I mention "The correct, however, is "GMT+2:00" as displayed by JodaTime.". I have JodaTime updated, the problem is Android's time zone database is not. How do I handle the conflicts that arise from this inconsistency? – Anyonymous2324 Apr 17 '15 at 07:28

5 Answers5

5

I think the other answers are missing the point. Yes, when persisting time information, you should consider carefully your use cases to decide how best to do so. But even if you had done it, the problem this question poses would still persist.

Consider Android's alarm clock app, which has its source code freely available. If you look at its AlarmInstance class, this is how it is modeled in the database:

private static final String[] QUERY_COLUMNS = {
        _ID,
        YEAR,
        MONTH,
        DAY,
        HOUR,
        MINUTES,
        LABEL,
        VIBRATE,
        RINGTONE,
        ALARM_ID,
        ALARM_STATE
};

And to know when an alarm instance should fire, you call getAlarmTime():

/**
 * Return the time when a alarm should fire.
 *
 * @return the time
 */
public Calendar getAlarmTime() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(Calendar.YEAR, mYear);
    calendar.set(Calendar.MONTH, mMonth);
    calendar.set(Calendar.DAY_OF_MONTH, mDay);
    calendar.set(Calendar.HOUR_OF_DAY, mHour);
    calendar.set(Calendar.MINUTE, mMinute);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    return calendar;
}

Note how an AlarmInstance stores the exact time it should fire, regardless of time zone. This ensures that every time you call getAlarmTime() you get the correct time to fire on the user's time zone. The problem here is if the time zone is not updated, getAlarmTime() cannot get correct time changes, for example, when DST starts.

JodaTime comes in handy in this scenario because it ships with its own time zone database. You could consider other date time libraries such as date4j for the convenience of better handling date calculations, but these typically don't handle their own time zone data.

But having your own time zone data introduces a constraint to your app: you cannot rely anymore on Android's time zone. That means you cannot use its Calendar class or its formatting functions. JodaTime provides formatting functions as well, use them. If you must convert to Calendar, instead of using the toCalendar() method, create one similar to the getAlarmTime() above where you pass the exact time you want.

Alternatively, you could check whether there is a time zone mismatch and warn the user like Matt Johnson suggested in his comment. If you decide to keep using both Android's and Joda's functions, I agree with him:

Yes - with two sources of truth, if they're out of sync, there will be mismatches. Check the versions, show a warning, ask to be updated, etc. There's probably not much more you can do than that.

Except there is one more thing you can do: You can change Android's time zone yourself. You should probably warn the user before doing so but then you could force Android to use the same time zone offset as Joda's:

public static boolean isSameOffset() {
    long now = System.currentTimeMillis();
    return DateTimeZone.getDefault().getOffset(now) == TimeZone.getDefault().getOffset(now);
}

After checking, if it is not the same, you can change Android's time zone with a "fake" zone you create from the offset of Joda's correct time zone information:

public static void updateTimeZone(Context c) {
    TimeZone tz = DateTimeZone.forOffsetMillis(DateTimeZone.getDefault().getOffset(System.currentTimeMillis())).toTimeZone();
    AlarmManager mgr = (AlarmManager) c.getSystemService(Context.ALARM_SERVICE);
    mgr.setTimeZone(tz.getID());
}

Remember you will need the <uses-permission android:name="android.permission.SET_TIME_ZONE"/> permission for that.

Finally, changing the time zone will change the system current time. Unfortunately only system apps can set the time so the best you can do is open the date time settings for the user and prompt him/her to change it manually to the correct one:

startActivity(new Intent(android.provider.Settings.ACTION_DATE_SETTINGS));

You will also have to add some more controls to make sure the time zone gets updated when DST starts and ends. Like you said, you will be adding your own time zone management but it's the only way to ensure the consistency between the two time zone databases.

Community
  • 1
  • 1
Ricardo
  • 7,785
  • 8
  • 40
  • 60
1

You're doing it wrong. If the user adds a calendar entry for 9 am next year he means 9 am. It doesn't matter if the timezone database in the user's device changes or the government decides to alter the start or end of daylight savings time. What matters is what clocks say on the wall. You need to store "9 am" in your database.

  • Thanks but I'm not sure this addresses the problem. Suppose I'm storing "9am" like you suggest. Now I have to set this time on Android in terms of milliseconds from 1970-01-01T00:00:00Z (UTC). How would you do it? Since I'm using Joda-Time, I could call something like `new DateTime(9, 0).getMillis()` to set it at 9am using the current time zone but if this time zone is incompatible with Android's one, I have a problem. – Anyonymous2324 Apr 12 '15 at 01:29
  • You would use a `LocalTime`, or a `LocalDateTime` in joda - not a utc-based value. – Matt Johnson-Pint Apr 17 '15 at 00:02
  • @MattJohnson, yes but I have to eventually convert this to a `Calendar` or `Date` object to get the milliseconds in the user time zone and schedule this time with Android's `AlarmManager`. If I just call `LocalDateTime.toDateTime().getMillis()`, this will create a `DateTime` in a time zone with a different offset than the one on Android. – Anyonymous2324 Apr 17 '15 at 07:44
  • And by the way, `LocalDateTime` is essentially a `DateTime` on UTC time zone anyway. – Anyonymous2324 Apr 17 '15 at 07:48
  • 1
    Not quite. [`LocalDateTime`](http://www.joda.org/joda-time/apidocs/org/joda/time/LocalDateTime.html) is *without* time zone. It just uses a UTC-based `Chronology` when performing calculations. – Matt Johnson-Pint Apr 17 '15 at 16:26
1

This is generally an impossible problem. You can never store delta time(e.g. ms after the epoch) for a future event at a given wall clock time, and be confident it will remain correct. Consider that for any arbitrary time, a nation may sign a law that daylight savings time goes into effect 30 minutes before that time, and time skips forward by 1 hour. A solution which may mitigate this problem is, instead of asking the phone to wake up at a specific time, wake up periodically, check the current time, and then check if any reminders should be activated. This could be done efficiently by adjusting the wake up time to reflect an approximate idea of when the next reminder is set for. For example, if the next reminder isn't for 1 year, wake up after 360 days, and start waking up more frequently until you are very close to the time of the reminder.

  • On Android I could listen to the [TIME_CHANGED broadcast](http://developer.android.com/reference/android/content/Intent.html#ACTION_TIME_CHANGED) to address your problem. However, with `JodaTime` I have the updated time zone information so that's not my case. My app knows the correct time to wake up the device. My problem is I cannot rely at all on any of Android built-in classes and methods because its time zone database is not guaranteed to be compatible with JodaTime's one. Otherwise I start to have the inconsistencies like the one from the image I shared in my post. Thanks. – Anyonymous2324 Apr 12 '15 at 07:56
1

Since you are using AlarmManager, and it would appear that it requires you to set the time in UTC, then you're correct in that you will need to project your time to UTC in order to schedule it.

But you don't have to persist it that way. For example, if you have a recurring daily event, then store the local time of day that the event should fire. If the event is to occur in a specific time zone (rather than the current time zone of the device), then store that time zone ID also.

Project the local time to the UTC time using JodaTime - then pass that value to AlarmManager.

Periodically (or at minimum, whenever you apply an update to JodaTime's data), re-evaluate the scheduled UTC times. Cancel and re-establish the events as necessary.

Watch out for times scheduled during a DST transition. You may have a local time that's not valid on a specific day (which should probably be advanced), or you may have a local time which is ambiguous on a specific day (which you should probably pick the first of the two instances).

Ultimately, I think your concern is that it would be possible for the Joda Time data to be more accurate than the device's data. Thus, an alarm might go off at the correct local time - but it might not match the time showing on the device. I agree that it might be puzzling for the end-user, but they'll probably be thankful that you did the right thing. I don't think there's much you can do about it anyway.

One thing you might consider is to check TimeUtils.getTimeZoneDatabaseVersion() and compare it against the version number of the data you loaded into Joda Time. If they are out of sync, you could present a warning message to your user to either update your application (when you are behind), or update their device's tzdata (when the device is behind).

A quick search found these instructions and this app for updating the tzdata on Android. (I have not tested them myself.)

Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
  • Thanks! When you say, "Project the local time to the UTC time using JodaTime", what do mean? If I have a `LocalTime` set to 9am, I need to convert it to the user's time zone and that will be incorrect in this scenario because Joda's time zone does not match the user's. One thing to keep in mind is that the user has the wrong time zone offset but the correct time. He or she likely noticed the wrong time caused by the wrong time zone offset and went ahead to manually change the time. That's where the discrepancy with Joda stems from. – Anyonymous2324 Apr 17 '15 at 17:06
  • Also, your instructions linked at bottom don't really work because the device must be rooted. – Anyonymous2324 Apr 17 '15 at 17:07
  • Yeah, I just grabbed that last bit from a search. I don't work with Android often enough to know what the recommended way to keep tzdata updated is. Still, you can check the version and alert the user without rooting. – Matt Johnson-Pint Apr 17 '15 at 17:19
  • 1
    By projecting, I mean take the scheduled local time, apply it to the current day (adjusting for any dst transition issues), then convert it from the user's time zone to UTC. Use Joda to do the conversion, as you can at least trust that you know the version of the data you have. Yes - with two sources of truth, if they're out of sync, there will be mismatches. Check the versions, show a warning, ask to be updated, etc. There's probably not much more you can do than that. – Matt Johnson-Pint Apr 17 '15 at 17:22
0

You need to override Joda Time, especially Timezone Provider, and use system's timezones instead of IANA database. Let's show an example of DateTimezone which is based on system's TimeZone class:

public class AndroidOldDateTimeZone extends DateTimeZone {

    private final TimeZone mTz;
    private final Calendar mCalendar;
    private long[] mTransition;

    public AndroidOldDateTimeZone(final String id) {
        super(id);
        mTz = TimeZone.getTimeZone(id);
        mCalendar = GregorianCalendar.getInstance(mTz);
        mTransition = new long[0];

        try {
            final Class tzClass = mTz.getClass();
            final Field field = tzClass.getDeclaredField("mTransitions");
            field.setAccessible(true);
            final Object transitions = field.get(mTz);

            if (transitions instanceof long[]) {
                mTransition = (long[]) transitions;
            } else if (transitions instanceof int[]) {
                final int[] intArray = (int[]) transitions;
                final int size = intArray.length;
                mTransition = new long[size];
                for (int i = 0; i < size; i++) {
                    mTransition[i] = intArray[i];
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public TimeZone getTz() {
        return mTz;
    }

    @Override
    public long previousTransition(final long instant) {
        if (mTransition.length == 0) {
            return instant;
        }

        final int index = findTransitionIndex(instant, false);

        if (index <= 0) {
            return instant;
        }

        return mTransition[index - 1] * 1000;
    }

    @Override
    public long nextTransition(final long instant) {
        if (mTransition.length == 0) {
            return instant;
        }

        final int index = findTransitionIndex(instant, true);

        if (index > mTransition.length - 2) {
            return instant;
        }

        return mTransition[index + 1] * 1000;
    }

    @Override
    public boolean isFixed() {
        return mTransition.length > 0 &&
               mCalendar.getMinimum(Calendar.DST_OFFSET) == mCalendar.getMaximum(Calendar.DST_OFFSET) &&
               mCalendar.getMinimum(Calendar.ZONE_OFFSET) == mCalendar.getMaximum(Calendar.ZONE_OFFSET);
    }

    @Override
    public boolean isStandardOffset(final long instant) {
        mCalendar.setTimeInMillis(instant);
        return mCalendar.get(Calendar.DST_OFFSET) == 0;
    }

    @Override
    public int getStandardOffset(final long instant) {
        mCalendar.setTimeInMillis(instant);
        return mCalendar.get(Calendar.ZONE_OFFSET);
    }

    @Override
    public int getOffset(final long instant) {
        return mTz.getOffset(instant);
    }

    @Override
    public String getShortName(final long instant, final Locale locale) {
        return getName(instant, locale, true);
    }

    @Override
    public String getName(final long instant, final Locale locale) {
        return getName(instant, locale, false);
    }

    private String getName(final long instant, final Locale locale, final boolean isShort) {
        return mTz.getDisplayName(!isStandardOffset(instant),
               isShort ? TimeZone.SHORT : TimeZone.LONG,
               locale == null ? Locale.getDefault() : locale);
    }

    @Override
    public String getNameKey(final long instant) {
        return null;
    }

    @Override
    public TimeZone toTimeZone() {
        return (TimeZone) mTz.clone();
    }

    @Override
    public String toString() {
        return mTz.getClass().getSimpleName();
    }

    @Override
    public boolean equals(final Object o) {
        return (o instanceof AndroidOldDateTimeZone) && mTz == ((AndroidOldDateTimeZone) o).getTz();
    }

    @Override
    public int hashCode() {
        return 31 * super.hashCode() + mTz.hashCode();
    }

    private long roundDownMillisToSeconds(final long millis) {
        return millis < 0 ? (millis - 999) / 1000 : millis / 1000;
    }

    private int findTransitionIndex(final long millis, final boolean isNext) {
        final long seconds = roundDownMillisToSeconds(millis);
        int index = isNext ? mTransition.length : -1;
        for (int i = 0; i < mTransition.length; i++) {
            if (mTransition[i] == seconds) {
                index = i;
            }
        }
        return index;
    }
}

Also I have created a fork of Joda Time, which is uses the system's timezones, it is available here. As a bonus it has less weight without timezones database.

Couple of words what be the best approach: Use the LocalDate and LocalDateTime classes to store and calculate reminders' time. But before calling the alarmManager.setExact method, convert it to the DateTime which acounts default timezone. But surround it with the try-catch operator as the IllegalInstantException could happen. Because there could be no such time that day. In the catch block, adjust the time according to the DST shift.

Oleksandr Albul
  • 1,611
  • 1
  • 23
  • 31