How to handle JodaTime's and Android's tim

2020-06-19 03:14发布

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?

5条回答
等我变得足够好
2楼-- · 2020-06-19 03:32

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.

查看更多
够拽才男人
3楼-- · 2020-06-19 03:34

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

查看更多
We Are One
4楼-- · 2020-06-19 03:34

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.

查看更多
Melony?
5楼-- · 2020-06-19 03:40

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.

查看更多
霸刀☆藐视天下
6楼-- · 2020-06-19 03:42

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.

查看更多
登录 后发表回答