Testing Android Compatibility
Background
Toast has a thriving Android ecosystem, including our flagship Android-based POS product and a number of native and hybrid Android apps available on Play Store such as Toast Takeout, My Toast, Toast Tables, and Sling Scheduling.
The POS codebase alone contains over 1 million lines of Android code and consumes over 100 internal libraries. The majority of those libraries are not Android AARs but rather plain Java/Kotlin JARs which allow us to share business logic and data structures across the POS and server-side code.
In other words, we maintain over 100 plain Java/Kotlin libraries that need to be compatible with Android, in some cases going as far back as Android 4.4 (API 19).
Compatibility
One challenge with maintaining a plain Java/Kotlin library that targets Android is ensuring that the library does not use any of the JDK APIs that are not available on the lowest supported version of Android. These APIs can be either obviously missing, like java.time, which is only available on API 26, or differ subtly from the ones provided, like the infamous Buffer ByteBuffer.position(int) vs ByteBuffer ByteBuffer.position(int).1
It is essential that this validation step is automated and executed early in the development cycle. The cost of making an Android-incompatible change can be high to the business. Even if the affected code path is covered by an instrumented test in the application, rolling back the library version in the application project and backing out the incompatible change from the library can waste hours of engineering time. In the worst case scenario, if the affected code path is only invoked from an untested edge case, the application will crash in production and impact end users.
Validation
This, of course, is not a new problem.
For years, the industry has approximated Android compatibility by setting the Java compiler source and target levels to Java 6 or 7. That, however, is not a very accurate approximation. For example, Java 7 includes widely used NIO File APIs that are not available until API 26. Targeting Java 6 is not even supported in the Java 17 compiler, which is what modern build toolchains like Android Gradle Plugin expect to be running.
A better solution is Animal Sniffer, a tool that can check the code for compatibility with the API surface of a specific version of a library, such as android.jar from the Android SDK. The library is snapshotted into a set of serialized signatures, which can then be used by the Animal Sniffer tooling at build time.
Historically, Android signatures were published under the “net.sf.androidscents.signature” namespace in Maven Central. Those signatures were derived directly from each SDK’s android.jar.
Desugaring
An additional bit of complexity is desugaring. Desugaring is a well-known 2 part of the Android build toolchain which effectively backports certain JDK APIs to older versions of Android by rewriting bytecode at build time.
Consider the static version of Integer.hashCode. Per Android documentation, it was added in API 24. However, code using this method builds against and runs on API 21 and even API 19 because the toolchain desugars it into instructions that only use available APIs.
Why is it important? Can we accept stricter validation and avoid using desugared APIs if we want to target an old enough version of Android? Unfortunately, if using Kotlin, the answer is no. The Kotlin compiler generates calls to Integer.hashCode and its siblings when implementing hashCode for data classes.
Therefore, it is not viable to simply check the code against the signatures from the Android SDK jar. The alternative is to build a more accurate set of signatures for each version of Android, incorporating the desugared methods, which is exactly what we have done.
Gummy Bears
In 2019, we open-sourced Gummy Bears 3, an accurate set of Android signatures, combining the APIs explicitly declared by Android SDK with known desugared signatures. It also includes experimental support for Core Library Desugaring, which backports many more APIs, including java.time, to older Android versions.
Internally at Toast, we have set up Animal Sniffer with Gummy Bears signatures across all libraries that are consumed by Android projects.
Since then, Gummy Bears signatures have been adopted by a number of high-profile open source projects, including Jackson and Guava.
What’s next?
Since setting up Animal Sniffer with Gummy Bears, we have run into a number of more subtle compatibility issues, primarily due to third-party libraries using APIs unavailable on API 19 or 21. Additionally, we have run into ABI incompatibilities between internal and third-party libraries which were unrelated to Android.
In 2023, we open sourced a new tool, Expediter. Expediter validates ABI cross-compatibility between project’s classes, project’s dependencies, and platform APIs. Platform APIs can be defined by a certain version of the regular JVM or a certain version of Android.
As such, Expediter can catch Android incompatibilities in transitive dependencies. It also implements finer-grained checks, such as access control to non-public members, methods invoked via the correct instruction - e.g. invokestatic invoking a method indeed declared as static - and others. For Android compatibility, Expediter reuses the type descriptors published by Gummy Bears, albeit in a more descriptive, Protocol Buffer-based format.
Expediter is currently in the alpha phase, and we are starting to adopt it as a replacement for Animal Sniffer in our builds. We continue to develop it in the open and invite you to try it out.
1 ByteBuffer and the Dreaded NoSuchMethodError, Gunnar Morling
2 D8 Library Desugaring, Jake Wharton
3 The seemingly odd name was inspired by the animal portion of Animal Sniffer, the sugar part of desugaring, and an odd fascination of one engineering team with gourmet gummy bears