How to handle JodaTime's and Android's timezone database differences?

▼魔方 西西 提交于 2019-12-03 15:57:21
Ricardo

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.

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.

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.

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.)

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.

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!