This is the second half of a pair. The first guide is about getting a small Android app onto the store in the first place. This one is about what happens after, when the app is done, nothing is planned, and Google starts sending email.
The app in question is deliberately boring: a single-activity Jetpack Compose app, offline, no account, no network, and no permissions of any kind. It shipped in the spring, went quiet, and then over one summer received three separate “your app needs an update” messages. Each one turned out to be a different kind of obligation, and the most useful thing I can tell you is how to sort them, because the emails themselves do not.
Built and maintained with Claude Code throughout; the first guide says more about that and why I would recommend it.
Important: Everything in Steps 1 through 6 was done on the real project and verified on a real phone (41 automated assertions plus a manual pass for the two things that cannot be automated), with one exception called out in Step 5: the image change was verified in the build output, not yet by eye on a device. The finished build has deliberately not been uploaded; Step 7 explains why, and what the console will show when it is, and will be updated then. The memory numbers are computed from image dimensions and pixel format, not measured on hardware, and the formula is shown.
Triage the Emails
Three messages arrived over a few weeks. Here is what each actually was, which is not what each sounded like.
| The email | What it actually is | Deadline | What it gated for this app |
|---|---|---|---|
| Target API level 36 | A hard gate on new releases. Any upload below the bar is rejected. | 2026-08-31 | Nothing immediately. See below. |
| Developer verification | An account requirement, unrelated to the build. Identity and organization details. | 2026-09-30 | Already done at registration. No action. |
| App quality: memory, code optimization, sign-in | A soft bar. The stated penalty is reduced visibility and publishing capability, not rejection. | 2027-02 (memory, DEX) and 2027-04 (sign-in) | One real problem, two already met. Step 5. |
The target API email is the one that reads as an emergency and is not, provided you read the rule carefully. The app was at targetSdk 35. The Play rule for existing apps is one level lower than the rule for new releases, so an app at 35 was never at risk of being removed or hidden; it stayed installable on every device. What the deadline blocks is uploading an update that targets less than 36. In other words it is a can’t-ship wall, not a takedown. There is an extension request for apps that need more time below the bar; an app already at the previous level does not qualify for it and does not need it.
That distinction changes the plan. If the live app stays compliant, there is no deadline on the upload itself. The deadline constrains what the next release must target, not when it must exist. So the work below was done early and then held, with the app sitting at the old version, fully compliant, until there was a real reason to ship.
The quality email is the opposite case: it sounds optional and is the one that needed actual work.
How AI can help
Paste the email and the manifest together and ask which of the changes apply to this app, with the manifest as evidence. A zero-permission app with no network and no sign-in is exempt from most of what these emails describe, but you want that stated per item with the reason, not assumed. It is also good at the deadline-semantics question above, which the email's wording actively obscures: ask specifically what happens to the live listing if you do nothing, and make it cite the rule.
The Toolchain Hop
Raising compileSdk and targetSdk to 36 is a one-line change in the module’s Gradle file, and it does not build, because compileSdk, the Android Gradle Plugin, and Gradle itself are coupled. You cannot bump one. The question is how far the other two have to move.
The Android 16 setup docs imply AGP 8.9 is enough. It is not: AGP 8.9’s maximum supported API level is 35. AGP 8.13 is the lowest 8.x release that reaches API 36 (it supports up to 36.1), and staying on the 8.x line avoids AGP 9’s breaking changes entirely. Gradle moves with it.
What changed:
| File | Change |
|---|---|
root build.gradle.kts |
AGP 8.5.2 to 8.13.0 |
gradle/wrapper/gradle-wrapper.properties |
Gradle 8.7 to 8.13 |
app/build.gradle.kts |
compileSdk / targetSdk 35 to 36; versionCode and versionName bumped |
AndroidManifest.xml |
one property, explained in Step 3 |
What deliberately did not change: Kotlin 1.9.22, KSP, Compose BOM 2024.02.00, and Compose compiler extension 1.5.8. All of them build clean against AGP 8.13. No Kotlin 2.x migration was needed, and resourceConfigurations still works without a localeFilters rewrite. The temptation on a toolchain hop is to modernize everything while you are in there. Don’t. Make the target-version switch its own isolated change, verify it, and only then consider anything else. Earlier in this project a refactor and a target-version change landed together and produced an incompatibility that took a while to untangle, because there were two sets of objectives in one diff.
The JDK is Android Studio’s bundled one (JAVA_HOME pointing at Studio’s jbr directory). It is what Studio builds with, so command-line builds get no compatibility surprises.
Verified before anything touched a device: debug build, release bundle with R8 minification, resource shrinking, and signing all passing; lintRelease at 87 warnings and 0 errors; 63 unit tests passing; and the merged release manifest confirmed at targetSdkVersion="36". Build-green is not behavior-green, but it is the floor.
Tip: Copy the previous release artifacts out of app/build/outputs/ before the rebuild. The new bundle overwrites them, and the old ones are the only record of what is actually live.
What Changes at the New Target, and the One That Bit
Each target level switches on behavior changes for apps that opt in by targeting it. The right move is to go down the list with the app open and decide, per item, whether it can apply. For this app:
| Android 16 change | Impact |
|---|---|
| Edge-to-edge enforced | None. enableEdgeToEdge() was already called, and enforcement actually began at target 35, so this had already shipped. |
| Orientation and resizability restrictions ignored on large screens | The real one. Below. |
| Predictive back on by default | Code is fine (Compose BackHandler is dispatcher-based), but the system back animation is new. Test item, Step 4. |
elegantTextHeight default changed |
None. Affects Thai, Arabic, and Indic scripts; the app ships two Latin-script locales. |
| Body-sensor permission split into granular health permissions | None. The app declares no permissions. |
scheduleAtFixedRate missed-task coalescing |
None. Not used. |
| Local network permission | None. No local network access. |
Six of seven are no-ops by construction, which is the recurring payoff of the zero-permission, no-network design. The seventh is not.
The orientation one. The main activity is locked to portrait, deliberately: the app’s measurement screens depend on a fixed portrait layout, and tablets were skipped at launch as untested. At target 36, Android ignores android:screenOrientation on any display of sw600dp or larger. The lock silently stops working on tablets and unfolded foldables, and the app would rotate into a layout it was never tested in.
There is an opt-out. One manifest property preserves today’s behavior exactly:
<property
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
android:value="true" />
It goes inside <application>. And it is temporary: the opt-out stops working at targetSdk 37. Next year’s bump forces the real decision.
Important: Be honest with yourself about what “real large-screen support” means before you decide to defer it, because it is not a manifest edit. Here it turned out to be three separate pieces. First, state persistence across configuration changes: across nine measurement screens there were zero rememberSaveable calls and zero ViewModels, every screen holding plain remember { mutableStateOf(...) }, and no android:configChanges declared. The portrait lock was hiding this. Unlock orientation and a mid-test rotation silently resets the test to the start; on a tablet, a multi-window resize does the same. Second, layouts that survive a short, wide viewport, where landscape phone is the worst case, not tablet. Third, a maximum content width so text does not stretch across a 10-inch panel. That is a real piece of work with its own test pass, and the opt-out is the right call until it is scheduled. The forcing function is the targetSdk 37 expiry, not any Play date.
One more correction that came out of this: the standing note from launch said “calibration may not work on tablets.” Reading the code disproved it. Every on-screen measurement sizes from a user-calibrated physical-millimeter factor, not from screen dimensions, so a tablet calibrates against the same reference object and renders the same physical size. The measurement was never the blocker; the state loss was. Worth checking your own launch-time assumptions against the code before you carry them into a compliance decision.
Verify on a Device You Do Not Have
Everything in Step 3 is a runtime behavior that only appears at the new target, and none of it can be confirmed by a build. The app has no instrumentation tests. What it has is an adb harness: shell scripts that install the build, drive the UI, and pull screenshots plus uiautomator dump XML for every step, with a small Python helper that turns “tap the thing labeled X” into coordinates. Everything ran over WiFi adb against one mid-range phone already on Android 16.
Three tricks made the sweep possible without buying hardware or building a new emulator:
No tablet needed for the large-screen check. adb shell wm density 240 makes a 1080-pixel phone report a smallest width of 720dp, which hits the same >= sw600dp path a tablet would. Force the display to landscape with adb shell settings put system user_rotation 1, and read the verdict from the app window’s frame in dumpsys window windows (Requested w= and h=), not from the display rotation, which goes landscape either way. Result: the app kept a 498x1080 portrait window, letterboxed inside the landscape display. The opt-out held. Always wm density reset afterward; the scripts trap on exit so a failed run cannot leave the phone in that state.
No API 36 emulator available. Both existing virtual devices were at API 35, useless for this. The API 36 system image was installed but no device used it, and the SDK’s cmdline-tools were absent so avdmanager could not create one from the shell; that is a manual Device Manager step in Studio. The phone was faster.
Predictive back cannot be tested on 3-button navigation. There is no edge swipe. Switch the phone to gesture navigation with adb shell cmd overlay enable com.android.internal.systemui.navbar.gestural (and back with ...navbar.threebutton), then drag slowly from the edge and hold; a flick completes before the preview animation draws, and you learn nothing. Verified by hand at all three in-app BackHandler sites: the two screens where back should step within the screen showed no “leaving the app” peek, and the root screen (the control) did.
Final tally: 41 automated assertions across six scripts, 0 failures, plus the manual predictive-back pass and one manual tap on the app’s single outbound link, which no test reaches and never will.
The harness also produced false results before it produced true ones, and the lessons are more transferable than the passes:
- “The tap succeeded” is not “the screen changed.” The first smoke run reported three passes while tapping disabled buttons: no calibration was stored on the fresh install, so every measurement entry point was gated behind a banner. Assert on a positive element that only exists after the transition. The tell-tale is byte-identical screenshots;
md5sumthe output directory. - Negative heuristics are fragile. “Is the word Recalibrate absent?” as a proxy for “we left the home screen” failed on Settings, which has its own Recalibrate row. Every sub-screen here renders a
Go backcontent description and the home screen does not; that is the positive signal. - Compose emits no resource IDs, so locate by
textorcontent-descfrom the dump, and walk up to the nearest clickable ancestor, because the clickable is on a parent of the text node and tapping the text’s center can miss. Duplicate labels are real (a category name that is also a recent-result row); index from the bottom. - The soft keyboard is invisible in the dump but physically covers buttons. A covered button looks tappable and the tap types a character instead. Dismiss the IME with BACK and verify with
dumpsys input_method | grep mInputShown, or you get a silent failure that looks like a broken button. - The release build cannot install over the debug build. Different signatures force an uninstall, and the uninstall wipes the device’s test data. Gate that script behind an explicit environment variable and say so in its name.
- Windows specifics.
adb.exeand Windows Python cannot resolve Git Bash paths like/w/project/...; hand thempwd -Woutput. And never2>/dev/nullanadb pullinside a harness: a silent snap makes every downstream assertion vacuous. WiFi pairing did not survive between sessions even with the service still advertising; the symptom isadb connectfailing against a port that TCP-connects fine, and the fix is to re-pair.adb mdns servicesfinds the connect endpoint without typing an IP.
How AI can help
This is the step where it did the most and got the most wrong, in that order. It wrote the harness, then reported three false passes, then diagnosed why and rewrote the assertions. Ask it for the test plan first, as a list of runtime behaviors with the positive signal that proves each one, and review that before any script exists. Then when a run reports green, ask it to prove the screens actually changed. The density trick and the gesture-nav overlay command both came from asking "how do I test this without a tablet" and "why does predictive back do nothing on this phone" rather than assuming the hardware was required.
The Memory Bar
The quality email names three memory metrics, thresholded per device RAM bucket, plus a DEX optimization requirement, plus a sign-in requirement. Take them in order of how much work they were.
Zero-Tap Sign-In: does not apply. The requirement covers “any app supporting user sign-in, optional or mandatory.” This app has no sign-in surface at all. A grep across the source for signIn, Credential, OAuth, Firebase, login, and account returns nothing, which is the evidence. If an account system is ever added, the Restore Credentials API becomes mandatory at that point, and not before.
DEX optimization: already passing. The bar is “a minimum of 25% coverage across optimization, shrinking, and obfuscation.” The release build already runs R8 in full mode: isMinifyEnabled = true, isShrinkResources = true, proguard-android-optimize.txt (which does not carry -dontoptimize), and AGP 8.13 where full mode is the default and nothing in gradle.properties disables it. The only -keep rules cover the Room entities and the DataStore preferences, a trivial slice of the app. -keepattributes SourceFile,LineNumberTable retains readable stack traces in Vitals and does not disable obfuscation. No change needed, and it took ten minutes to establish that; check yours before assuming it needs work.
Dynamic memory (anonymous RSS plus swap): no action. No native code, no in-memory caches. The dominant contributor was bitmaps, which is the fix below, and bitmap allocations count into RSS, so one change improves both metrics.
Bitmap memory: the actual problem. The app ships five illustrations as PNGs, each 1168x784. All five lived in the plain res/drawable/ folder. That folder is density-agnostic, which Android treats as mdpi (160 dpi), and it upscales anything found there to the device’s density at decode time. On a 640-dpi phone, a 1168x784 image decodes to 4672x3136. At ARGB_8888 (4 bytes per pixel), per image, computed as width x height x 4 with the scale factor applied to each dimension:
| Device density | Scale from mdpi | Decoded size in drawable/ |
Decoded size in drawable-xxxhdpi/ |
|---|---|---|---|
| hdpi (240) | 1.5x | 7.9 MB | 0.5 MB |
| xhdpi (320) | 2x | 14.0 MB | 0.9 MB |
| 420 dpi | 2.6x | 24.1 MB | 1.5 MB |
| xxhdpi (480) | 3x | 31.4 MB | 2.0 MB |
| xxxhdpi (640) | 4x | 55.9 MB | 3.5 MB |
A flat 16x reduction at every density, because the scale factor drops by 4x in each dimension. It also addresses the email’s specific complaint about bitmap memory that “persists in background/cached states”: Compose’s resource cache holds decoded bitmaps after the app is backgrounded, and shrinking the decode shrinks the cached footprint by the same factor.
The fix was to move the five files into res/drawable-xxxhdpi/. No Kotlin changed; painterResource resolves by name across density buckets, so every call site was untouched. The two launcher vectors stay in drawable/, because vectors must remain density-agnostic. One unreferenced PNG was deleted while I was in there.
Why xxxhdpi and not nodpi or xxhdpi. The bucket should match the resolution the images are displayed at, not their file size. Measured from the call sites, the images are shown at heights of 140 to 200dp, which at 4x is 560 to 800 pixels, against a native height of 784. Native resolution is a near-exact match for a 640-dpi device, which makes xxxhdpi the correct bucket. nodpi would have pinned every device to 3.5 MB, including the low-RAM hdpi phones that only need 0.5 MB, and those are exactly the devices where the per-RAM-bucket thresholds are tightest. xxhdpi would have upscaled 1.33x on flagships, costing 6.5 MB per image for no visible gain. xxxhdpi downsamples correctly all the way down the range.
Verified in the build, not yet by eye: the release bundle contains all five images under base/res/drawable-xxxhdpi-v4/ and the deleted file is absent. Resource resolution is proven by the build itself rather than by inference, because the launcher foreground vector references one of the moved images by name and aapt2 link would have failed had the move broken that reference. The files are byte-identical and a density bucket affects decode resolution, not layout size, so nothing can shift position or scale. It still gets an eyeball on the next device run.
Considered and skipped: WebP. Lossless WebP would take the five images from 494 KB to 284 KB as packaged, about 214 KB off the download. That was measured against the aapt2-recompressed sizes actually inside the bundle, not the raw source PNGs, which overstate the saving. Skipped, because it does nothing for any of the three memory metrics: decoded bitmap memory is identical regardless of container format. It is a download-size optimization only, and 214 KB does not justify touching a release that is otherwise ready.
Tip: Do not put raster images in bare res/drawable/. It means mdpi, and it is the single most expensive default in Android resources. Any new PNG or JPEG goes in a density-qualified folder, drawable-xxxhdpi/ unless it is displayed much larger than these are. Vectors stay in drawable/.
Google's own blog post on this requirement is the primary source, and it is short: App quality: memory optimization and secure onboarding. Read it before reading anyone's summary, including this one.
How AI can help
The density math is the kind of thing it does well if you make it show work: give it the image dimensions, the folder, the display sizes from the call sites, and ask for decoded bytes per density bucket for each candidate folder. Then ask which bucket is correct and why, and make it argue against nodpi, because nodpi is the answer that looks safest and is wrong for low-RAM devices. It also read the R8 configuration and established that the DEX bar was already met, which is worth doing before you spend an afternoon on ProGuard rules that are not needed.
Ship Decisions
Once the build is green and device-verified, the remaining decisions are about the upload, and none of them are technical.
Rebuild rather than upload the existing bundle. The artifact sitting in app/build/outputs/ may be stale relative to whatever else has landed since it was built. ./gradlew clean :app:bundleRelease from the committed tree, then re-run the harness against that output.
Version bookkeeping. The versionCode and versionName claimed by this work are spent even though nothing was uploaded. If other changes ride along, bump past them rather than reusing them.
Deferred warnings. At the original submission the console showed three “recommended” warnings, all acknowledged and deferred. Two come from one deprecated status-bar-color call in the theme, redundant since enableEdgeToEdge() was already in place. It was left alone here deliberately: it is a no-op from API 35 up but still functional on the minSdk 26 to 34 range, so removing it changes status-bar appearance on older devices and needs its own visual pass. The third warning is the orientation restriction, which is intentional and should stay; the manifest property from Step 3 is the deliberate answer. Recommended warnings do not block, and some of them are wrong for your app. Decide each on purpose.
Country list. The listing has been limited to one country since the original submission. Widening it is a listing change, not a build change, and it is still open.
When to actually upload. As established in Step 1, there is no deadline on this. The live app stays compliant at the old target indefinitely. The build targets 36, so it can be submitted any time. Submit when there is a reason to ship, which for a finished app is usually a bug report or a policy date that genuinely gates the live listing. The next real forcing function here is not a Play date at all; it is the targetSdk 37 expiry of the orientation opt-out.
Holding the Build, and What to Check When It Ships
As of this writing the build is done, verified, and deliberately not uploaded. That is not procrastination; it follows from Step 1. The live app is compliant at its current target and stays that way indefinitely. There is no bug report waiting on a release. Nothing about the three emails gates the listing that is already live. So the update sits, and it will ship closer to a date that actually forces it: the soft memory bar in February 2027 if visibility starts to matter, or the targetSdk 37 expiry of the orientation opt-out next year, which is the hard one.
Holding has a cost, and it is worth naming: the longer the gap, the more the toolchain drifts underneath the held build, and Step 6’s “rebuild from clean and re-run the harness” becomes more work rather than a formality. Six months is fine. A year means doing Step 2 again first.
What the console will show when it does ship, because both of Step 5’s claims were made by argument and can be checked by measurement:
- DEX optimization. The Play Console reports optimization insights per uploaded bundle. The claim to confirm is that R8 full mode already clears the 25 percent bar with no configuration change.
- Memory. Android Vitals reports memory metrics with percentile and RAM-bucket drill-down, plus an out-of-memory filter under Crashes and ANRs. The claim to confirm is the 16x bitmap reduction, and specifically that the low-RAM buckets, where the thresholds are tightest, moved. This needs sessions to accumulate before it reports anything.
- Review outcome for a compliance-only release with no functional changes, and whether the three deferred warnings from Step 6 draw any attention.
And one thing to do before the upload, not after: the visual pass on the five moved images and the launcher icon, on a device, which Step 5 was honest about not having done. Ten minutes with the phone.
This step gets a short update with what the console actually reported, when it ships. If you are reading this and it still says “as of this writing,” the build is still being held, and the reasoning above is why.
What You Spent
Nothing beyond the developer account that already existed.
- Google Play developer registration: a one-time fee, paid at launch and covered in the first guide
- Toolchain: $0. Android Studio, the SDK, adb, and the API 36 system image are free downloads
- Test hardware: one phone that was already owned; no tablet and no new emulator, per Step 4
- Time: two working sessions. One for the target-API hop, the behavior review, and the device sweep; one for the quality-bar triage and the image fix. Most of the second session was establishing what did not need doing.
The cost that matters is the one deferred to next year: real large-screen support, which is three pieces of work with their own test pass, and which the targetSdk 37 expiry will collect.
Toolkit Reference
The components that appear across this guide, and the concrete spots where an AI assistant earns its keep.
Tools and Services
- Android Studio
- The toolchain. Its bundled JDK is
JAVA_HOMEfor command-line builds, which avoids Gradle and AGP compatibility surprises. Device Manager is still the only way to create a virtual device ifcmdline-toolsis missing. - Android Gradle Plugin
- Coupled to
compileSdkand to Gradle. 8.9 tops out at API 35; 8.13 is the lowest 8.x that reaches 36, and it stays clear of AGP 9's breaking changes. - adb and uiautomator
- The whole test harness: install, tap by label, screenshot, dump the hierarchy, assert on a positive element. Over WiFi, no instrumentation framework, no tablet.
- Android 16 behavior changes
- The list to go down with the app open. Six of seven were no-ops here by construction; the orientation change was not.
- Compatibility mode and the resizability opt-out
PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITYpreserves an orientation lock on large screens at target 36. It expires at target 37.- R8
- Full mode under AGP 8.x with
proguard-android-optimize.txtalready clears the 25% DEX optimization bar.-keepattributes SourceFile,LineNumberTablekeeps Vitals traces readable without disabling obfuscation. - Density-qualified resource folders
- Bare
drawable/is mdpi. Match the bucket to the displayed size, not the file size; here that wasdrawable-xxxhdpi/. - Google's quality requirements post
- The primary source for the memory, DEX, and sign-in bars and their dates. Short; read it directly.
Where AI Earns Its Keep
- Email triage against the manifest
- Which items apply to this app, with the manifest as evidence per item, and what happens to the live listing if you do nothing. Make it cite the rule.
- Version-matrix research
- The AGP minimum for a given API level is the kind of fact the setup docs get wrong and a release-notes search gets right. Ask for the lowest plugin on the current major line, with a source.
- Behavior-change review
- Go down the platform's list with the source open and get a per-item verdict with the code reference that proves it. The real risk is the one it cannot dismiss.
- Test plan before test scripts
- A list of runtime behaviors, each with the positive signal that proves it. Review that before any harness exists, and when a run is green, ask it to prove the screens actually changed.
- Density arithmetic
- Decoded bytes per bucket for each candidate folder, from image dimensions and display sizes. Make it argue against
nodpi. - Establishing what is already done
- Reading the R8 config and the source for sign-in surfaces took minutes and removed two of three requirements from the work list. Do this before doing anything.