Lean Pixels, Silent Logins: Getting an Android App Ready for Play’s 2027 Quality Bar
Memory budgets, bitmap discipline, R8 coverage and zero-tap restore, with Kotlin you can ship and failures you can avoid. Google Play announced two new quality requirements in August 2026. One is about how much memory y
Memory budgets, bitmap discipline, R8 coverage and zero-tap restore, with Kotlin you can ship and failures you can avoid.
Google Play announced two new quality requirements in August 2026. One is about how much memory your app holds. The other is about whether a returning user has to type a password on a brand-new phone. Both come with enforcement dates, and both punish habits that most codebases quietly accumulate.
This post is the engineering view: what is being measured, how to measure it yourself, which mistakes show up in real apps, and how to build the fixes in idiomatic Kotlin. The original announcement is on the Android Developers Blog, and the exact thresholds live on Play’s help page. I won’t invent numbers that Google hasn’t published; where a value matters, check that page.
Every code sample uses a fictional app, TrailLog, a hiking journal with photo galleries and an account system.
What Play will actually check
Dynamic memory is anonymous resident memory plus swap. In plain terms: the private data your process holds, whether it sits in RAM or has been compressed. File-backed pages, such as your code and asset files, are excluded. Play evaluates this per process state and per device RAM class, so a number that is fine in the foreground on a high-end phone can fail in the cached state on a budget one.
Bitmap memory is the footprint of decoded images. Holding them while a screen is visible is expected. Holding them while the process is backgrounded or cached is the problem.
DEX optimization requires at least 25% coverage across optimization, shrinking and obfuscation, using R8 or another shrinker. That is a floor, not a goal, and apps with sloppy keep rules can miss it without noticing.
Per the announcement, the memory, bitmap and DEX thresholds are enforced from February 2027, and zero-tap sign-in applies from April 2027. Further details are promised later in 2026, so confirm exact consequences for your app in Play Console. Apps that miss them can lose visibility and some publishing capabilities. Games are currently exempt from the sign-in requirement, though single-account games are encouraged to adopt it anyway.
Part 1: Measure what the grader measures
Heap profilers show you the managed heap. The metric Play describes is broader: anonymous RSS plus swap includes native allocations, graphics buffers and anything else the kernel considers private to your process. On Linux you can read it directly from /proc/self/status.
import java.io.File
data class DynamicMemory(val anonRssKb: Long, val swapKb: Long) {
val totalKb: Long get() = anonRssKb + swapKb
}
object MemoryProbe {
/** Reads RssAnon and VmSwap for this process. Debug builds only. */
fun read(): DynamicMemory? = runCatching {
val fields = File("/proc/self/status")
.useLines { lines ->
lines.mapNotNull { line ->
val key = line.substringBefore(':')
if (key == "RssAnon" || key == "VmSwap") {
key to line.substringAfter(':').trim().substringBefore(' ').toLong()
} else null
}.toMap()
}
DynamicMemory(
anonRssKb = fields["RssAnon"] ?: return null,
swapKb = fields["VmSwap"] ?: 0L,
)
}.getOrNull()
}
This is a diagnostic, not the official number. Play aggregates its own measurements, and the exact methodology is theirs. But the trend line you get from it is honest, and it exposes problems the Java heap view hides.
Log it at the moments that matter: after a cold start, after opening and closing your heaviest screen, and after backgrounding the app for a minute.
class TraceMemoryObserver : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
MemoryProbe.read()?.let {
Log.d("Mem", "backgrounded: ${it.totalKb / 1024} MB (anon+swap)")
}
}
}
// Application.onCreate (debug only)
if (BuildConfig.DEBUG) {
ProcessLifecycleOwner.get().lifecycle.addObserver(TraceMemoryObserver())
}
Pair this with the new memory views and the out-of-memory filter in Android vitals. Your own probe tells you why a regression happened; vitals tells you how widespread it is.
Part 2: Bitmaps that know when to leave
Bitmaps are the single easiest way to blow a memory budget, because their cost is width × height × bytes per pixel, not the file size. A 12-megapixel photo decoded at full resolution into ARGB_8888 is roughly 48 MB, however small the JPEG was.
Size caches in bytes, not entries
The classic mistake is LruCache<String, Bitmap>(50). Fifty thumbnails and fifty full-screen photos are not the same thing.
class PhotoCache(maxBytes: Int) {
private val cache = object : LruCache<String, Bitmap>(maxBytes) {
override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
}
operator fun get(key: String): Bitmap? = cache[key]
operator fun set(key: String, bitmap: Bitmap) { cache.put(key, bitmap) }
fun trimTo(fraction: Double) {
cache.trimToSize((cache.maxSize() * fraction).toInt())
}
fun clear() = cache.evictAll()
}
fun photoCacheBudget(context: Context): Int {
val am = context.getSystemService<ActivityManager>()!!
val classMb = am.memoryClass // per-app heap hint, in MB
val lowRam = am.isLowRamDevice
val fraction = if (lowRam) 0.08 else 0.125
return (classMb * 1024 * 1024 * fraction).toInt()
}
React to the process state
Android reports memory pressure through ComponentCallbacks2.onTrimMemory, but the levels are not one clean ladder. They fall into two families: running levels (TRIM_MEMORY_RUNNING_MODERATE = 5, RUNNING_LOW = 10, RUNNING_CRITICAL = 15) for a process the user is actively using, and background levels (UI_HIDDEN = 20, BACKGROUND = 40, MODERATE = 60, COMPLETE = 80) for a process that is no longer on screen. The two families mean different things, so handle them explicitly instead of comparing against one threshold.
There is a bigger catch. The current reference marks most of these constants as deprecated and says apps are not notified of them since API 34, leaving TRIM_MEMORY_UI_HIDDEN as the dependable signal. Verify which levels your targetSdk and test devices still deliver. The safe design is to stop relying on the callback for background cleanup and drive it from process lifecycle, using onTrimMemory only as an extra hint:
class TrailLogApp : Application() {
val photoCache by lazy { PhotoCache(photoCacheBudget(this)) }
override fun onCreate() {
super.onCreate()
// Registered once per process; no per-screen setup, so nothing to leak.
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
// The app left the foreground: decoded bitmaps can be rebuilt later.
photoCache.clear()
}
})
}
@Suppress("DEPRECATION")
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when (level) {
// Legacy running levels: on API 34+ these may never arrive, so this
// branch is a harmless bonus on older devices, not your safety net.
// Foreground pressure: shrink gradually, the user is still looking at the app.
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> photoCache.trimTo(0.75)
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> photoCache.trimTo(0.5)
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> photoCache.trimTo(0.25)
// UI went away, or the process is a candidate for being killed: release everything.
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND,
ComponentCallbacks2.TRIM_MEMORY_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> photoCache.clear()
}
}
}
Matching exact values with when (level) keeps the two families separate, and the lifecycle observer guarantees the cache empties when the app is backgrounded even on releases that never deliver the legacy levels.
Decode for the view, not for the file
suspend fun decodeForTarget(context: Context, uri: Uri, targetW: Int, targetH: Int): Bitmap =
withContext(Dispatchers.IO) {
val source = ImageDecoder.createSource(context.contentResolver, uri)
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val scale = minOf(
targetW.toFloat() / info.size.width,
targetH.toFloat() / info.size.height,
1f,
)
decoder.setTargetSize(
(info.size.width * scale).toInt().coerceAtLeast(1),
(info.size.height * scale).toInt().coerceAtLeast(1),
)
}
}
If you use an image-loading library, the same rule applies. Give it the actual size of the view and keep its memory cache wired to trim callbacks. Most mature libraries already do this; hand-rolled loaders usually don’t.
Real-world failure modes
A few of these deserve more detail.
The singleton that remembers everything. A GalleryRepository object keeps a Map<Long, Bitmap> "for speed." It never shrinks, survives every configuration change, and is exactly what an inspector sees in the cached state. Store encoded bytes, URIs or file paths, and decode on demand.
Cache sizing that looks right on your test device. You tune on a phone with generous RAM and ship. On a low-memory device the same cache is a large share of the whole budget. Derive the budget from memoryClass and isLowRamDevice as above.
Recycling as a fix. Calling Bitmap.recycle() on an image that a view or cache still references trades a memory problem for a crash. Prefer dropping references and letting your cache own lifetimes.
Part 3: Make R8 earn its keep
The DEX requirement is the cheapest one to satisfy and the easiest to sabotage. Start with a release configuration that actually enables everything:
// build.gradle.kts (app)
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
Then audit your rules file. The pattern that quietly destroys coverage is a blanket keep:
# Looks harmless, disables most of what R8 can do for these packages
-keep class com.traillog.** { *; }
Replace it with rules scoped to what reflection or serialization genuinely needs:
-keepclassmembers class com.traillog.network.dto.** {
<init>(...);
<fields>;
}
After a build, look at the optimization insights Play Console now surfaces for each uploaded bundle. If your shrinker emits optimization metadata, you can see where coverage is low and trace it to the rules responsible. A useful habit: treat every new -keep line in code review as a cost that needs a justification.
Part 4: Zero-tap sign-in with Restore Credentials
When someone moves to a new phone, the old flow is: install the app, open it, see a login screen, remember which method they used, authenticate, maybe pass a second factor. The new requirement is that an app with sign-in, whether mandatory or optional, restores the signed-in state automatically on first launch via the Android Restore Credentials API.
The design has two halves. Your app registers a restore credential while the user is signed in on the old device. The platform carries it across during device setup. On the new device, your first launch asks for it before showing any login UI.
The API shapes below follow Credential Manager’s restore support. Call these from your Activity (pass the Activity as context), which is the safest choice for Credential Manager calls. Treat the exact class and parameter names as something to confirm against the current Restore Credentials documentation for the library version you use, because this surface is newer than most of Jetpack.
Dependency
Restore Credentials ships in the Credential Manager Jetpack library from version 1.5.0 onward, and Android’s documentation currently labels the feature a developer preview. Pin a version the docs list for your setup (their samples have shown 1.6.x pre-releases) and re-check it before release:
// build.gradle.kts (app) - confirm the current version in the Restore Credentials docs
implementation("androidx.credentials:credentials:1.6.0-rc01")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0-rc01")
Registering on the old device
class RestoreCredentialManager(private val api: AuthApi) {
// No Context is stored: the caller passes one per call, so a long-lived
// manager can never pin an Activity in memory.
/** Call after any successful sign-in AND on session refresh for existing users. */
suspend fun registerRestoreKey(context: Context, session: Session) {
val requestJson = api.restoreKeyRegistrationOptions(session.accessToken)
try {
CredentialManager.create(context).createCredential(
context = context,
request = CreateRestoreCredentialRequest(requestJson),
)
} catch (e: CreateCredentialException) {
// Non-fatal: the user simply won't get zero-tap on the next device.
Log.w("Restore", "registration failed: ${e.type}", e)
}
}
}
Restoring on the new device
sealed interface StartupAuth {
data class SignedIn(val session: Session) : StartupAuth
data object NeedsLogin : StartupAuth
}
suspend fun RestoreCredentialManager.tryRestore(context: Context): StartupAuth {
val authJson = api.restoreKeyAuthenticationOptions()
val request = GetCredentialRequest(listOf(GetRestoreCredentialOption(authJson)))
return try {
val result = CredentialManager.create(context).getCredential(context, request)
val session = api.exchangeRestoreAssertion(result.credential.data)
StartupAuth.SignedIn(session)
} catch (e: NoCredentialException) {
StartupAuth.NeedsLogin // normal: fresh install, no backup
} catch (e: GetCredentialException) {
StartupAuth.NeedsLogin // never strand the user
}
}
Note the shape: the restore attempt returns a sealed result, and every failure collapses to the ordinary login screen. Zero-tap is an optimization over login, never a replacement for it.
Real-world failure modes
Existing users are never enrolled. If you only register a credential at the moment of login, anyone already signed in when you ship the feature stays unprotected until they sign out and back in. Register on a silent refresh too, so your installed base is covered before their next upgrade.
Sign-out doesn’t clear the key. A user who deliberately signs out and later restores to a new phone should not be signed back in. Clear the restore credential in your sign-out path:
suspend fun signOut(context: Context) {
api.revokeSession()
CredentialManager.create(context).clearCredentialState(
ClearCredentialStateRequest(ClearCredentialStateRequest.TYPE_CLEAR_RESTORE_CREDENTIAL)
)
sessionStore.clear()
}
One trap here: a plain ClearCredentialStateRequest() with no type clears only non-restore credentials, so an existing clearCredentialState() call in your sign-out code does not remove the restore key. You need the typed request above in addition to it, and the system will not delete the key for you.
The restore call sits on the critical path. If your launch waits on a network round-trip, a cold start on the new device feels broken. Run it in a coroutine behind your splash screen with a sensible timeout, and fall back to login when it expires.
The server is the weak link. The key is only as safe as your validation. Bind it to the account, make revocation possible, allow re-issuance, and monitor redemption patterns. A restore credential that can be replayed indefinitely is a new attack surface, not a convenience.
A pre-flight checklist
Before the February and April dates arrive, I’d run through this in order:
- Add the
MemoryProbeto debug builds and record cold start, heaviest-screen and backgrounded readings on one low-RAM device. - Open Android vitals and review the new memory and bitmap views plus the out-of-memory filter.
- Convert every entry-count cache to a byte-sized one, flush it when the process leaves the foreground, and treat
onTrimMemoryas a hint rather than the trigger. - Confirm release builds minify and shrink, then audit every blanket
-keep. - Read Play Console’s DEX optimization insights for your latest bundle.
- Implement restore registration, restore on first launch, and cleanup on sign-out. Test the path with a real device transfer, not just unit tests.
Closing thoughts
None of this is exotic. Memory budgets reward the same discipline that good engineers already apply: own your lifetimes, size things by what they cost, and release work you can redo later. Zero-tap sign-in rewards another familiar habit, which is designing the unhappy path first, so that when the shortcut fails the user lands somewhere sensible.
The deadlines are months away, and the diagnostics are already in Play Console. Teams that start measuring now will treat February as a non-event.
Note: how Play computes the 25% DEX coverage figure is defined on Play’s help page, not by R8 itself, so use the Play Console insights as the source of truth for your numbers.
📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps
If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend “Mastering Android AI: App to Intelligence” It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.




