Tick Tock: Desugaring and Timezones
Dealing with timezone data in a desugaring world.
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.