Skip to content

feat(android): target Android 16 (API 36) and handle edge-to-edge - #8

Merged
nseldeib merged 2 commits into
mainfrom
ne--android-16-target-sdk-36
Aug 11, 2026
Merged

feat(android): target Android 16 (API 36) and handle edge-to-edge#8
nseldeib merged 2 commits into
mainfrom
ne--android-16-target-sdk-36

Conversation

@nseldeib

Copy link
Copy Markdown
Contributor

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 36 requires compileSdk 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 on compileSdk 36cashapp/paparazzi#1877, no 1.3.x backport).

Before After
compileSdk / targetSdk 35 36
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 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.0 on releaseRuntimeClasspath. The alpha itself stays test-scope and never enters 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

Android 16 makes edge-to-edge unconditional — the windowOptOutEdgeToEdgeEnforcement escape hatch is ignored at target 36. The app had no inset handling at all, and themes.xml was still setting statusBarColor / navigationBarColor, which have been deprecated no-ops since API 35. This was very likely already a live defect on Android 15.

MainActivity now opts in explicitly via enableEdgeToEdge (transparent scrim, light icons); the dead theme attrs are gone.

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.

Test

CounterScreenInsetsTest (4 tests, Robolectric, no emulator) pins that. Insets are injected via a defaulted contentInsets parameter 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 BoxWithConstraints leaves every positional assertion passing — the base Column fills what it's given and weight(1f) absorbs the difference. Only the size assertion catches it:

Increment bar height (114.0.dp) must be derived from the USABLE height
(expected ~74.2.dp). Deriving it from the full window would give ~114.2.dp,
which means the inset padding was applied inside BoxWithConstraints.

The first version of this test passed under mutation and was rewritten.

Verification

  • 298 tests green across both runners (121 Swift + 177 Android)
  • verifyPaparazziDebug green
  • bundleRelease → signed AAB, R8 enabled, mapping.txt present, merged manifest reads targetSdkVersion="36"
  • Confirmed on a real API 36 device (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 bar

Goldens 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

  • versionName 1.0 → 1.1. versionCode needs no change; play-release.yml derives it as 110000 + GITHUB_RUN_NUMBER and remains the sole uploader.
  • play-release.yml also needed the JDK bump — it carried its own java-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.
  • Suggest a staged rollout (rollout < 1.0) — this changes both the build toolchain and window handling on a live app.
  • Predictive back is deliberately out of scope. It defaults on at target 36 and the app has no back handling, so back exits even with a panel open. That's a real UX gap but a separate behavioural feature needing its own scenarios; folding it into a deadline-driven compliance change would widen the diff.
  • The 4 new tests are not in .codeyam/test-registry.json. 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; registering them against the stale cache produces REGISTRY_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-finalize is owed.

🤖 Generated with Claude Code

nseldeib and others added 2 commits August 10, 2026 16:41
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
nseldeib force-pushed the ne--android-16-target-sdk-36 branch from 6663b52 to 843a4d2 Compare August 11, 2026 13:22
@nseldeib
nseldeib merged commit ed0a2b5 into main Aug 11, 2026
2 of 3 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant