Mastodon

Tick Tock: Desugaring and Timezones

Dealing with timezone data in a desugaring world.

Tick Tock: Desugaring and Timezones
TL;DR Gabriel Ittner and I open sourced a new library for managing timezone data on Java 8+ called TickTock: a JVM library with desugar-compatible Android extensions.

AGP/Android Studio 4.0 recently introduced library desugaring (code name "L8"), a tool that backports a number of Java 8+ APIs for use on older Android versions. This is a big win for working with time APIs, as historically developers have had to depend on external libraries like Joda-Time or ThreeTenBp (and their Android wrappers). Now we can use java.time.* APIs directly and even consume external libraries that use them.

For timezones, L8's implementation uses a java.util.TimeZone-based implementation of a ZoneRulesProvider by default. This comes at a potential cost though: it relies on the current runtime to provide data. This is problematic for Android, where these updates are historically at the mercy of OEMs to update their devices. While this process is much more reliable for recent Android versions via Project Mainline, this is still a problem for sdks older than ~29.

This isn't a new problem, nor is it going away anytime soon. Timezones rules change all the time (7 times in 2019 alone!) and older devices may not get those changes. A device running Android 5.1 may be missing years of timezone data!

One major benefit of the previously-mentioned external libraries is they can bundle timezone data directly. This allows developers to ship the latest timezone data (usually bundled in a tzdb.dat binary file) directly, rather than rely on the current runtime. With library desugaring, these libraries are no longer in the picture.

So how can we make sure those older devices work correctly?

Short answer: TickTock

Long answer: read on!


TzdbZoneRulesProvider

The simplest approach is to prepackage your own tzdb.dat file. The conventional mechanism for this is TzdbZoneRulesProvider (a ZoneRulesProvider implementation backed by a tzdb.dat file). However, the JDK implementation looks in your java.home location, which obviously doesn't exist in Android.

Edit: The below gists don't appear to show up well on mobile. Best to request as desktop site, sorry!

L8's implementation has this same class, but with a slightly different implementation.

Here's our foot in the door! If we package our custom tzdb.dat at this magic j$/time/zone/tzdb.dat location in Java (not Android) resources, this implementation will use it.

Note: Loading from resources can be problematic on Android. The team behind D8/L8/R8 are looking into offering alternative options and you can follow along at this issue.

That's step one, but we're not quite there yet. Remember that L8 will use TimeZoneRulesProvider by default. If you look at the code though, observe the conditional above it.

This actually matches the JDK implementation, where there is a system java.time.zone.DefaultZoneRulesProvider property we can set to point ZoneRulesProvider at a different provider. System properties work in Android too, so all we need to do is set this before it's initialized and point it to L8's TzdbZoneRulesProvider implementation. When L8 packages in its backported APIs, its pattern roughly follows a convention of replacing java with j$. So for java.time.zone.TzdbZoneRulesProvider, we want j$.time.zone.TzdbZoneRulesProvider.

System.setProperty(
    "java.time.zone.DefaultZoneRulesProvider",
    "j$.time.zone.TzdbZoneRulesProvider"
)

Stick this snippet somewhere as early as possible in your application lifecycle (static init in your Application class or similar). Also, since this is looked up reflectively at runtime, you'll need to add a keep rule if you use minification of any sort.

-keep class j$.time.zone.TzdbZoneRulesProvider { *; }

This works! Now the packaged tzdb.dat file will be used at runtime instead.

A word about minSdk 26+

This works with L8 desugaring. That's great! What if our minSdk is 26 (where these APIs were introduced)?

I've got unfortunate news for you: it doesn't appear to be possible right now because the Android framework implementation of ZoneRulesProvider does not respect the DefaultZoneRulesProvider property and hardcodes it to its IcuZoneRulesProvider implementation. In fact, ZoneRulesProvider itself is hidden from the Android API as a non-sdk interface even though it's present in the framework. This means that this class cannot be touched or used directly in either source nor runtime (even via reflection!).

The good news is that this only leaves 3 API versions (26, 27, and 28) in limbo for now. If you find yourself wanting to raise your minSdkVersion to 26 but timezone data is critical for your app, maybe wait until you can skip all the way to 29. In the meantime, I've filed this issue on libcore requesting that the class be opened up in future Android versions. We may add minSdk checks in the future to TickTock, but will wait to see how that issue progresses.

Lazy loading

One downside to packaging your own tzdb is that you pay an I/O cost the first time you load it. While this will happen lazily at first use, this is often incurred on app startup. A solution to this is lazily loading individual zones as needed and/or eagerly caching them off the main thread.

My go-to library for this is Gabriel's excellent lazythreetenbp library. It's currently built just for ThreeTenBp with an Android wrapper around it, but it's not too hard to borrow from its compiler artifact. It works by generating individual .dat files for each zone, and just loading those individual zones on-demand.

Adapting this implementation for use with java.time APIs via L8 is fairly straightforward and the implementation came out quite nice! It requires (mis)using a few java.time APIs via reflection and copying in some serialization logic from ThreeTenBp since it isn't really public API, but these are a one-time setup and work just fine so far.

Lastly, I just needed to update my system property setting to point to my custom LazyZoneRulesProvider class so L8's implementation will use it. You need to use keep rule for this case too, or just annotate the class with @Keep directly.

This solution does still use Java resources for loading rather than Android assets (like lazythreetenbp) or raw resources (like joda-time-android). It's possible, but tricky since we can't actually make the tzdata library an Android library due to ZoneRulesProvider not being an available API there. In TickTock, we handle this via plugin system to set custom data loaders instead and compiling against the Android API from a plain Java library.

In TickTock, we implement this as a custom ZoneDataProvider.

A word about IDE builds and eager caching

TickTock offers a helper cacheZones() method you can call off the main thread to trigger loading of all caches. Its implementation is fairly trivial:

/**
 * Call on background thread to eagerly load all zones. Starts with loading {@link
 * ZoneId#systemDefault()} which is the one most likely to be used.
 */
public static void cacheZones() {
  ZoneId.systemDefault().getRules();
  Set<String> zoneIds = ZoneId.getAvailableZoneIds();
  if (zoneIds.isEmpty()) {
    throw new IllegalStateException("No zone ids available!");
  }
  for (String zoneId : zoneIds) {
    ZoneId.of(zoneId).getRules();
  }
}

While this works now, it's a bit odd to use ZoneId for this rather than the more idiomatic ZoneRulesProvider APIs of the same names. Initially we did! L8 will desugar it fine, but in an L8-less environment, using ZoneRulesProvider APIs would actually fail at runtime with a NoSuchMethodError due to the previously mentioned non-sdk interface issue.

What does this have to do with IDE builds? When building with Android Studio, if you run a build with a target device to install on it will "inject" that device's OS version into the build. This is done as an optimization to avoid desugaring in debug builds if the device you're running it on doesn't need it. For the average developer, this is often the case since we tend to prefer using modern devices for development.

With ZoneRulesProvider APIs, this is a problem for us. If we build for a device running API 26+, the resulting build will not use time desugaring and will fail at runtime when these APIs are hit. While this would only happen in debug builds, it's still annoying and a bit confusing. In the meantime, we've switched to the ZoneId versions in TickTock and filed this issue for the behavior confusion. In short - desugar and the listed non-sdk-interfaces don't always agree!

Conclusion

If you want to dig in more, here is the repo for TickTock: https://github.com/ZacSweers/ticktock. I've described a lot of nitty gritty above, but there's no reason to really have to do this manually if a library's available for it.

For Android users, setup is simple: all you need to do is add the ticktock-android-tzdb dependency. No further configuration necessary!

If you want to go the extra mile for a bit for added performance, you can use the lazy zone loading method we covered in the second half.

If you're looking for advanced usage or configuration, there's APIs for that too!

There's no perfect solution for ensuring the latest timezone data on Android, but you can get it pretty close. Project Mainline should make this a non-issue in API 29+ (though as always, mileage may very with OEMs and Play Store availability). This approach can help you bridge the gap until you're at minSdk 29.


Thanks to Dan Lew for reviewing this! If you've got any other questions, feel free to find me on Twitter.