AX notes · Incident

A launch crash only on Android 7: one java.time call in a shared module and a NewApi lint gate

One java.time call in a shared module crashed two minSdk 24 games on Android 7 launch. It sat in the code for 14 days, and every device and emulator we tested on ran API 30 or higher.

By SugarMount Android development 9 min read Last updated

Our arrow puzzle game (minSdk 24) closed on Android 7 before drawing its first screen: GA4 recorded app_exception on first launch and no screen views. Our block puzzle shares the same module and runs the same path. A sub-agent we dispatched at 08:50 on October 17, 2026 (KST) to analyze drop-off in the arrow game reported this at 08:56. The cause was one line in the ad module both games share. It called java.time, which exists only from API 26, in apps with minSdk 24, and it runs on every launch. The line had been in the code since a commit on October 3. We submitted fixed versions of both games at 09:29 the same day, and at 15:05 we watched the release builds start on an Android 7.0 emulator that had booted at 15:04.

Sources: the game repository's git history (6bb016c, cc4e860, 44c6acd and others), build files and test code, the session log from the day of the fix, and the GA4 drop-off analysis document. Code re-checked at HEAD on October 25, 2026.

Contents (9)
  1. The line every launch runs: from Ads.start to java.time
  2. For two weeks, no check ran that line on Android 7
  3. The next day, two other files avoided java.time
  4. Lint settings in the app module did not stop the shared module
  5. The Android 7 emulator booted five minutes after the download started
  6. The gate covers one module and one lint check
  7. From two rg commands to an API 24 AVD
  8. What is left
  9. Frequently asked questions

The line every launch runs: from Ads.start to java.time

Both apps, Block Turn and Arrow Knots, call Ads.start() from the shared core-platform module in their first Activity's onCreate. On the first start of the process, that calls onLaunch() on Pacer, the class that spaces out ads, and rollDay() inside it works out the local day number so daily ad caps reset at midnight. This was the default for that calculation:

private val day: (Long) -> Long = { java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay() },

Android ships java.time from Android 8.0 (API 26). Both apps and core-platform have minSdk 24, and nothing in the repository turns on core library desugaring. An Android 7 phone does not have the class, so the app dies before it draws anything. This is the lint output from later, when we put the old line back to test the gate:

Pacer.kt:68: Error: Call requires API level 26, or core library desugaring (current min is 24): java.time.Instant#atZone [NewApi]

The sub-agent found the crash in the arrow game's data and added in the same report that Block Turn uses the same core-platform and would crash the same way.

Step diagram: Oct 3, one java.time call lands in the shared Pacer class; Oct 4, two other files avoid java.time; Oct 17 at 08:56, a GA4 analysis reports the crash; at 09:04, a lintRelease gate on the shared module; at 15:04, an Android 7.0 emulator boots.
The line sat in the code for 14 days, and every device and emulator in use during that time ran API 30 or higher.

For two weeks, no check ran that line on Android 7

The October 3 commit that added the line (6bb016c) was co-written with the agent, and its message ends with a verification note: "Verified on S21 … release (R8, test ads) monkey 3x1500 0 fatals." That is a release build, three monkey runs of 1,500 events and zero fatal errors, on an S21 running Android 15; minSdk 24 was never exercised by that check.

Our other phone, a Galaxy S20, runs Android 13, and the emulator images on the PC were API 30, 33, 34 and 35.

The unit tests never executed the line. Pacer was written as a pure class so every ad rule could be tested without Android, and all 13 tests build it through one helper, Pacer(rules, state, now = { t }, day = { today }). Injecting the clock and the day made the rules easy to test, and the injected spot was exactly the default that crashed. The JVM has java.time, so the line would have passed there anyway.

The release build never ran lint on the shared module. On October 17, :core-platform:lintDebug flagged NewApi on the old line, but the lint that runs automatically with a release build belonged to the app modules.

The next day, two other files avoided java.time

Early on October 4, the commit that added Block Turn (cd62087) formats a date string with SimpleDateFormat and comments "java.time needs API 26". That night, the daily puzzle commit for Arrow Knots (89dc35a) computes dates with Howard Hinnant's civil algorithms, commented "so the app needs no java.time (minSdk 24, no desugaring)". The line added to the shared module the day before stayed as it was.

On October 17, rg -n "java\.time" --type kotlin across seven modules found exactly one java.time call in running code: that line in Pacer.

The fix uses only java.util.TimeZone:

fun localEpochDay(ms: Long, zone: java.util.TimeZone = java.util.TimeZone.getDefault()): Long =
    Math.floorDiv(ms + zone.getOffset(ms), 86_400_000L)

This time the default itself is under test. LocalEpochDayTest walks seven time zones, including Asia/Seoul, America/New_York, Pacific/Kiritimati and America/Adak, for 400 days from December 31, 2025 in 37-minute steps, and compares each result with java.time on the JVM. Steps of 37 minutes cross every midnight and every daylight saving switch.

Lint settings in the app module did not stop the shared module

The first attempt at a guard was wrong. The agent added lint { checkDependencies = true; fatal += "NewApi" } to both apps' build.gradle.kts, expecting that checking dependencies and raising NewApi to fatal would block the release build. It put the old java.time line back for a moment and ran :app-block:lintVitalRelease. The result was BUILD SUCCESSFUL in 29s.

The second attempt made the shared module run its own lint before every release build:

// core-platform/build.gradle.kts
lint {
    checkOnly += "NewApi"
    abortOnError = true
}

// app-block/build.gradle.kts, app-arrows/build.gradle.kts
tasks.matching { it.name == "preReleaseBuild" }.configureEach { dependsOn(":core-platform:lintRelease") }

At 09:04, with the old line back in, the release build stopped with the NewApi error above, BUILD FAILED in 28s and exit code 1. With the fixed line, :core-platform:lintRelease exited 0. The build file comment records the reason as "lintVitalRelease does not look into library modules", and the first setting was removed.

Comparison: with the old java.time line put back, the app-module lint setting gives lintVitalRelease BUILD SUCCESSFUL in 29 s, while the shared module's lintRelease dependency gives BUILD FAILED in 28 s with exit code 1.
With the same old line in place, only the shared module's own lintRelease stopped the build.

Before shipping we also looked inside the release dex. Unzipping it and searching for Ljava/time/ still finds Instant, ZonedDateTime and more. Running dexdump to find the calling classes and matching them against R8's mapping.txt showed a Compose API-version helper and Firebase classes; our own code had none. Then cc4e860 took Arrow Knots from 1.2.1 (versionCode 6) to 1.2.2 and Block Turn from 1.1.2 (versionCode 5) to 1.1.3. That commit also carries unrelated changes such as a new order for the first puzzles, so it touches 19 files with 241 added lines.

The Android 7 emulator booted five minutes after the download started

On the morning of October 17 the agent noted the S20 and S21 could not answer the question and queried the Firebase Test Lab device list. The only models supporting API 26 or lower were four virtual devices, and the lowest level any of them offered was 26.

In its afternoon report the agent recommended waiting: watch a few days of Crashlytics data from real users on the new versions for Android 7 crashes. Downloading an Android 7 image (about 1 GB) for an emulator was its second option, pending approval. It also pointed out that buying a Galaxy S23 would not help, since it runs Android 14 or later. The operator approved the download (this setup asks before installing anything). It started at 14:59, and the Android 7.0 AVD booted at 15:04.

By then a 12:44 commit (b2d305d) had produced the next versions, Block Turn 1.1.4 and Arrow Knots 1.2.3, and both were public by the 14:51 report. We installed release APKs of those versions and started them with am start -W: both returned Status: ok, with an empty crash buffer. The analysis document from October 18 (44c6acd) records:

Android 7.0 emulator, release (R8) buildBlock Turn 1.1.4Arrow Knots 1.2.3
Crash on launchNone (0 FATAL)None (0 FATAL)
Time to first screen2.2 s1.3 s
Monkey (seeds 7, 21, 99; 600 to 800 events)0 crashes, 0 ANRs0 crashes, 0 ANRs

On Block Turn's first pass with seed 7, the emulator's system_server died with DeadSystemException. Two more runs with the same seed did not repeat it, and there was no crash log for our process. (The grey bar with the app name that appeared during monkey runs was Android 7's split-screen caption.)

The emulator is now in the agent's memory as the device for minSdk 24 regressions. If your minSdk is 24, do not call a release verified until the release build has started on an API 24 emulator.

The gate covers one module and one lint check

Another shared module would need the same dependsOn line in every app. Because checkOnly turns on NewApi alone, no other lint issue stops the build at this step. The full order of our release checks is in What to check before shipping an AI-built Android app: the release gate and an R8 crash.

The emulator ran a Google APIs x86_64 image, and the builds checked used test ad IDs.

The evidence for the cause is the GA4 events, the call path in the code, and the lint result. The exception name, NoClassDefFoundError, comes from the code comment and the analysis; we did not see it in a local stack trace.

Projects that use desugaring are in a different position: the lint message itself offers "or core library desugaring". In this repository two other files were already written without it, and the code to fix was one line. There is no record of comparing the two approaches.

From two rg commands to an API 24 AVD

  1. Run rg -n "java\.time" --type kotlin and rg -n "desugar|minSdk" */build.gradle.kts over every module to find calls in running code and see whether desugaring is on.
  2. For each shared (library) module, run ./gradlew :module:lintDebug and look for NewApi in the lint-results XML.
  3. Add lint { checkOnly += "NewApi"; abortOnError = true } to the shared module, and make each app's preReleaseBuild depend on that module's lintRelease.
  4. Put the bad line back and confirm the release build fails, then restore it and confirm it passes. If the old line passes, the setting is not a gate.
  5. For classes that take an injected clock or day function, test the default too: use java.time on the JVM as the expected value and sweep several time zones and daylight saving changes.
  6. Download the image with sdkmanager "system-images;android-24;google_apis;x86_64", create an AVD, install the release APK, and run am start -W, logcat -b crash and a three-seed monkey test.
  7. For any Ljava/time/ reference left in the release dex, find the owning class through mapping.txt. Library code that branches on API level can stay.

What is left

No phone with a manufacturer's Android 7 build has run the apps yet. There is also no record of starting a pre-fix build on this emulator, so reproducing the crash and its exception name there is the next check. We have not counted affected users and will not put a number here until we have.

Frequently asked questions

Why does java.time crash a minSdk 24 app only on Android 7?

Android includes the java.time classes only from Android 8.0 (API 26). Without core library desugaring, an Android 7 device does not have them, and the app dies when it reaches the call. Android 8+ devices and JVM unit tests never show the problem.

Why doesn't a release build catch NewApi errors in a library module?

In this project, the lintVitalRelease task that runs with a release build did not look into the library module. Even with checkDependencies = true and fatal += "NewApi" in the app, the old code passed. Making the app's preReleaseBuild depend on the library's lintRelease is what stopped it.

How can I test on Android 7 without an Android 7 phone?

Create an emulator from the API 24 system image and start your release build on it. The Firebase Test Lab list we checked on October 17, 2026 had no API 24 or 25 devices. The emulator still lacks a manufacturer's system build.

This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.

All notes on Android development · All AX notes