feat(android): target Android 16 (API 36) and handle edge-to-edge - #8
Merged
Conversation
Play reports API 35 as non-compliant, and from 2026-10-31 an app whose target API is not within one year of the latest release cannot be updated at all. This is an availability deadline, not a warning. targetSdk 36 requires compileSdk 36, which the old AGP cannot compile against, which forces Gradle up, which forces Paparazzi onto its 2.0.0 alpha line (1.3.5 crashes on compileSdk 36 — cashapp/paparazzi#1877, no 1.3.x backport). Resolved empirically, since the alpha moved its own floors: AGP 8.3.1 -> 8.13.2 Gradle 8.7 -> 8.13 Kotlin 2.0.21 -> 2.3.0 Paparazzi 1.3.5 -> 2.0.0-alpha05 CI/release JDK 17 -> 21 Kotlin was not in the plan's version matrix but had to move: Paparazzi alpha05 is compiled against Kotlin 2.3.0, so an older KGP cannot read its metadata. Note this puts kotlin-stdlib 2.3.0 on releaseRuntimeClasspath — the alpha itself stays test-scope and out of the AAB, but the compiler and stdlib moved with it. Kotlin 2.3 also made `kotlinOptions { jvmTarget }` a hard error, hence the compilerOptions DSL migration; the 1.8 bytecode target is deliberately unchanged. Edge-to-edge is unconditional at target 36 — the opt-out is ignored — and the app had no inset handling at all, so this was likely already a live defect on Android 15. MainActivity now opts in explicitly and sets bar icon appearance via enableEdgeToEdge, replacing the statusBarColor / navigationBarColor attrs in themes.xml that have been deprecated no-ops since API 35. The padding is applied OUTSIDE BoxWithConstraints on purpose. CounterScreen derives its whole layout from maxHeight/maxWidth, and CounterBottomBar sizes itself as screenHeight * 0.20f; insetting inside would leave every derived measurement scaled to a window the content no longer occupies — correct on the device you tested, wrong on one with a taller nav bar. CounterScreenInsetsTest pins that. It takes injected insets (Robolectric has no real window, so platform insets are all zeroes) and asserts the bottom bar is sized from usable height. Mutation-checked: moving the padding inside BoxWithConstraints leaves every positional assertion passing and fails only the size assertion, 114.0dp against 74.2dp expected. Verified: 298 tests green across both runners (121 Swift + 177 Android), verifyPaparazziDebug green, bundleRelease produces a signed AAB with R8 and mapping.txt, merged manifest reads targetSdkVersion="36". Goldens re-recorded — Paparazzi 2.0 wraps the canvas to the composable's measured height where 1.3.5 used a full device frame; the numerals themselves render identically. Edge-to-edge confirmed visually on a booted API 34 emulator. Committed with --no-verify at the user's direction: the editor workflow was walked to step 16/26 but cannot reach its commit tail. `refresh-tests` drops the android-tests runner from the merged cache (298 pass, only 121 persist), which turns the reconcile audit into 174 phantom findings. The four tests above are therefore NOT in .codeyam/test-registry.json — registering them produces REGISTRY_TEST_NOT_WIRED against the stale cache, and the prescribed recovery (`refresh-tests --test`) matches zero tests for this runner even for already-registered ones. Bookkeeping debt only; the tests run in CI. Not verified on an API 36 device: no such AVD exists locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nseldeib
force-pushed
the
ne--android-16-target-sdk-36
branch
from
August 11, 2026 13:22
6663b52 to
843a4d2
Compare
jaredcosulich
added a commit
that referenced
this pull request
Aug 11, 2026
…or (#11) * fix(audit): declare test-file shapes explicitly and clear metadata drift Cuts the strict audit from 152 findings to 26. The dominant blocker was 99 SOURCE_TEST_FILE_MATCHED_BY_NO_RUNNER findings naming every non-test source file in the repo (32 Kotlin + 67 Swift) as "a test file no runner executes". None of them contain a single test. Root cause is in the invariant, not this repo's config. It derives its test-file recognizer by discarding every directory segment of each testFilePattern and keeping only the basename, so a pattern scoped by directory collapses to a bare extension and then matches every source file of that language. Both of this project's test conventions are directory-scoped (SwiftPM Tests/, Gradle src/test/), so both collapse. The guard against over-broad shapes only rejects a basename that is entirely wildcard, which catches a bare directory pattern but not a directory-scoped one ending in an extension. Setting audit.testFileShapes explicitly replaces that derivation. The two shapes cover all 17 real test files with no gaps - every test in this repo is named *Test.kt or *Tests.swift. This is a declaration of the project's actual convention, not a suppression: a genuinely unrun test file matching either shape is still reported. Also in this pass: - sync-capture-scripts, clearing 3 CAPTURE_SCRIPT_SOURCE_DRIFT findings - 23 glossarySkip entries for helpers the tooling refresh added to .claude/hooks/editor-pretool-hook.py, matching the 25 entries already recorded for that same file - registered defaultContentInsets (CounterScreen.kt), real app code from the edge-to-edge work in #8, covered by CounterScreenInsetsTest.kt Remaining: 26 AGED_STALE_SCREENSHOTS (Android, genuinely stale since #8) and 1 non-blocking plan advisory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164CaGthrJAHNbacwgkRcLv * chore(scenarios): recapture the 26 Android screenshots after edge-to-edge The Android goldens were 16 days older than their source and predated the edge-to-edge / API 36 work that landed in #8. Recaptured all 26 against the current build so they show what the app actually renders today. Every scenario shifted by 5.26-5.55% of pixels - a tight, uniform band consistent with a systematic inset change rather than per-screen churn, which is what enableEdgeToEdge plus the systemBars/displayCutout inset padding in CounterScreen should produce. Captured on the codeyam_pixel AVD (pixel_6, API 34, 1080x2400 @ 420dpi) with animations disabled. API 34 is representative here because the app opts in explicitly via AndroidX enableEdgeToEdge, which applies from API 21 rather than depending on the unconditional enforcement added in API 35. Verification: the first attempt captured a "System UI isn't responding" ANR dialog overlaying the app - the emulator had booted while a Gradle build saturated the host, and SystemUI never recovered. That frame was discarded, the emulator cold-restarted on an idle host, and every capture re-verified. All 26 were then scanned for the dialog signature (a bright band spanning ~87% of the frame width); the widest bright row across the set is 59.3%, which is the hero numeral, so none carry the artifact. Clears all 26 AGED_STALE_SCREENSHOTS findings. The strict audit is now clean; one non-blocking plan advisory remains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164CaGthrJAHNbacwgkRcLv * fix(stack): declare the real JDK floor as 21, not 17 The Android build has required JDK 21 since #8: Paparazzi 2.0.0-alpha05 is a JDK 21 artifact, and the app is on alpha05 because 1.3.5 cannot compile against compileSdk 36 (cashapp/paparazzi#1877). ci.yml already sets up JDK 21 and says so; stack.json was left behind claiming 17. The gap is not cosmetic - stack.json is what the setup flow reads, so a fresh clone provisioned per its instructions installs 17 and then fails the first Android build with "Run this build using a Java 21 or newer JVM", with nothing pointing at the real cause. That is exactly what happened on this machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0164CaGthrJAHNbacwgkRcLv * chore: update codeyam state --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Play reports the app's highest non-compliant target API level as Android 15 (API 35). From 2026-10-31, an app whose target API isn't within one year of the latest Android release cannot be updated at all — this is an availability deadline, not a warning.
The toolchain cascade
targetSdk 36requirescompileSdk 36, which the old AGP can't compile against, which forces Gradle up, which forces Paparazzi onto its 2.0.0 alpha line (1.3.5 crashes oncompileSdk 36— cashapp/paparazzi#1877, no 1.3.x backport).compileSdk/targetSdkKotlin wasn't in the original plan's matrix but had to move. Paparazzi alpha05 is compiled against Kotlin 2.3.0, so an older KGP can't read its metadata (the 2.1.20 attempt crashed the compiler outright). Worth reviewing with open eyes: this puts
kotlin-stdlib:2.3.0onreleaseRuntimeClasspath. The alpha itself stays test-scope and never enters the AAB, but the compiler and stdlib moved with it. Kotlin 2.3 also madekotlinOptions { jvmTarget }a hard error, hence thecompilerOptionsDSL migration — the 1.8 bytecode target is deliberately unchanged.Edge-to-edge
Android 16 makes edge-to-edge unconditional — the
windowOptOutEdgeToEdgeEnforcementescape hatch is ignored at target 36. The app had no inset handling at all, andthemes.xmlwas still settingstatusBarColor/navigationBarColor, which have been deprecated no-ops since API 35. This was very likely already a live defect on Android 15.MainActivitynow opts in explicitly viaenableEdgeToEdge(transparent scrim, light icons); the dead theme attrs are gone.The padding is applied OUTSIDE
BoxWithConstraints, on purpose.CounterScreenderives its whole layout frommaxHeight/maxWidth, andCounterBottomBarsizes itself asscreenHeight * 0.20f. Insetting inside would leave every derived measurement scaled to a window the content no longer occupies — correct on the device you tested, wrong on one with a taller nav bar.Test
CounterScreenInsetsTest(4 tests, Robolectric, no emulator) pins that. Insets are injected via a defaultedcontentInsetsparameter because Robolectric has no real window, so platform insets are all zeroes and the behaviour would otherwise be untestable.It's mutation-verified. Moving the padding inside
BoxWithConstraintsleaves every positional assertion passing — the baseColumnfills what it's given andweight(1f)absorbs the difference. Only the size assertion catches it:The first version of this test passed under mutation and was rewritten.
Verification
verifyPaparazziDebuggreenbundleRelease→ signed AAB, R8 enabled,mapping.txtpresent, merged manifest readstargetSdkVersion="36"sdk=36,release=16): status-bar icons light-on-dark with no grey band, header clears the status bar, control row clears the gesture pill, background runs bar to barGoldens were re-recorded: Paparazzi 2.0 wraps the canvas to the composable's measured height where 1.3.5 used a full device frame. The numerals themselves render identically — the diff was inspected before accepting.
Reviewer notes
versionName1.0 → 1.1.versionCodeneeds no change;play-release.ymlderives it as110000 + GITHUB_RUN_NUMBERand remains the sole uploader.play-release.ymlalso needed the JDK bump — it carried its ownjava-version: "17"with a comment that was already stale (it cited Gradle 8.4). Left alone, the Play artifact would build on a different JDK than the gates ran on.rollout< 1.0) — this changes both the build toolchain and window handling on a live app..codeyam/test-registry.json.refresh-testsdrops theandroid-testsrunner from the merged cache (298 pass, only 121 persist), which turns the reconcile audit into 174 phantom findings; registering them against the stale cache producesREGISTRY_TEST_NOT_WIRED, and the prescribed recovery matches zero tests for this runner even for already-registered ones. Bookkeeping debt in the codeyam tooling — the tests themselves run in CI.session-finalizeis owed.🤖 Generated with Claude Code