
R8 kept 5 AdMob test IDs in our release build: one listOf().any line and a dex count
Four ad unit checks wrapped in listOf(...).any were something R8 could not fold, so 5 test ad strings stayed in the release dex. A plain || over the same constants left none.
The first real-ID bundle for Block Turn version code 2, built before upload at 17:16 on October 4, 2026, held 4 real ad unit strings and 5 Google test ad strings in its dex. It had passed the Gradle guard. A rebuild two minutes later overwrote it, so it was never uploaded, and the version code 2 we did upload has no test strings. With all real IDs present, either version of the code picks the real units at runtime.
Sources: git history of our game repository (575aaa1, 1aecbc2, 301bc9e), the launch rehearsal checklist and that day's agent session log. On 2026-10-28 we recounted three bundles and two APKs and built a small R8 reproduction. No ID value was printed.
Contents (8)
- The bundle passed the Gradle guard with 5 test strings inside
- R8 could not evaluate the listOf condition at build time
- The review agent made 19 findings and missed the test strings
- Why the debug source set is a poor home for the test units here
- Counting ad IDs in the dex without printing them
- Counting by prefix alone picks up one string from UMP
- Arrow Knots still guesses from task names
- FAQ
The bundle passed the Gradle guard with 5 test strings inside
Money.kt gives a debug build, or a build with any empty ad unit field in BuildConfig, AdUnits.TEST, the set of Google test units; anything else gets the real ones. Version code 1, built at 12:25 that day, only checked the banner field, and when we counted that bundle on October 28 it had 4 real ad units and 0 test strings.
At 16:14 Claude Code widened the check to all four units, so that a build missing any one unit would treat itself as having no real IDs and use test units for all four. The comment it left says "never a half-real mix". This is the line:
units = if (BuildConfig.DEBUG || listOf(BuildConfig.AD_BANNER, BuildConfig.AD_INTERSTITIAL,
BuildConfig.AD_REWARDED, BuildConfig.AD_APP_OPEN).any { it.isEmpty() }) AdUnits.TEST
else AdUnits(BuildConfig.AD_BANNER, BuildConfig.AD_INTERSTITIAL, BuildConfig.AD_REWARDED, BuildConfig.AD_APP_OPEN)
In the same hour a review agent showed that the old Gradle guard, which decided by task name, could be bypassed. The agent moved the check into a checkReleaseAdIds task hooked to preReleaseBuild, which every release variant runs. That guard only looks at its input, ads.properties.
The agent committed at 17:14 (575aaa1) and built the bundle with the real IDs; the guard passed. The checklist's build step asked for the guard and the signature. The agent also ran grep -c over the dex strings, printing no values: 4 for our publisher number, 5 for the test publisher number. All 5 strings of AdUnits.TEST (banner, interstitial, rewarded, rewarded interstitial, app open) were in the dex.
The 16:14 comment and the review brief covered runtime behavior only; nobody checked whether R8 removed the branch until the dex of a build was counted.

R8 could not evaluate the listOf condition at build time
In a real-ID build the condition is always false, yet R8 could not work that out at build time and kept the branch that returns AdUnits.TEST. On October 28 we reproduced it in a small project: a BuildConfig of the same shape and a 5-string test set, all dummy strings, and both versions of the condition. We compiled it with Kotlin 2.3.21 and shrank it with R8 9.4.24, the version bundled with Android Gradle Plugin 9.4.1, using that plugin's proguard-android-optimize.txt unchanged. The listOf version left 5 test strings and 4 real ones in the dex. The || version left only the 4 real ones.
In javap output, kotlinc has already inlined the BuildConfig constants as string literals in both versions. The listOf version puts the four into an array, builds a list with listOf, and walks it with an Iterator, calling length() on each element. In R8's output dex the listOf call has become Arrays.asList, and the Iterator loop is still there. The || version calls length() directly on each literal; R8 computed those, folded the whole condition to false and dropped the test branch. Version code 1, which checked the banner alone, had the same shape and 0 test strings.

Write test-unit fallbacks as a plain || over BuildConfig constants, and don't wrap them in collection functions (we only measured any). Another case where we read R8's output by the numbers: Play Console said our DEX was 39% optimized.
The review agent made 19 findings and missed the test strings
The 16:49 review agent read the whole change without building it. Its brief listed "if any ad unit is empty, use TEST for all" and "what could break under R8 (keep rules for the Review API)". It came back with 2 high, 6 medium and 11 low findings, and none of them said the release would keep the test strings.
The Gradle guard only sees inputs, so it could not catch this. At 16:55 the agent had counted the dex of the measurement build (-PallowTestAds) the same way and logged "0 real, 5 test" in row 0-4 of the rehearsal checklist; at 17:16 it ran the same check on the real-ID build. The top of the checklist says not to write secrets down. Our full pre-upload checks are in What to check before shipping an AI-built Android app.
The fix landed at 17:18 (1aecbc2). The agent described it as a change that removes the test ID strings from a real-ID build. The 17:42 commit (301bc9e) records the check that followed: 4 real, 0 test.
Why the debug source set is a poor home for the test units here
Moving the test units into the debug source set would keep them out of release builds entirely. It does not fit Block Turn. The -PallowTestAds build we use for device measurements and monkey runs is a release variant, and it has to ignore ads.properties and run on test units, because automated taps on real ads count as invalid clicks.
Left alone, every real-ID build would carry 5 test strings, "0 test strings" could no longer be the pass condition, and each time someone would have to work out whether those strings are dead code. So the condition was rewritten into a shape R8 folds. Today the input guard runs before the build, and the dex count is still run by hand after it.
Counting ad IDs in the dex without printing them
Don't decide what went into a release by reading code. Count it in the build output. These are the Git Bash commands we used for the October 28 recount; the publisher number is read into a variable and never echoed.
# 1. Read your publisher number into a variable (not printed)
MY_PUB=$(sed -n 's/^admob\.banner=[^0-9]*\([0-9]*\)\/.*/\1/p' app/ads.properties)
TEST_PUB=... # the 16-digit test publisher number from Google's test ads page
A=app/build/outputs/bundle/release/app-release.aab # for an APK use 'classes*.dex'
# 2. Your ad units: must equal the number of units the app uses
unzip -p "$A" 'base/dex/*.dex' | grep -a -o "$MY_PUB/[0-9]*" | sort -u | wc -l
# 3. Test strings: must be 0 in a real-ID build
unzip -p "$A" 'base/dex/*.dex' | grep -a -o "$TEST_PUB[~/][0-9]*" | sort -u | wc -l
These commands give 4 and 0 for the vc1, vc2 and October 25 bundles, and 0 and 5 for the -PallowTestAds APK from October 25. For the check before the build, hook it to preReleaseBuild instead of guessing from task names:
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
if (!allowTestAds) dependsOn(checkReleaseAdIds)
}
Counting by prefix alone picks up one string from UMP
Counted by the ad ID prefix alone on October 28, each of the three bundles showed 5. Four were our ad units (the '/' form). The fifth was an app ID-shaped string ('~' form) under a different publisher. Searching 135 Google Play services, consent library (UMP, user-messaging-platform) and Firebase library files in the Gradle cache found the same string in UMP 4.0.0. Count with your own publisher number and the '/' to get 4.
A -PallowTestAds build is supposed to carry the 5 test strings, so the check only applies to real-ID builds. Because it runs after the build, all it can stop is the upload. The R8 reproduction covers one version (9.4.24) and one shape, a BuildConfig string constant; we did not test IDs read from resources or remote config. The manifest app ID is not in the dex; checklist row 1-7 covers it.
Arrow Knots still guesses from task names
Arrow Knots, the other game in the same repository, still checks only the banner field as of the October 27 commit, and its release guard still looks for "release" in task names. Moving it to the checkReleaseAdIds setup and turning the hand-run dex count into a script in the repository are the next jobs.
FAQ
Does listOf().any change how the app behaves?
It doesn't. With all real IDs present, both versions pick the real units.
Would all or none instead of any also keep the branch?
We haven't measured those. Until we do, we write the fallback as a plain || over the constants.
Do these commands work on Windows?
Git Bash ships unzip, grep, sort and wc, so they run as written there.
This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.
More AX notes
- Pre-gzipping Android assets, measured: zero APK savings, and .pack.gz became .pack with a FileNotFoundException
- A market:// link opens a store chooser on SKT-sold Samsung phones: set the Play package first
- A splash that never moved on: addOnCompleteListener(this) and onStop
- "6.7" < "6.11" is false: comparing Remote Config versions as strings froze content updates
- A reverse-construction generator and the gate behind 1,100 arrow-puzzle levels
- Daily users rose after the redesign while organic D1 retention fell from 15.2% to 11.2%