# ANDROID-ARCHITECT — Blog Archive > The complete archive of the 39 technical articles published at https://android-x.app/blog, written by Rocky Elsalaymeh. One entry per article, generated from the article source, newest first. As of 2026-10-18. Categories: Kotlin (6), Jetpack Compose (5), Architecture (4), Best Practices (4), Performance (4), Build Tools (3), Data Layer (3), Security (3), Testing (3), Accessibility (2), CI/CD (2). Product reference: https://android-x.app/llms-full.txt · short index: https://android-x.app/llms.txt. Each entry names the article's Markdown twin: the full text, code included, without the page chrome. --- ## Your UI Tests Pass While Your UI Breaks: Screenshot Testing for Compose - URL: https://android-x.app/blog/compose-screenshot-testing - Markdown: https://android-x.app/blog/compose-screenshot-testing.md - Category: Testing - Author: Rocky Elsalaymeh - Published: 2026-09-25 · Updated: 2026-10-11 - Reading time: 9 min read Semantic assertions cannot see a collapsed margin or a broken dark theme. Compare the three Compose screenshot tools, and wire golden images into CI. Key takeaways: - Compose UI tests assert semantics; a collapsed margin, a broken dark theme or a truncated label passes them. Screenshot tests are the layer that sees pixels. - Host-side tools render with Layoutlib (Compose Preview Screenshot Testing, Paparazzi) or Robolectric Native Graphics (Roborazzi); pick RNG when the state you need requires interaction. - Google now recommends configuring Compose Preview Screenshot Testing through AGP test suites (AGP 9.5.0-alpha03, engine 0.0.1-alpha16); the standalone plugin is deprecated and the tool is still experimental. - Thresholds cut both ways: too loose misses real regressions. Record and verify in the same CI environment so a tight threshold stays stable. - Keep goldens in Git LFS, pin the renderer version, and feed previews fixed data. - Upload the HTML diff report on failure and keep golden updates in the author’s pull request, where reviewers see the image next to the code. Sections: The Regression Your Test Suite Cannot See · Which screenshot testing tool should you use? · Google’s Tool Just Moved Into the Android Gradle Plugin · The Threshold Decides Whether the Suite Is Worth Running · Wiring It Into CI So Diffs Get Read · What to Screenshot, and What to Leave to Assertions Questions answered: - Q: Do Compose screenshot tests need an emulator? A: No. Compose Preview Screenshot Testing and Paparazzi render on the JVM with Layoutlib, and Roborazzi renders with Robolectric Native Graphics. All three run as host-side tests, with no emulator and no device. That is what makes them cheap enough to run on every pull request, where an instrumented test would be too slow. - Q: Should I use the standalone Compose Preview Screenshot Testing plugin? A: Not for new work. Google marks the standalone plugin deprecated from AGP 9.5.0-alpha03 and engine 0.0.1-alpha16, and recommends AGP test suites instead. Both paths are still experimental, so whichever you choose, pin the versions and expect the configuration to change between releases. Reference images should never depend on an unpinned renderer. - Q: Why do my screenshot tests fail on CI but pass locally? A: Rendering differs between machines, most often in fonts. Record the reference images and verify against them in the same CI environment, never on a laptop. Pin the renderer version, and remove anything that changes between runs from the previews under test, such as clocks and images loaded from the network. --- ## Your Crash Rate Is a Ranking Signal: Android Vitals, Wake Locks and the 2027 Memory Rules - URL: https://android-x.app/blog/android-vitals-bad-behavior-thresholds - Markdown: https://android-x.app/blog/android-vitals-bad-behavior-thresholds.md - Category: Performance - Author: Rocky Elsalaymeh - Published: 2026-09-22 · Updated: 2026-10-11 - Reading time: 10 min read Google Play demotes apps past a 1.09% crash or 0.47% ANR rate. Wake locks became a core vital in 2026, and memory limits arrive in February 2027. Key takeaways: - Google Play can reduce visibility and show store-listing warnings when an app exceeds a core vitals threshold, judged over the last 28 days. - The stability thresholds are 1.09% user-perceived crash rate and 0.47% ANR rate overall, and 8% for either on a single phone model. - Excessive partial wake locks became a core vital in November 2025, with discovery-surface penalties rolling out from March 1, 2026. - From February 2027, memory usage (Anonymous RSS + Swap, and bitmaps) and a 25% code-optimization bar affect store visibility. - ANRs fire at 5 seconds for input, startForeground and foreground broadcasts; ApplicationExitInfo (API 30+) recovers the trace on next launch. - Pull vitals through the Play Developer Reporting API, alert per device model, and gate staged rollouts on them. Sections: Stability Stopped Being an Engineering Metric · What are the Android vitals thresholds? · Wake Locks Joined the Core Vitals · Memory Is Next, and It Has a Date · ANRs: The Timeouts You Are Actually Racing · Watch Vitals Like a Production Metric Questions answered: - Q: What are the Android vitals bad behavior thresholds? A: A user-perceived crash rate of 1.09% and a user-perceived ANR rate of 0.47% overall, 8% for either on a single phone model, and 5% of sessions with excessive partial wake locks. The rates count affected daily active users, not events. Exceeding a threshold can reduce an app's visibility on Google Play. - Q: When do the Android vitals memory thresholds take effect? A: Google states that apps exceeding the memory usage, bitmap memory usage or code optimization thresholds may see an impact on store visibility starting from February 2027. Treat the months before that as the measurement window: find your current numbers in Android vitals now, while a regression is still only a number on a dashboard. - Q: What counts as an excessive wake lock? A: Google's 2025 definition is a user session that holds more than 2 cumulative hours of non-exempt wake locks in a 24 hour period. The threshold is crossed when 5% of an app's sessions over the last 28 days are excessive. Audio playback, location and user-initiated transfers are exempt from the count. --- ## Your Kotlin App Ships Native Code You Never Wrote: The 16 KB Page Size Deadline - URL: https://android-x.app/blog/android-16kb-page-size - Markdown: https://android-x.app/blog/android-16kb-page-size.md - Category: Build Tools - Author: Rocky Elsalaymeh - Published: 2026-09-18 · Updated: 2026-10-11 - Reading time: 10 min read From February 1, 2027 Google Play will block updates that do not support 16 KB pages. The native code at risk usually sits inside an SDK you never compiled. Key takeaways: - Google Play will block app updates that do not support 16 KB memory page sizes from February 1, 2027, for apps targeting Android 15 and higher. - Pure Kotlin and Java apps are exempt only if every library and SDK is also free of native code; most production apps ship .so files from third-party SDKs. - Three things break: ELF LOAD segments aligned below 2**14, uncompressed libraries not 16 KB zip-aligned, and native code that assumes a 4096-byte page. - AGP 8.5.1+ and NDK r28+ make your own builds 16 KB compatible by default; on AGP 8.3 to 8.5 an app can pass locally and fail to install from Play. - Prebuilt libraries inside vendor SDKs can only be fixed by the vendor; inventory them and escalate now. - Gate CI with llvm-objdump and zipalign -c -P 16, and test on a 16 KB emulator image or a Pixel in 16 KB mode. Sections: The Requirement Hiding in Your Dependency Tree · Why is Android changing its page size? · What Actually Breaks · Find It Before Google Play Does · Fix It in Order: Toolchain, Dependencies, Code · Under Four Months Is Less Time Than It Sounds Questions answered: - Q: Does a Kotlin-only Android app need to support 16 KB pages? A: Only if nothing it includes ships native code. Google's guide exempts apps that use only Java or Kotlin, including all libraries or SDKs, and that last clause is where most apps fail. Open the APK, look in the lib/ folder for .so files, and assume any you find came from a dependency. - Q: What is the 16 KB deadline on Google Play? A: Google's page size guide states that from February 1, 2027, app updates that do not support 16 KB memory page sizes cannot be released. The rule covers apps targeting Android 15 (API level 35) and higher. One unaligned native library in any dependency is enough to stop an update, so audit before the date. - Q: Which tool versions support 16 KB pages by default? A: Android Gradle plugin 8.5.1 or higher zip-aligns uncompressed native libraries to 16 KB, and NDK r28 or higher compiles 16 KB-aligned libraries by default. With an older NDK, pass the -z max-page-size=16384 linker flag yourself. A toolchain upgrade fixes your own code. Third-party libraries still have to be updated by their authors. --- ## Accessibility Is Now a Market-Access Rule: The European Accessibility Act for Android Teams - URL: https://android-x.app/blog/european-accessibility-act-android-apps - Markdown: https://android-x.app/blog/european-accessibility-act-android-apps.md - Category: Accessibility - Author: Rocky Elsalaymeh - Published: 2026-09-15 · Updated: 2026-10-11 - Reading time: 10 min read The EAA has covered EU banking, e-commerce, transport and e-book apps since June 2025, and its standard now points to WCAG 2.2. What Android teams must ship. Key takeaways: - Since 28 June 2025 the EAA has covered consumer banking, e-commerce, e-books, passenger transport and electronic communications services, explicitly including their mobile apps. - Only microenterprises providing services (fewer than 10 persons, EUR 2 million or less turnover or balance sheet) are exempt; legacy contracts run at most five years and products used before the date only until 28 June 2030. - EN 301 549 v4.1.1, published September 2026, moves the benchmark to WCAG 2.2; v3.2.1 (WCAG 2.1 AA) remains the cited reference until v4.1.1 is cited in the Official Journal. - Web views inside an app are assessed as software under clause 11, not as web pages. - Penalties are national: up to EUR 100,000 in Germany, and a criminal offence with fines up to EUR 60,000 and up to 18 months in Ireland. - Turn on enableAccessibilityChecks() in Compose tests and AccessibilityChecks in Espresso so common failures break the build. Sections: The Backlog Item That Became a Legal Requirement · Are you in scope? · What "Accessible" Means, and Why the Answer Just Changed · Enforcement Is National, and Some of It Is Criminal · The Android Checklist, in Compose · Make Accessibility a Build Gate, Not an Audit Questions answered: - Q: Does the European Accessibility Act apply to Android apps? A: Yes, for in-scope consumer services. The directive names mobile device-based services, including mobile applications, for passenger transport, and its e-commerce definition covers services concluded through mobile apps. Consumer banking, e-books and electronic communications are also in scope. If your app sells to consumers in the European Union, assume it applies and then check the exemptions. - Q: Which WCAG version does the EAA require? A: The directive sets outcomes, and the harmonised standard is EN 301 549. Version 3.2.1, based on WCAG 2.1 Level AA, remains the legal reference until the Commission cites version 4.1.1 in the Official Journal. Version 4.1.1 was published in September 2026 and adopts WCAG 2.2, so that is the level to build to now. - Q: Can Compose tests check accessibility automatically? A: Yes, from Compose 1.8.0. Add the ui-test-junit4-accessibility dependency and call enableAccessibilityChecks() on the AndroidComposeTestRule. After that, any action in an existing UI test, such as performClick, also runs the Accessibility Test Framework checks. Automation finds missing labels, small targets and low contrast. It does not replace a manual pass with a screen reader. --- ## API 36 Is Now Required on Google Play: The Android 16 Changes That Break Real Apps - URL: https://android-x.app/blog/target-sdk-36-android-16-migration - Markdown: https://android-x.app/blog/target-sdk-36-android-16-migration.md - Category: Best Practices - Author: Rocky Elsalaymeh - Published: 2026-07-28 · Updated: 2026-10-11 - Reading time: 9 min read Google Play requires API 36 for new apps and updates from August 31, 2026. Edge-to-edge, predictive back and large screens are where migrations break. Key takeaways: - Google Play requires new apps and updates to target API 36 from August 31, 2026, with an extension available to November 1, 2026. - For apps targeting API 36, the edge-to-edge opt-out is deprecated and disabled: backgrounds go edge to edge, touch targets take safe-drawing insets. - onBackPressed is no longer called and KEYCODE_BACK is not dispatched; move back handling to OnBackPressedCallback or BackHandler, enabled only while it has something to intercept. - Orientation and resizability locks are ignored on 600dp+ displays for API 36; the temporary opt-out ends at API 37, which Play requires in August 2027. - The job runtime quota and cross-process broadcast priority changes apply to all apps on Android 16, whatever their targetSdk. - Bump on a branch first, fix back handling before anything else, and ship the target bump behind a staged rollout. Sections: A Build Number That Changes Behaviour · Edge-to-Edge: The Escape Hatch Is Welded Shut · Predictive Back: onBackPressed Goes Silent · Large Screens: Your Orientation Lock Is Ignored · The Quieter Changes That Still Cost a Day · What is the plan if the deadline has already passed? Questions answered: - Q: When must Android apps target API 36 on Google Play? A: New apps and app updates have had to target Android 16 (API level 36) since August 31, 2026. Wear OS and Android Automotive OS apps need API level 35, and Android TV and Android XR apps need API level 34. Google allows a request for an extension to November 1, 2026 through Play Console. - Q: Why does my back confirmation dialog no longer appear on Android 16? A: For apps that target API 36 running on Android 16, the platform no longer calls onBackPressed and no longer dispatches KEYCODE_BACK. A confirmation dialog implemented there never runs, and no test fails. Move the logic to an OnBackPressedCallback, or to BackHandler in Compose, which the predictive back system does call. - Q: Can I still opt out of edge-to-edge? A: Not when targeting API 36. The windowOptOutEdgeToEdgeEnforcement attribute is deprecated and disabled at that level, so the app draws edge-to-edge and has to handle window insets itself. The attribute still has an effect when the same app runs on an Android 15 device, so test on Android 16 before you ship. --- ## Your Phone App Is Already a Tablet App: Adaptive Layouts in Jetpack Compose - URL: https://android-x.app/blog/compose-adaptive-layouts-large-screens - Markdown: https://android-x.app/blog/compose-adaptive-layouts-large-screens.md - Category: Jetpack Compose - Author: Rocky Elsalaymeh - Published: 2026-06-09 · Updated: 2026-10-11 - Reading time: 10 min read Android 16 ignores orientation and resize locks on screens 600dp and wider. Window size classes and canonical layouts are how a phone app survives it. Key takeaways: - For apps targeting API 36, Android ignores orientation, resizability and aspect-ratio restrictions on displays 600dp and wider; the temporary opt-out disappears at API 37. - Google Play ranks large-screen-optimized apps higher and shows a warning on listings that miss its large-screen quality bar. - Branch on window size classes from currentWindowAdaptiveInfo(), never on device model, density or orientation. - Use isWidthAtLeastBreakpoint() with the WIDTH_DP_*_LOWER_BOUND constants; the windowWidthSizeClass enum is deprecated. - Reach for the canonical layouts first: NavigableListDetailPaneScaffold and NavigationSuiteScaffold cover most screens without bespoke tablet design. - Fold, unfold and resize are configuration changes: keep UI state in rememberSaveable or a ViewModel, and assert it with StateRestorationTester. Sections: The Lock on Your Activity Stopped Holding · Why is Google forcing the issue? · Window Size Classes: Branch on Space, Never on Device · Canonical Layouts: Stop Inventing a Tablet UI · State Has to Survive the Fold · A Migration Order That Actually Ships Questions answered: - Q: Does Android 16 ignore screenOrientation for every app? A: No. It applies to apps that target API 36, on displays whose smallest width is at least 600dp. Games declared with android:appCategory are exempt, and so are smaller screens. A temporary opt-out exists, and Google states that it will not apply when targeting API level 37, so it buys one release cycle and no more. - Q: Should I check whether the device is a tablet? A: No. Read the window size class from currentWindowAdaptiveInfo() and branch on the space the window has. A foldable, a tablet in split screen and a resizable desktop window all change the available space without changing the device, so a check on the device model or the density gives the wrong layout in each case. - Q: What is the fastest route to Google’s Tier 3 “Adaptive ready”? A: Remove the orientation and resizability locks, run the app on a resizable emulator and fix what breaks. Keep UI state in rememberSaveable or a ViewModel so that a fold or a resize does not lose it. Then replace a stretched bottom bar with NavigationSuiteScaffold, which switches between a bar and a rail by window size class. --- ## You Cannot Recall an APK: Staged Rollouts and Release Automation on Google Play - URL: https://android-x.app/blog/play-staged-rollouts-release-automation - Markdown: https://android-x.app/blog/play-staged-rollouts-release-automation.md - Category: CI/CD - Author: Rocky Elsalaymeh - Published: 2026-05-12 · Updated: 2026-10-11 - Reading time: 10 min read A bad Android release cannot be pulled back off a phone that installed it. Staged rollouts, halts and the Publishing API decide how many phones that is. Key takeaways: - Android blocks versionCode downgrades, so users who installed a bad build can only be fixed by a new build with a higher versionCode. - Play can now halt a fully rolled-out release and serve the previous one to new users, but it cannot remove the bad build from phones that already have it. - Staged rollouts never advance on their own, apply only to updates, and resume to the same user cohort after a halt. - Gate each promotion on vitals: Play shows hourly crash and ANR rates for a new release’s first days, and the bad-behavior thresholds are 1.09%, 0.47% and 8% per device. - Automate the ladder with the Publishing API or Gradle Play Publisher, and set changesInReviewBehavior explicitly; the default cancels changes in review. - Ship fix-forward releases with in-app update priority 5 so users on the bad build see them as critical. Sections: There Is No Undo on a Phone · How does a staged rollout really behave? · Let Vitals, Not the Calendar, Promote a Release · Automate the Ladder with the Publishing API · Fix Forward, Fast: Update Priority and Managed Publishing · The Release Checklist That Assumes You Will Ship a Bug Questions answered: - Q: Can I roll back an Android app update on Google Play? A: Not for users who already installed it. Android blocks versionCode downgrades, so the fix is a new release with a higher versionCode. Play can halt a release, including one already at 100%, so that new and eligible users receive the previous version. A halt stops the spread. It does not reach phones that have already updated. - Q: Does a staged rollout increase automatically? A: No. Google's Play Console documentation states that a staged rollout percentage won't increase automatically. Someone, or something, has to promote it: by hand in Play Console, or through the Publishing API from your pipeline. Decide the steps and the vitals threshold for each one before the release, not while watching the dashboard. - Q: What userFraction completes a rollout through the API? A: None. In the Publishing API a userFraction must be greater than 0 and less than 1, and it can only be set while the release status is inProgress or halted. You finish a rollout by changing the status to completed. A fraction of 1 is outside the allowed range, so do not send it. --- ## SafetyNet Is Gone. Play Integrity Is Only as Strong as Your Server - URL: https://android-x.app/blog/play-integrity-api-android - Markdown: https://android-x.app/blog/play-integrity-api-android.md - Category: Security - Author: Rocky Elsalaymeh - Published: 2026-04-21 · Updated: 2026-10-11 - Reading time: 10 min read SafetyNet Attestation was turned down in January 2025. Play Integrity replaces it, but a verdict checked on the client, or at one tier, protects almost nothing. Key takeaways: - SafetyNet Attestation was fully turned down in January 2025; every attest call now fails with a network error, so a fail-open handler means zero protection. - A Play Integrity verdict has five parts (request, app, device, account, environment); collapsing them into one boolean discards most of the signal. - Use standard requests with a warm-up and a requestHash bound to the protected action; reserve classic requests and nonces for occasional high-value checks. - On Android 13 and higher, MEETS_STRONG_INTEGRITY now requires a security update within the last year, so it is partly a patch-recency check. - Decrypt and decide on the server, never in the client, and respond in tiers: allow, allow with limits, challenge, deny. - Plan for the 10,000 requests/day default quota, ship the remediation dialogs, and consider device recall for repeat-abuse detection. Sections: The Security Check That Quietly Stopped Working · What does a Play Integrity verdict actually tell you? · Standard or Classic: Pick by Frequency, Not by Habit · The Device Verdict Changed Meaning Under You · Decide on the Server, and Decide in Tiers · The Operational Details That Bite in Production Questions answered: - Q: Does SafetyNet Attestation still work? A: No. Google's deprecation timeline says the SafetyNet Attestation API was deprecated in 2022 and fully turned down in January 2025. The attest call now always fails with an ApiException and status code 7, NETWORK_ERROR. Code that treats that error as a pass has been checking nothing since then. Migrate to the Play Integrity API. - Q: Can I check the Play Integrity verdict inside the app? A: No. Send the opaque token to your server, decrypt and verify it there, and make the decision there. A verdict evaluated inside the app can be patched out by anyone who modifies the app, which is exactly the person the check exists to stop. The app only requests the token and passes it on. - Q: Should I require MEETS_STRONG_INTEGRITY for every user? A: Usually not. On Android 13 and higher the strong verdict also requires a security update within the last year, so it excludes genuine devices whose manufacturers stopped shipping patches. Reserve it for high-value actions such as a transfer, and respond in tiers elsewhere: allow, add friction or block, depending on the verdict. --- ## Implementing Passkeys and Biometric Auth in Android with Credential Manager - URL: https://android-x.app/blog/credential-manager-passkeys-android - Markdown: https://android-x.app/blog/credential-manager-passkeys-android.md - Category: Security - Author: Rocky Elsalaymeh - Published: 2026-04-07 · Updated: 2026-10-11 - Reading time: 11 min read Passkeys replace passwords with phishing-resistant public-key credentials. Credential Manager offers passkeys and saved passwords through one Android API. Key takeaways: - A passkey has no shared secret: the device holds the private key and the server stores only the public key. - Passkeys through Credential Manager work on Android 9 (API level 28) and higher, and require Digital Asset Links. - Create the CredentialManager with any context, and launch flows with an activity-based context wrapped in MutableContextWrapper. - One getCredential request can offer passkeys and saved passwords. NoCredentialException is the sign-up path, not an error. - WebAuthn requires challenges to be randomly generated on the server. Use a maintained server library to verify responses. - Keep a password fallback, and make account recovery at least as strong as sign-in. Sections: Why are passkeys replacing passwords? · Setting Up Credential Manager · Passkey Registration: Creating Credentials · Passkey Authentication: Signing In · ViewModel Integration and UI Flow · Security Considerations and Production Checklist Questions answered: - Q: Why are passkeys more secure than passwords? A: A passkey has no shared secret. The device keeps a private key and the server stores only a public key, so a breach of the server does not expose anything that can sign in. Android describes passkeys as built on standard protocols that are not subject to phishing attacks, because the credential is bound to the genuine site. - Q: Can I generate the WebAuthn challenge on the client? A: No. The WebAuthn specification says challenges must be randomly generated by the relying party in an environment it trusts, and gives the server side as the example. The protocol depends on randomized challenges to avoid replay attacks. Generate each one on your server, use it once, and check the response against it. - Q: Should I remove password login once passkeys ship? A: Not yet. Passkeys through Credential Manager need Android 9 or higher, and many users will not have created one. A single getCredential request can offer saved passwords beside passkeys, so keeping the fallback is cheap. Invite users to create a passkey after they sign in with a password. --- ## Version Catalogs and Convention Plugins: Scaling Gradle for Multi-Module Android Projects - URL: https://android-x.app/blog/version-catalogs-convention-plugins - Markdown: https://android-x.app/blog/version-catalogs-convention-plugins.md - Category: Build Tools - Author: Rocky Elsalaymeh - Published: 2026-04-04 · Updated: 2026-10-11 - Reading time: 13 min read Version catalogs centralize dependency declarations; convention plugins extract shared build logic. Together they end copy-paste config in multi-module Android. Key takeaways: - A version catalog (gradle/libs.versions.toml) has four sections: versions, libraries, bundles and plugins. Modules reach it through generated libs accessors. - A catalog makes every module request the same version. It does not enforce what Gradle resolves; a platform or BOM does that. - A convention plugin holds the build logic that repeats across modules, and the simplest form is a precompiled script plugin named after its plugin id. - The build-logic included build does not inherit the catalog. Import gradle/libs.versions.toml in its own settings file. - AGP 9 compiles Kotlin itself. Remove the kotlin-android plugin and the jvmTarget block from Android convention plugins. - Adopt in phases: catalog, one convention plugin, the rest, then a CI check that stops drift from coming back. Sections: The Multi-Module Build Configuration Problem · What does a Gradle version catalog give you? · Convention Plugins: Extracting Shared Build Logic · Wiring It Together: The Feature Module Build File · Migration Strategy: Incremental Adoption Questions answered: - Q: What problem do Gradle version catalogs solve? A: They put every dependency coordinate and version in one file, gradle/libs.versions.toml, and give build scripts generated, type-safe accessors to it. Every module then requests the same version. They do not enforce the version Gradle finally resolves: the Gradle documentation says a catalog declares requested versions but does not enforce them. - Q: What is a convention plugin? A: A Gradle plugin that applies your own defaults to existing plugins, so a module states them once by id. For Android that usually means compileSdk, minSdk, Java compatibility and the test runner. The simplest form is a precompiled script plugin: a .gradle.kts file in a build-logic build whose file name becomes the plugin id. - Q: Do I still apply the kotlin-android plugin in a convention plugin? A: Not on Android Gradle plugin 9.0 or later. AGP 9 has built-in Kotlin support, enabled by default, and applying org.jetbrains.kotlin.android now fails with a message that the plugin is no longer required. Remove it from the convention plugin, from every module build file, and from the plugins section of the version catalog. --- ## Baseline Profiles and R8: Advanced Android Performance Tuning - URL: https://android-x.app/blog/baseline-profiles-r8-optimization - Markdown: https://android-x.app/blog/baseline-profiles-r8-optimization.md - Category: Performance - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 12 min read Baseline Profiles compile critical code paths before first launch, and R8 shrinks and optimizes the rest. What each does, how to set them up, what to delete. Key takeaways: - Baseline Profiles improve code execution speed by about 30% from the first launch, according to Android. Measure your own app before quoting a number. - Profiles are only installed for release builds, and the generator needs a rooted device or one on API 33 or higher. - Mark startup tests with includeInStartupProfile so the same run also produces a Startup Profile. - R8 full mode has been the default since AGP 8.0. From AGP 9.3, optimization is enabled with the optimization block. - Delete -optimizationpasses and -mergeinterfacesaggressively from old rules files. R8 ignores them without a warning. - Keep rules should be as specific as possible. A package-wide keep undoes the optimization you turned on. Sections: What do Baseline Profiles and R8 actually do? · Generating Baseline Profiles with Macrobenchmark · Writing Profile Generator Rules · R8: What an Old Rules File Gets Wrong · Measuring Impact: Before and After Questions answered: - Q: Do I have to regenerate the Baseline Profile for every release? A: It should match the code you ship. Setting automaticGenerationDuringBuild to true triggers the generation of a new profile for each release assembly, at the cost of a device or emulator in the build. The alternative is to run the generateBaselineProfile task when critical journeys change and commit the result with saveInSrc. - Q: Can I generate a Baseline Profile from an obfuscated build? A: No. Android states that when capturing a Baseline Profile with a test, your app must not be obfuscated, so the rules match real method signatures. The release build stays obfuscated: R8 rewrites the profile rules to the obfuscated names, and the Baseline Profile Gradle plugin sets up the non-minified variant for you. - Q: Which ProGuard options does R8 ignore? A: Android lists the options R8 ignores without reporting a warning, including -optimizationpasses, -mergeinterfacesaggressively, -overloadaggressively, -dontpreverify and -verbose. They have no effect on the output, so remove them. Two other common ones, -repackageclasses and -allowaccessmodification, are already the default on current Android Gradle plugin versions. --- ## Clean Architecture on Android: Building a Scalable Domain Layer - URL: https://android-x.app/blog/clean-architecture-android - Markdown: https://android-x.app/blog/clean-architecture-android.md - Category: Architecture - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 14 min read Implement Clean Architecture with use cases, repository abstractions, and clear dependency rules that make your Android app testable and framework-independent. Key takeaways: - Clean Architecture and Google's app architecture are different things. Google's own page says its domain layer has a different meaning. - The dependency rule: the domain declares repository interfaces and the data layer implements them. Enforce it with a Gradle module that has no Android plugin. - Use cases do one thing, hold no state and are main-safe. Add them where there is a rule to enforce, not for every read. - coroutineScope fails when a child fails. Decide explicitly whether a side effect is allowed to fail the operation. - The database is the single source of truth. Observe it, and refresh from the network as a separate call. - Mappers keep the API shape, the database shape and the domain type apart, so an API change touches one file. Sections: Is Clean Architecture the same as Google's app architecture? · The Domain Layer: Entities and Use Cases · Use Cases: Single-Responsibility Business Operations · The Data Layer: Repository Implementations · The Presentation Layer: ViewModels and UI State · Module Structure and Dependency Injection Questions answered: - Q: What is the dependency rule in Clean Architecture? A: Inner layers never depend on outer ones. The domain declares the repository interfaces it needs, and the data layer implements them, so the domain knows nothing about Retrofit, Room or Android. Put the domain in a Gradle module with no Android plugin and a violation becomes a compile error, not a review comment. - Q: Is Clean Architecture what Google's architecture guide recommends? A: No. Google's guide has an optional domain layer whose use cases depend on repository classes in the data layer, and it warns readers not to confuse its domain layer with the one in clean architecture. Clean Architecture inverts that dependency: the data layer depends on the domain. Choose deliberately; both work. - Q: Do I need a use case for every operation? A: No. Google's domain layer page says forcing every call through a use case adds complexity for little benefit when the use case is a simple call to the data layer, and suggests adding use cases only when required. Write one where there is a business rule or several collaborators to coordinate. --- ## Jetpack Compose Testing: Strategies for Bulletproof UI - URL: https://android-x.app/blog/compose-testing-strategies - Markdown: https://android-x.app/blog/compose-testing-strategies.md - Category: Testing - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 12 min read Master Compose UI testing with semantic matchers, test tags, screenshot testing, and a pragmatic strategy that balances coverage, speed, and maintenance cost. Key takeaways: - Compose tests query the semantics tree, the same structure accessibility services read. - The v1 test entry points are deprecated. The v2 versions live in androidx.compose.ui.test.junit4.v2 and use StandardTestDispatcher. - Tests are synchronized with Compose automatically. Use waitUntil for work Compose cannot see. - Make screens stateless and test them by passing state. A fake ViewModel is rarely needed. - Use screenshot tests for visual attributes: the official preview tool, or Roborazzi for captures inside a test. - Android recommends many small tests and few big ones. It gives no percentages, and neither should a strategy document. Sections: Why Compose Changes the Testing Game · Which Compose test APIs are current? · Finders, Actions and Waiting · Test the Screen, Not the ViewModel · Screenshot Testing · The Testing Pyramid for Compose Apps Questions answered: - Q: How much Compose UI testing is enough? A: Enough that each screen is exercised in every state it can show, and each design system component has a screenshot. Keep logic in unit tests, where it is cheap to cover. Reserve end-to-end tests for a handful of journeys. Android guidance is many small tests and relatively few big ones. - Q: How do I test asynchronous work in a Compose test? A: Recomposition and animation are synchronized for you: an action or assertion waits until Compose is idle. For work outside Compose, such as a data load, use waitUntil with a condition or a helper like waitUntilExactlyOneExists. Never sleep. Under the v2 APIs coroutines are queued, so assert through the test rule. - Q: Do I need to migrate to the Compose v2 testing APIs? A: Yes, in time. The documentation calls the v1 entry points deprecated and strongly recommends migrating. The change is an import, to the v2 package, plus fixes for tests that assumed coroutines run eagerly. Most tests that act and assert through the test rule keep passing without edits. --- ## Kotlin Serialization: The Definitive Guide to JSON Parsing on Android - URL: https://android-x.app/blog/kotlin-serialization-guide - Markdown: https://android-x.app/blog/kotlin-serialization-guide.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 14 min read Replace Gson with Kotlin Serialization: reflectionless, multiplatform JSON parsing that enforces nullability and defaults when it decodes, not silently later. Key takeaways: - Gson states in its own README that Kotlin non-null types and default arguments are not supported. That is the reason to move. - Kotlin Serialization checks a payload when it is decoded, not at compile time. A mismatch is an exception at the boundary. - A missing property without a default throws, null for a non-null property throws, and unknown keys throw unless ignoreUnknownKeys is set. - For sealed hierarchies, mark every subtype @Serializable and give each a @SerialName so no package name reaches the wire. - The plugin version is your Kotlin version. The runtime versions separately and is 1.11.0 as of October 2026. - Do not chain the Gson and Kotlin Serialization converters on one Retrofit instance. Each claims every type. Use two instances. Sections: Why move off Gson? · Setup and Gradle Configuration · What is checked when a payload is decoded? · Sealed Classes and Polymorphic Serialization · Retrofit and Ktor Integration · Custom Serializers and Migration Strategies Questions answered: - Q: Does Kotlin Serialization validate my models at compile time? A: No. The compiler plugin generates serializers at compile time, and the payload is checked when it is decoded. A missing required property throws MissingFieldException, and null for a non-null property is an error even when the property has a default. You still need tests against real payloads to catch a mismatch before users do. - Q: Do I need ProGuard or R8 keep rules for Kotlin Serialization? A: Usually not. The library README states that proguard rules are supplied with the library and keep serializers for all serializable classes retained after shrinking. The documented exception is serializable classes with named companion objects, which need extra rules that differ between R8 compatibility mode and full mode. - Q: Can Gson and Kotlin Serialization share one Retrofit instance during a migration? A: Not usefully. Retrofit documents that each of these converters assumes it can handle all types, so whichever is added first receives every request and response and the second is never consulted. Create two Retrofit instances that share one OkHttpClient, and move API interfaces from the Gson instance to the new one a few at a time. --- ## Paging 3 with Compose: Infinite Lists Done Right - URL: https://android-x.app/blog/paging3-compose-infinite-lists - Markdown: https://android-x.app/blog/paging3-compose-infinite-lists.md - Category: Data Layer - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 13 min read Efficient infinite scrolling with Paging 3: RemoteMediator for offline-first pagination, Compose LazyColumn integration, and loading and error state handling. Key takeaways: - Paging loads a list a page at a time. By default it never drops loaded pages: set PagingConfig.maxSize if you want memory bounded. - With a page-number API keep the request limit constant. Passing params.loadSize makes page 2 overlap a larger initial load. - RemoteMediator writes network pages into Room, and the UI reads only from Room. The database is the source of truth. - RemoteMediator.load() does not run in a transaction. Open one yourself around the clear and the insert. - prefetchDistance defaults to pageSize and enablePlaceholders defaults to true. - Always finish the Pager flow with cachedIn(viewModelScope), or the data cannot be collected again. Sections: Why does Paging 3 exist? · PagingSource: Loading Pages from an API · RemoteMediator: Offline-First Pagination · Wiring It Together: Pager and ViewModel · Compose Integration: LazyColumn with Loading States Questions answered: - Q: Should I use Paging 3 or write pagination by hand? A: Use Paging when the list is long enough that loading it whole is wasteful. The library provides in-memory caching, request deduplication, and built-in refresh and retry, all of which a hand-written solution has to reproduce. Writing it yourself is reasonable only for a short list that fits comfortably in one request. - Q: How do I update an item in a paged list? A: With a RemoteMediator, write the change to the Room table. The database is the source of truth, and a write invalidates the current PagingData so Paging presents the updated rows. With a network-only PagingSource there is no table to write to, so call refresh() on the LazyPagingItems to load the data again. - Q: Does Paging 3 free memory as the user scrolls? A: Not by default. PagingConfig.maxSize defaults to MAX_SIZE_UNBOUNDED, and the reference states that pages will then never be dropped. Set maxSize to bound the list. Pages inside the prefetch window, defined as pageSize plus twice prefetchDistance, are never dropped, so maxSize must leave room for that window. --- ## WorkManager Mastery: Reliable Background Processing on Android - URL: https://android-x.app/blog/workmanager-background-processing - Markdown: https://android-x.app/blog/workmanager-background-processing.md - Category: Best Practices - Author: Rocky Elsalaymeh - Published: 2026-03-27 · Updated: 2026-10-11 - Reading time: 13 min read Build robust background work with WorkManager: chained operations, constraints, retry policies, and real-world patterns for sync, upload, and cleanup tasks. Key takeaways: - WorkManager persists work across app restarts and device reboots and recovers it after a force-stop. On-demand initialization may delay that recovery. - WorkManager 2.12.0 raised minSdk to API 24. Advice about fallbacks for older devices is obsolete. - Periodic work has a 15 minute minimum interval, and retry backoff defaults to exponential from 30 seconds with a 10 second floor. - A constraint that becomes unmet stops a running worker. Write workers that can be stopped and run again. - A failure fails that request and its dependents, not the work that already succeeded. - Parallel parents overwrite each other on shared keys unless the child uses ArrayCreatingInputMerger. Sections: What problem does WorkManager solve? · Defining Workers: OneTime and Periodic · Constraints: Running Work at the Right Time · Chaining Work: Complex Pipelines · Observing Work Progress in Compose Questions answered: - Q: Should I use WorkManager, JobScheduler or AlarmManager? A: Use WorkManager for work that must finish even if the user leaves the screen, the app exits or the device restarts, such as sync and uploads. Its documentation says it is not meant for in-process work that can safely die with the app, and it is not a general tool for work that must start immediately. - Q: Why is my work not running immediately? A: WorkManager runs work when its constraints are met and the system allows it, so the start time is not yours to set. Periodic work cannot repeat faster than every 15 minutes. A request that returned retry waits for its backoff delay, which starts at 30 seconds by default and grows exponentially. - Q: What happens when one worker in a chain fails? A: The failed request and every request that depends on it are marked FAILED, and the dependents never run. Work earlier in the chain that already succeeded keeps its result. The same rule applies to cancellation: dependents of a cancelled request are marked CANCELLED and are not executed. --- ## Kotlin Context Parameters: Eliminating Boilerplate in Android Architecture Layers - URL: https://android-x.app/blog/kotlin-context-receivers - Markdown: https://android-x.app/blog/kotlin-context-receivers.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-03-18 · Updated: 2026-10-11 - Reading time: 11 min read Context parameters replaced context receivers and became stable in Kotlin 2.4. How to use them for Logger, Analytics and Clock without bloating every signature. Key takeaways: - Context receivers were replaced by context parameters, previewed in Kotlin 2.2.0 and stable since Kotlin 2.4.0. - A context parameter has a name and is used by name. It is not an implicit receiver inside the function body. - Resolution is by type at the call site. Two matching values at the same scope level is a compile error. - Constructors cannot declare context parameters, so constructor injection and Hilt stay exactly where they are. - Provide values with one context(a, b, c) call. Nested with blocks work and read worse. - On the JVM a context parameter compiles to an additional function parameter. There is no runtime lookup. Sections: Context receivers are gone. What replaced them? · Context Parameters: The Basics · Where they fit in an Android codebase · ViewModel Integration Pattern · Testing with Context Parameters Questions answered: - Q: Are context receivers stable? A: No, and they never will be. Context receivers were an experimental prototype that context parameters replaced. Context parameters were previewed in Kotlin 2.2.0 and became stable in Kotlin 2.4.0, released on July 14, 2026. Explicit context arguments and callable references are the two parts still outside the stable set in that release. - Q: Do context parameters replace dependency injection? A: No. The Kotlin documentation states that constructors cannot declare context parameters, so a class still receives its collaborators through its constructor and Hilt still builds the graph. Context parameters work one level down: they let a function receive a logger or a clock from its caller without every signature in between naming it. - Q: What is the difference between a context parameter and a context receiver? A: A context receiver made its members available unqualified inside the function, as an implicit receiver. A context parameter is declared as name: Type and you access it through that name. The Kotlin release notes call this the main difference. Old samples that call log() directly must be rewritten as logger.log(). --- ## Compose Animation Masterclass: From Micro-Interactions to Shared Element Transitions - URL: https://android-x.app/blog/compose-animation-masterclass - Markdown: https://android-x.app/blog/compose-animation-masterclass.md - Category: Jetpack Compose - Author: Rocky Elsalaymeh - Published: 2026-03-16 · Updated: 2026-10-11 - Reading time: 15 min read Build polished, performant Jetpack Compose animations with animate*AsState, AnimatedVisibility, shared element transitions, gestures, and physics springs. Key takeaways: - Pick the highest-level API that fits: animate*AsState, AnimatedVisibility, AnimatedContent, then Animatable for gestures. - Apply animated values in lambda modifiers such as graphicsLayer { } so they are read in draw, not composition. - Animate list items with keys and Modifier.animateItem(), not with per-row visibility flags. - Shared elements need SharedTransitionLayout, the same key on both sides, and the animating scope passed to each. - Launch gesture animations inside pointerInput with coroutineScope. Never create a CoroutineScope per drag event. - Use spring for anything a user can interrupt: it keeps velocity continuous where a tween cannot. Sections: Which Compose animation API should you use? · Micro-Interactions with animate*AsState · AnimatedVisibility: Enter and Exit Choreography · Shared Element Transitions · Gesture-Driven Animation with Animatable · Performance: Keeping Animation Off the Recomposition Path Questions answered: - Q: How do I avoid animation performance problems in Compose? A: Read the animated value as late as possible. Apply it inside a lambda modifier such as graphicsLayer or offset, so the read happens during draw or layout and composition is skipped. Prefer scale, translation and alpha to animating size or padding, which forces a layout pass on every frame of the animation. - Q: Should I use spring or tween? A: Use spring for anything a user can interrupt, such as a press, a drag release or a toggle. The documentation notes that spring handles interruption more smoothly because it keeps velocity continuous. Use tween when you need an exact duration, for example to line an animation up with another timed event. - Q: How do shared element transitions work with navigation? A: Wrap the NavHost in a SharedTransitionLayout and pass its scope to each screen, together with the destination scope, which you reference as this@composable. Apply Modifier.sharedElement with the same key on the matching composable in both screens. Compose then animates position and size between the two automatically during the navigation. --- ## Feature Flags in Android: Ship Faster with Remote Config and Gradual Rollouts - URL: https://android-x.app/blog/android-feature-flags - Markdown: https://android-x.app/blog/android-feature-flags.md - Category: Best Practices - Author: Rocky Elsalaymeh - Published: 2026-03-14 · Updated: 2026-10-11 - Reading time: 11 min read Implement a production-grade feature flag system using Firebase Remote Config, local overrides, and gradual rollouts to de-risk Android releases. Key takeaways: - A flag separates shipping code from switching it on. A Play staged rollout controls who gets a build; a flag controls what the build does. - Hide the provider behind one interface so screens, tests and QA overrides never touch the Firebase SDK. - Firebase advises against activating new values where the UI could change noticeably mid-use. Activate structural flags on the next startup. - ConfigUpdateListener has onUpdate and onError, and the listener must be removed through its registration. - Read a structural flag once in the ViewModel and pass a plain value to the composable. - Give every temporary flag an owner and a removal date, and fail a test when the date passes. Sections: Why does an Android team need feature flags? · Architecture: The Feature Flag Layer · Firebase Remote Config Implementation · Using Feature Flags in Compose UI · Debug Overrides and QA Tools · Flag Hygiene: Expiration and Cleanup Questions answered: - Q: Should I use feature flags for everything? A: No. Use them where being able to switch something off without a release is worth an extra code path: risky features, staged exposure and kill switches for things that depend on a backend. Every flag doubles the paths through the code it guards, so a stable feature is better shipped with no flag at all. - Q: When should a Remote Config value take effect? A: For anything that changes the structure of a screen, on the next app start. Firebase's loading guide warns against activating values where the UI could change noticeably while someone is using it. Activate the previously fetched values at startup and fetch new ones for the following session. Reserve real-time updates for kill switches. - Q: How do I test feature flags? A: Depend on an interface and pass a fake in unit tests, so no test needs the network or the Firebase SDK. Run the tests for flagged code with the flag on and off. In debug builds, wrap the provider with local overrides so testers can force either state without changing the production configuration. --- ## Jetpack DataStore: The Modern Replacement for SharedPreferences - URL: https://android-x.app/blog/jetpack-datastore-guide - Markdown: https://android-x.app/blog/jetpack-datastore-guide.md - Category: Data Layer - Author: Rocky Elsalaymeh - Published: 2026-03-12 · Updated: 2026-10-11 - Reading time: 11 min read Migrate from SharedPreferences to DataStore: type-safe Proto DataStore, reactive Preferences DataStore, and production patterns for settings and onboarding. Key takeaways: - Google now tells SharedPreferences users to move to DataStore, which stores data asynchronously, consistently and transactionally. - There are three configurations: Preferences, JSON and Proto. Preferences DataStore has no schema and does not provide type safety. - Create one DataStore instance per file, at the top level. A second instance for the same file throws IllegalStateException. - The stored type must be immutable, and a second process needs MultiProcessDataStore. - A migration can run more than once, so a custom mapping must be idempotent. - Keep DataStore out of composables: repository, ViewModel, StateFlow, then collectAsStateWithLifecycle. Sections: Why move off SharedPreferences? · Which DataStore should you use: Preferences, JSON or Proto? · Preferences DataStore: The Shortest Migration Path · Proto DataStore: Type-Safe Structured Data · The Rules That Break DataStore When Ignored · Migrating from SharedPreferences · Integrating DataStore with Jetpack Compose Questions answered: - Q: Should I use Preferences, JSON or Proto DataStore? A: Use Preferences DataStore for a few independent key-value settings. It needs no schema, and the documentation states that it does not provide type safety. Once the values form an object, use a typed DataStore with a Serializer. Google recommends protocol buffers for that, and JSON through kotlinx.serialization is the lighter setup. - Q: How do I migrate from SharedPreferences to DataStore? A: Pass a SharedPreferencesMigration to produceMigrations when you create the DataStore. It copies boolean, float, int, long, string and string set values, and it skips a key that already exists in the new store. If you write a custom mapping, make it idempotent, because the migration methods may be called more than once. - Q: Why does DataStore throw IllegalStateException? A: Most often because two DataStore instances are active for the same file in one process. The documentation says never to create more than one, and that doing so can break all DataStore functionality. Declare the delegate once at the top level of a Kotlin file and reach it through that property everywhere else. --- ## Hunting Memory Leaks in Android: LeakCanary, Profiler, and Production Monitoring - URL: https://android-x.app/blog/android-memory-leak-detection - Markdown: https://android-x.app/blog/android-memory-leak-detection.md - Category: Performance - Author: Rocky Elsalaymeh - Published: 2026-03-10 · Updated: 2026-10-11 - Reading time: 13 min read Detect, diagnose, and eliminate Android memory leaks with LeakCanary, Android Studio Profiler heap dumps, and production-grade OOM monitoring. Key takeaways: - A memory leak is a reference kept to an object that is no longer needed. Most are lifecycle bugs. - LeakCanary 2.14 needs one debugImplementation line and watches Activities, Fragments, fragment Views, ViewModels and Services. - It waits 5 seconds, then dumps the heap at 5 retained objects while the app is visible, or 1 when it is not. - Read a leak trace top down and use the Leaking marks to rule references out. - A WeakReference does not fix a leak. Clear the reference when the lifecycle ends. - In production, count REASON_LOW_MEMORY exits. Out-of-memory crashes scatter across stack traces and low memory kills are not crashes at all. Sections: What is a memory leak on Android, exactly? · LeakCanary: Automatic Leak Detection in Debug Builds · Reading Leak Traces Like a Detective · Android Studio Profiler: Heap Dumps · Common Leak Patterns and Fixes · Production Monitoring: What You Can Actually See Questions answered: - Q: How do I know if my app has a memory leak? A: Add LeakCanary to the debug build and use the app. It watches destroyed Activities, Fragments, fragment Views, cleared ViewModels and destroyed Services, and reports any that are still retained after five seconds and a garbage collection. In production, count process exits with REASON_LOW_MEMORY, because a leaking app is often killed before it crashes. - Q: What is the most common cause of a memory leak on Android? A: LeakCanary documents three: a Fragment on the back stack that keeps its view fields after onDestroyView, an Activity stored as a Context in an object that survives configuration changes, and a listener or subscription that is never unregistered. All three are the same bug, a reference that outlives the lifecycle of what it points at. - Q: Can I fix a memory leak with a WeakReference? A: No. The LeakCanary documentation states that memory leaks cannot be fixed by replacing strong references with weak references and that the approach never works. The bug that kept the reference alive is still there, and objects can now be collected too early. Clear the reference at the correct point in the lifecycle. --- ## Kotlin Sealed Interfaces: Modeling Complex Domain State Without Compromise - URL: https://android-x.app/blog/kotlin-sealed-interfaces - Markdown: https://android-x.app/blog/kotlin-sealed-interfaces.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-03-08 · Updated: 2026-10-11 - Reading time: 10 min read Go beyond sealed classes: sealed interfaces for exhaustive type hierarchies, multi-trait state models, and compiler-enforced business rules in Android apps. Key takeaways: - Sealed interfaces arrived in Kotlin 1.5.0. A class can implement several, which a sealed class hierarchy cannot offer. - Direct subtypes must be in the same package and module. Indirect subtypes are not restricted, so keep leaf types final. - Exhaustiveness is documented for when used as an expression. Write state mappings as expressions, with no else branch. - Model independent dimensions of state as separate sealed interfaces and compose them in one data class. - Use sealed interfaces as parameter types to make an invalid call fail at compile time. - In multiplatform code, direct subclasses must share a source set, and an expected sealed class still needs else. Sections: Why Sealed Classes Are Not Enough · What does sealed actually guarantee? · Modeling UI State with Multiple Dimensions · Compiler-Enforced Business Rules · Event Systems with Sealed Interface Hierarchies · Limits and Best Practices Questions answered: - Q: When should I use sealed interfaces over sealed classes? A: Use a sealed interface when one type needs to belong to more than one closed family, such as an event that is both trackable and undoable. Kotlin lets a class implement several sealed interfaces and extend only one class. Use a sealed class when the subtypes share state or a constructor and there is one dimension. - Q: Do sealed interfaces work with serialization? A: Yes, with kotlinx.serialization. Mark the sealed parent and every subtype with @Serializable; the documentation says all subclasses must be marked explicitly. The JSON format then adds a type key as a discriminator, whose value is the fully qualified class name unless you set one with @SerialName on each subtype. - Q: Does the compiler always check that a when over a sealed type is exhaustive? A: The documented guarantee is for when used as an expression: it must cover all possible cases, and for a sealed subject it can do so without an else branch. The documentation says a when used as a statement does not need to cover every case. Write state mappings as expressions to rely on the documented rule. --- ## Type-Safe Navigation in Jetpack Compose: From Strings to Kotlin Serialization - URL: https://android-x.app/blog/compose-navigation-type-safe - Markdown: https://android-x.app/blog/compose-navigation-type-safe.md - Category: Jetpack Compose - Author: Rocky Elsalaymeh - Published: 2026-03-05 · Updated: 2026-10-11 - Reading time: 12 min read Eliminate runtime route crashes by replacing string-based navigation with Kotlin Serialization-powered type-safe routes, nested graphs, and deep link handling. Key takeaways: - Navigation Compose 2.x is in maintenance mode. Navigation 3, stable since November 2025, is the library for new apps. - Type-safe routes arrived in Navigation 2.8.0: @Serializable objects and data classes instead of route strings. - Declare destinations with composable and read arguments with toRoute(), in the composable or from SavedStateHandle. - The Kotlin Serialization compiler plugin must be applied to every module that declares a route. - Declare deep links against the route type with navDeepLink(basePath), and still validate what arrives. - Migrate one destination per pull request, argument-heavy screens before simple ones. Sections: The Cost of String-Based Routes · Should a new app use Navigation Compose or Navigation 3? · Defining Routes as Kotlin Types · Building the NavHost with the Type-Safe DSL · Deep Links with Typed Routes · Testing Navigation · Migration Strategy from String Routes Questions answered: - Q: Is type-safe navigation worth the migration? A: For any app with more than a handful of destinations, yes. A route string, its argument keys and the code that reads them must agree, and the compiler checks none of it. A @Serializable route makes all three one type, so a renamed or missing argument becomes a compile error instead of a crash. - Q: Should I use Navigation Compose or Navigation 3? A: Start new apps on Navigation 3. The androidx.navigation release page states that the library is in maintenance mode and will only receive critical fixes. An existing app on Navigation Compose can stay there and move to type-safe routes now; those route types carry over as Navigation 3 keys when you do migrate. - Q: How do I read a type-safe route argument in a ViewModel? A: Call toRoute on the SavedStateHandle the ViewModel receives, with the route class as the type argument. It returns the same typed object the destination was navigated to with, so the ViewModel reads productId as a property. The composable then needs no argument handling at all and only renders state. --- ## Android App Startup Optimization: What to Measure and What to Fix First - URL: https://android-x.app/blog/android-app-startup-optimization - Markdown: https://android-x.app/blog/android-app-startup-optimization.md - Category: Performance - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 10 min read Measure cold start with Macrobenchmark, then fix it with Baseline Profiles, lazy initialization and App Startup, against the thresholds Google Play uses. Key takeaways: - Android vitals counts a cold start as slow at 5 seconds or more, a warm start at 2 and a hot start at 1. Those are ceilings, not targets. - TTID and TTFD are equally important. TTFD only exists if the app calls reportFullyDrawn(). - Measure with Macrobenchmark before and after every change, on a release-like build. - Baseline Profiles improve code execution speed by about 30% from the first launch, per Android. Add a startup profile as well. - With App Startup, remove an initializer from the manifest before initializing it lazily, or nothing is deferred. - Record a system trace and read the main thread up to the first frame. Use StrictMode to catch disk and network access there. Sections: What counts as a slow start on Android? · Measuring Startup with Macrobenchmark · Baseline Profiles: Compile the Startup Path Before First Launch · Lazy Initialization with the App Startup Library · Reducing Work Before the First Frame · Diagnosing Bottlenecks with a Trace Questions answered: - Q: What is a good cold start time on Android? A: Google publishes a ceiling, not a target. Android vitals counts a cold start as slow at 5 seconds or more, a warm start at 2 seconds or more and a hot start at 1 second or more. Treat those as the point of failure, set your own budget well below them, and measure it with Macrobenchmark. - Q: How do Baseline Profiles help startup? A: They list the code paths to compile ahead of time, so startup code is not interpreted and JIT-compiled while the user waits. Android states that Baseline Profiles improve code execution speed by about 30% from the first launch for included code paths, and recommends adding a startup profile to fully optimize app startup. - Q: What is the difference between TTID and TTFD? A: Time to initial display is the time it takes to display the first frame, and the Android Framework reports it for every app. Time to full display is the time until the app is fully interactive, and it is only recorded when the app calls reportFullyDrawn(). Android describes the two as equally important. --- ## Android Modularization: Structuring Large-Scale Apps for Speed - URL: https://android-x.app/blog/android-modularization-guide - Markdown: https://android-x.app/blog/android-modularization-guide.md - Category: Architecture - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 12 min read Break a monolithic Android app into data, feature and common modules for clearer ownership, enforced boundaries and builds that can skip work. Key takeaways: - Modularize when the codebase is large enough to need it. Android says it does not always make sense. - Four module types: data, feature, app and common. Feature modules depend on data modules, and the app module depends on features. - A data module exposes a repository and hides its data sources. Keep the repository with its data, not in a shared model module. - Mark everything internal except the entry point, and prefer implementation over api. - Modules can improve build time through incremental build, the build cache and parallel build. They also add configuration overhead, which convention plugins contain. - Features talk through a mediator, usually the app module, and pass ids, not objects. Sections: When does a single module become a problem? · Module Types and the Dependency Graph · Data Modules and the Model · Feature Module Structure · Build Time: What Modules Buy and What They Cost · Navigation Across Module Boundaries Questions answered: - Q: When should I modularize my Android app? A: Android gives a clear test. If you need reusability across apps, strict visibility control or Play Feature Delivery, modularization is a necessity, because those benefits are only achievable with modules. If you want better scalability, ownership, encapsulation or build times, it is worth considering. If the codebase will stay small, the gains do not apply. - Q: How many modules is too many? A: There is no number. Android describes two failure modes: too fine-grained, where every module adds build complexity and boilerplate until the overhead outweighs the benefit, and too coarse-grained, where a module grows into another monolith. Start with clear boundaries, and consolidate modules when the configuration cost exceeds what the split gives you. - Q: Can feature modules depend on each other? A: Not in both directions: Gradle does not allow cyclic dependencies between modules. We forbid it in either direction as a convention. Android recommends a mediator instead, usually the app module that owns the navigation graph, which passes a simple id from one feature to the next so that neither knows about the other. --- ## Securing Android Apps: OWASP Mobile Top 10 in Practice - URL: https://android-x.app/blog/android-security-best-practices - Markdown: https://android-x.app/blog/android-security-best-practices.md - Category: Security - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 10 min read Protect your Android app against the most common mobile risks. Covers credentials, secure storage, certificate pinning, input validation, and integrity checks. Key takeaways: - Assume the device is hostile. Put every trust decision on the server. - The 2024 OWASP Mobile Top 10 leads with Improper Credential Usage. Insecure Data Storage is M9, not M1. - Do not ship secrets in the APK. Hold keys in the Android Keystore. - EncryptedSharedPreferences is deprecated. Internal storage is private to the app by default; encrypt with a Keystore key when you need more. - Cleartext traffic is off by default from API 28. Pin certificates only with a backup pin and an owner for rotation. - Bind SQL parameters, canonicalize file paths and validate every deep link parameter. Sections: Why Mobile Security Requires Different Thinking · What is on the OWASP Mobile Top 10 today? · Credentials, Keys and Storage · Network Security: Certificate Pinning · Input Validation and Injection Prevention · Binary Protection and Integrity Checks Questions answered: - Q: How do I securely store API keys? A: Do not put them in the app. Anything compiled into an APK can be read, and obfuscation does not hide string values. Keep third-party keys on your backend and have the app call your server. For keys the app itself generates, use the Android Keystore, which prevents the key material from being extracted. - Q: Is certificate pinning worth it? A: Sometimes. Android documents pinning as something some apps choose, and warns that without a backup key a certificate change forces an app update to restore connectivity. Pin when the data justifies that outage risk and someone owns key rotation. Otherwise rely on the default trust store with cleartext traffic disabled. - Q: Should I still use EncryptedSharedPreferences? A: Not for new code. The class was deprecated in security-crypto 1.1.0 and its reference page says to use SharedPreferences instead. Internal storage is already private to your app. Store less, keep settings in DataStore, and when data needs more protection, encrypt it with a key held in the Android Keystore. --- ## Automated CI/CD for Android: GitHub Actions from Zero to Production - URL: https://android-x.app/blog/github-actions-android-cicd - Markdown: https://android-x.app/blog/github-actions-android-cicd.md - Category: CI/CD - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 15 min read Set up a complete CI/CD pipeline with automated testing, signing, and staged rollouts to Google Play using GitHub Actions. Key takeaways: - A CI/CD pipeline has four stages: validate, test, build, deploy. - Aim for a pull-request workflow under 10 minutes, and set timeout-minutes: a hosted job can otherwise run for up to 6 hours. - Never commit signing keys. Use GitHub Actions encrypted secrets, which are limited to 48 KB each. - Base64-encode your keystore and decode it during CI builds. - Upload with tracks, not the deprecated track input; status inProgress with a userFraction makes it a staged rollout. - Linux runners bill at $0.006 a minute against $0.062 for macOS. Build Android on Linux. - Use concurrency groups to cancel redundant workflow runs. Sections: The Cost of Manual Releases · Pipeline Architecture · How Should the Pull-Request Workflow Run? · The Release Workflow: Production Builds · Secrets Management · Gradle Caching for Faster Builds · What Does the Pipeline Cost? Questions answered: - Q: How long should an Android pull-request workflow take? A: Aim for under 10 minutes. Past that, developers stop waiting for the result and the gate stops functioning. Gradle's build cache, the configuration cache and parallel execution keep it there, and timeout-minutes stops a hung emulator from running to GitHub's 6-hour job limit while everyone assumes the build is still working. - Q: How do I sign a release build in CI without committing the keystore? A: Base64-encode the keystore, store it as a GitHub Actions encrypted secret, and decode it on the runner at build time. Secrets are limited to 48 KB each, so check the encoded size. Read the passwords and the key alias from secrets too, and keep a local key.properties fallback so developer machines never need the CI values. - Q: How do I stop redundant CI runs from piling up? A: Use a concurrency group keyed on the branch or pull request. GitHub allows at most one running job or workflow per group, and cancel-in-progress: true cancels the run already in flight when a new commit arrives. You stop paying for builds of commits that are no longer the head of the branch. - Q: How much does GitHub Actions cost for Android builds? A: Hosted runners bill by the minute, rounded up per job. A standard Linux 2-core runner is $0.006 a minute, so a 10-minute build costs about six cents. macOS runners are $0.062 a minute, roughly ten times more, which is why an Android build, unlike an iOS build, should run on Linux. --- ## Gradle Build Optimization: Where Android Build Time Actually Goes - URL: https://android-x.app/blog/gradle-build-optimization-android - Markdown: https://android-x.app/blog/gradle-build-optimization-android.md - Category: Build Tools - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 11 min read Inspect a slow Android build first, then fix it with the configuration cache, parallel execution, the build cache and a graph that lets Gradle skip work. Key takeaways: - Inspect the build with a Build Scan or profile report before changing anything, then change one thing and inspect again. - Parallel execution and the build cache are off by default. The configuration cache is opt-in until Gradle 10. - Do not keep configuration cache warning mode on. Gradle calls it a migration and troubleshooting aid. - Pin dependency versions, keep the repository list short and replace kapt with KSP where the library supports it. - Prefer implementation over api so a change inside a module does not recompile its consumers. - On CI, let setup-gradle manage the cache and give every job a timeout. Sections: Why Build Speed Matters More Than You Think · How do you find out where the time goes? · Configuration Cache and Parallel Execution · Dependency Management for Faster Resolution · Module Structure and Build Avoidance · CI-Specific Optimizations Questions answered: - Q: Why is my Gradle configuration phase so slow? A: The configuration phase runs on every build, whichever tasks you ask for, so anything expensive in a build script is paid each time. Enable the configuration cache so Gradle can skip the phase when its inputs are unchanged, apply plugins only where they are needed, and move shared logic into convention plugins. - Q: Is the Gradle configuration cache enabled by default? A: Not yet. The Gradle documentation says the configuration cache has been the preferred mode of execution since Gradle 9.0.0 and will be enabled by default in Gradle 10. Until then, set org.gradle.configuration-cache=true in gradle.properties. IDE imports and syncs do not use it, so it speeds up builds, not project sync. - Q: Should I pass --no-daemon on CI? A: It depends on the runner. Gradle states that on CI machines long-lived agents benefit from the daemon and short-lived ones may not. A hosted runner that is discarded after each job gains nothing from a warm daemon, so --no-daemon is reasonable there. A persistent self-hosted agent should keep the daemon enabled. --- ## Dependency Injection with Hilt: The Complete Android Guide - URL: https://android-x.app/blog/hilt-dependency-injection - Markdown: https://android-x.app/blog/hilt-dependency-injection.md - Category: Architecture - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 11 min read Learn how to structure your Android app with Hilt for testable, maintainable code. Covers modules, scopes, qualifiers, and testing patterns. Key takeaways: - Hilt is Jetpack's recommended DI library. It generates the wiring at compile time on top of Dagger. - Use @Binds to map an interface to an implementation and @Provides when there is construction logic. - Bindings are unscoped by default: a new instance per request. Scope only what must be shared. - hiltViewModel() now lives in androidx.hilt:hilt-lifecycle-viewmodel-compose, not the navigation artifact. - Unit tests do not need Hilt. Call the constructor with fakes. - Prefer @TestInstallIn to replace a binding everywhere; @UninstallModules with @BindValue and @JvmField for one test class. Sections: Why Dependency Injection Matters · Setting Up Hilt · Modules: Telling Hilt How to Provide Dependencies · How long does a Hilt binding live? · Qualifiers: Multiple Bindings of the Same Type · Testing with Hilt Questions answered: - Q: Should I use Hilt or manual DI? A: Hilt, for most production apps. Android's documentation calls it Jetpack's recommended library and describes manual injection as constructing every class and its dependencies by hand. Manual DI stays reasonable in a very small app or a library module, where a few constructor calls are easier to read than generated components. - Q: Do I need @AndroidEntryPoint on every composable? A: No. The Hilt guide says that in Compose you annotate the root ComponentActivity with @AndroidEntryPoint and nothing else. That activity is the single entry point for the whole UI hierarchy. Composables then obtain a ViewModel annotated with @HiltViewModel by calling hiltViewModel(), which comes from the hilt-lifecycle-viewmodel-compose artifact. - Q: Do I need Hilt in unit tests? A: No. The testing guide states that Hilt is not necessary for unit tests: a class that uses constructor injection can be created directly with fake dependencies. Keep Hilt for instrumented tests, where @TestInstallIn replaces a module for all tests and @UninstallModules with @BindValue replaces it for a single test class. --- ## Accessibility in Jetpack Compose: Building Apps Everyone Can Use - URL: https://android-x.app/blog/jetpack-compose-accessibility - Markdown: https://android-x.app/blog/jetpack-compose-accessibility.md - Category: Accessibility - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 10 min read Make Compose UI accessible to screen readers, switch devices, and keyboard users: semantics, content descriptions, touch targets, focus, and automated tests. Key takeaways: - Give every meaningful icon and image a contentDescription, and pass null for decorative ones. - Android asks for 48dp touch targets. WCAG 2.1 44px is a AAA criterion and WCAG 2.2 AA asks for 24px. - Reserve space for small custom controls with Modifier.minimumInteractiveComponentSize(). - Mark screen and section titles with Modifier.semantics { heading() } so services can jump between them. - Announce changes with a polite live region. Keep assertive for time-sensitive alerts. - From Compose 1.8.0, enableAccessibilityChecks() runs the Accessibility Test Framework inside your UI tests. Sections: Why Accessibility Is Not Optional · Semantics: How Compose Communicates with Accessibility Services · What touch target size does Android require? · Heading Hierarchy and Screen Structure · Focus, Keyboards and Announcing Changes · Automated Accessibility Testing Questions answered: - Q: How do I test accessibility in a Compose app? A: Do both kinds. Walk the main flows with TalkBack and a keyboard, because only a person can judge whether an announcement makes sense. Then add the ui-test-junit4-accessibility dependency and call enableAccessibilityChecks() in your Compose tests, which checks labels, contrast, touch target size and traversal order on every run. - Q: What is the most common accessibility mistake in Compose? A: A meaningful icon with no content description. Compose cannot know what a picture means, so an icon button without a description reaches a screen reader as an unlabelled control. The opposite mistake is nearly as common: describing decorative images, which makes the user listen to noise. Pass null for those. - Q: Does WCAG require 44dp touch targets? A: No. WCAG 2.1 criterion 2.5.5 asks for 44 by 44 CSS pixels at Level AAA, the strictest level. WCAG 2.2 added criterion 2.5.8 at Level AA with a 24 by 24 CSS pixel minimum. Android guidance is stricter than both and asks for 48dp, which is the number to build to. --- ## Building a Design System with Jetpack Compose - URL: https://android-x.app/blog/jetpack-compose-design-system - Markdown: https://android-x.app/blog/jetpack-compose-design-system.md - Category: Jetpack Compose - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 10 min read Build a scalable Compose design system: design tokens as the foundation, Material 3 extended with custom CompositionLocals, and an atomic component library. Key takeaways: - A design system gives each visual decision one home: tokens, then a theme, then components. - Extend MaterialTheme before replacing it. Material components keep working and you add only what is missing. - Deliver custom tokens with staticCompositionLocalOf and give the default instance unusable values such as Color.Unspecified. - Components read tokens and offer fewer options than Material. Narrowness is the point. - Dynamic colour needs Android 12 and is a choice: your extended colours do not follow the wallpaper. - Previews are the catalogue, and screenshot tests are what keep the catalogue true. Sections: Why Your App Needs a Design System · What belongs in a Compose design system? · Design Tokens: The Foundation · Building the Theme Provider · Component Library: Atoms to Organisms · Previews, Screenshots and Keeping It Honest Questions answered: - Q: What are design tokens in a Compose design system? A: Tokens are the named raw decisions of the visual language: colours, type sizes, spacing steps and shapes. Components read tokens and never hold literal values, so changing a token changes every component that uses it. In Compose they live in the Material theme, plus your own classes delivered through a CompositionLocal. - Q: How do I extend Material 3 with my own tokens? A: Define an immutable class for the extra values, create a staticCompositionLocalOf for it, and provide an instance inside your theme function around MaterialTheme. Expose it through an accessor object so call sites read AppTheme.extendedColors.success. For one or two values, extension properties on ColorScheme are simpler still. - Q: Should I build a fully custom design system or extend Material? A: Extend Material unless your brand shares nothing with it. Extending keeps every Material component usable with reasonable defaults and inherits future Material work. Replacing a subsystem, or the whole theme, means you also own how each Material component you still use gets its colours, type and shapes, which is ongoing maintenance. --- ## Jetpack Compose Performance: Eliminating Jank and Unnecessary Recompositions - URL: https://android-x.app/blog/jetpack-compose-performance - Markdown: https://android-x.app/blog/jetpack-compose-performance.md - Category: Jetpack Compose - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 11 min read Master Compose performance with stability annotations, derivedStateOf, remember optimizations, and Layout Inspector profiling techniques. Key takeaways: - Strong skipping is on by default from Kotlin 2.0.20: every restartable composable is skippable, with or without unstable parameters. - Stability now decides the comparison. Stable parameters use equals(), unstable ones use ===, so new instances are what defeat skipping. - Lambdas inside composables are remembered by the compiler. Hand-written remember around callbacks is no longer needed. - derivedStateOf is expensive. Use it only when an input changes much more often than the result. - Give lazy list items stable keys, and never nest a LazyColumn in a vertically scrolling Column: it throws. - Measure a release build. Use Layout Inspector counts first, then the compiler report to explain them. Sections: How Recomposition Actually Works · Does stability still matter with strong skipping? · derivedStateOf: Computed State Without Recomposition Storms · Lambdas: What the Compiler Now Does for You · Lazy Layout Performance · Profiling with Layout Inspector and Compiler Reports Questions answered: - Q: Why is my Compose UI janky? A: The usual causes are work done in a composable body on every frame, state read higher in the tree than it is used, lazy lists without keys, and new object instances passed as parameters on each recomposition. Confirm it on a release build first, because debug builds carry a performance cost of their own. - Q: When should I use remember versus derivedStateOf? A: Use remember, with keys, to keep the result of a calculation across recompositions. Use derivedStateOf only when an input changes far more often than the value you derive from it, such as scroll position feeding a boolean. The documentation calls derivedStateOf expensive and warns against using it merely to combine two states. - Q: Do I still need @Stable and immutable collections with strong skipping? A: Sometimes. Strong skipping makes composables with unstable parameters skippable, but it compares those parameters by instance. If your data layer allocates new objects whenever anything changes, instance comparison fails every time. Marking the class @Stable or passing an immutable collection switches the comparison to equals(), so equal content is skipped. --- ## Kotlin Coroutines Deep Dive: Structured Concurrency in Practice - URL: https://android-x.app/blog/kotlin-coroutines-deep-dive - Markdown: https://android-x.app/blog/kotlin-coroutines-deep-dive.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 12 min read Master structured concurrency, cancellation, exception handling, and Flow operators for production-grade Android applications. Key takeaways: - Structured concurrency binds a coroutine to its scope: cancel the scope and its coroutines are cancelled with it. - Use viewModelScope for data work and rememberCoroutineScope for UI-only work launched from an event. - Cancellation is cooperative. Check ensureActive() in CPU loops and never swallow CancellationException. - A failed async child fails its parent whether or not you call await(). Only a root async waits. - Use supervisorScope to isolate failures. launch(SupervisorJob()) detaches the coroutine and isolates nothing. - A StateFlow built with stateIn and WhileSubscribed needs a collector in the test, or its value never changes. Sections: What is structured concurrency in Kotlin? · Coroutine Scopes and Their Lifecycle · Cancellation: The Silent Contract · Exception Handling Strategies · Flow: Reactive Streams Done Right · Testing Coroutines Questions answered: - Q: What is the difference between launch and async? A: launch returns a Job and treats a failure as an uncaught exception. async returns a Deferred and, as a root coroutine, holds the failure until you call await(). As a child of another coroutine the two behave alike: either one fails its parent immediately, and the parent then cancels the remaining children. - Q: How do I handle exceptions in coroutines? A: Catch specific types such as IOException around the suspending call, and never swallow CancellationException. Use supervisorScope when one child failing should not cancel the others. Treat CoroutineExceptionHandler as a logging hook for uncaught exceptions only: the Kotlin documentation states that you cannot recover from an exception inside it. - Q: Why does my StateFlow test only see the initial value? A: A StateFlow created with stateIn and SharingStarted.WhileSubscribed does not collect its upstream until something subscribes. Reading .value does not count as a subscriber. Launch an empty collector in backgroundScope before you act on the ViewModel, then call advanceUntilIdle() so the debounce delay passes in virtual time. --- ## Kotlin Flow vs LiveData: When to Migrate and How - URL: https://android-x.app/blog/kotlin-flow-vs-livedata - Markdown: https://android-x.app/blog/kotlin-flow-vs-livedata.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 9 min read Understand the practical differences between Flow and LiveData, when migration makes sense, and how to convert your codebase incrementally. Key takeaways: - Android architecture guidance for new code is a StateFlow collected with collectAsStateWithLifecycle. It no longer mentions LiveData. - Do not rewrite working LiveData screens for their own sake. Migrate where combining streams or background writes hurt. - StateFlow needs an initial value. LiveData did not, so each migrated property needs a real starting state. - A bare collect does not stop when the view stops. Use repeatOnLifecycle in views and collectAsStateWithLifecycle in Compose. - postValue keeps only the last value when called repeatedly before the main thread runs. - asFlow() and asLiveData() bridge the two, so repositories can move to Flow before any screen changes. Sections: Should you migrate from LiveData to Flow? · Where LiveData Falls Short · What changes when you switch · Migration Pattern: ViewModel Layer · Collecting Flow in Compose and Fragments · Incremental Migration Strategy Questions answered: - Q: Should I rewrite all LiveData to Flow? A: No. Write new ViewModels with StateFlow, which is what Android architecture guidance now describes, and leave working LiveData screens alone until you have a reason. The reasons worth acting on are concrete: combining several sources, writing from background threads without losing values, or sharing the code with layers that already use Flow. - Q: Is Flow replacing LiveData? A: In the guidance, yes. The Android architecture recommendations say to make UI state a StateFlow and to collect it with collectAsStateWithLifecycle, and they no longer mention LiveData. LiveData itself is still shipped in androidx.lifecycle, and the LiveData guide still documents asLiveData() for converting a Flow at the ViewModel boundary. - Q: What is the most common bug when migrating from LiveData to StateFlow? A: Replacing observe() with a plain collect. LiveData unregisters its observer when the view stops, and a flow does not stop collecting automatically. The screen keeps working, so nobody notices the background work. Collect inside repeatOnLifecycle in a Fragment or Activity, and use collectAsStateWithLifecycle in Compose. --- ## Kotlin Multiplatform for Android Developers: Sharing Code Without Losing Native Quality - URL: https://android-x.app/blog/kotlin-multiplatform-android - Markdown: https://android-x.app/blog/kotlin-multiplatform-android.md - Category: Kotlin - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 12 min read Move business logic, networking, and data layers to a shared Kotlin module while keeping native Android and iOS UIs. KMP patterns for teams shipping Android. Key takeaways: - Kotlin Multiplatform has been Stable since Kotlin 1.9.20, and Google officially supports it for sharing business logic. - KMP shares logic, not UI. Compose Multiplatform is the optional, separate step for shared UI. - On AGP 9, add the Android target with the com.android.kotlin.multiplatform.library plugin, not com.android.library. - Expected and actual classes are Beta, and the docs recommend interfaces with an expected factory function for simple cases. - Ktor chooses an engine per platform through ktor-client-engine-defaults. Room and SQLDelight both work for shared storage. - Migrate in phases: models, logic, data, then networking. Sharing ViewModels is possible and optional. Sections: Is Kotlin Multiplatform ready for a production Android team? · Project Structure: source sets and the Android target · expect/actual: use it less than the tutorials do · Sharing Networking with Ktor · Shared Data Layer: Room or SQLDelight · Testing Shared Code · Incremental Migration Strategy Questions answered: - Q: Does KMP increase build complexity? A: Yes, and it is worth knowing where. The shared module gains source sets per platform, an iOS framework to produce, and on Android Gradle plugin 9 a different plugin for the Android target. The cost is concentrated in setup. Moving data models first keeps that cost separate from any change in behaviour. - Q: Can I migrate to Kotlin Multiplatform incrementally? A: Yes. Start with data models, then validation and other pure logic, then repositories, and replace the networking layer last. Each phase can ship without the next, and the Android app keeps working throughout. Sharing ViewModels is an optional final step that Android documents and does not require. - Q: Should I use expect and actual classes for platform code? A: Rarely. Expected and actual classes are in Beta, and the Kotlin documentation says that using them where an interface would be sufficient is not recommended. Declare an interface in common code and an expected factory function that returns it. That form is stable and lets tests substitute a fake implementation. --- ## MVVM vs MVI: Choosing the Right Architecture for Your Android App - URL: https://android-x.app/blog/mvvm-vs-mvi - Markdown: https://android-x.app/blog/mvvm-vs-mvi.md - Category: Architecture - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 8 min read A practical comparison of Model-View-ViewModel and Model-View-Intent patterns for modern Android development with Jetpack Compose. Key takeaways: - Google's architecture guide names neither MVVM nor MVI. It recommends a single source of truth and unidirectional data flow. - The practical difference is whether the ViewModel exposes screen state as several streams or as one immutable object. - One stream gives consistency: the UI can never observe half an update. - Several streams suit unrelated data, or data where one value updates far more often than the rest. - Decide per screen. The ViewModel is the boundary, so the two styles coexist in one app. Sections: Is MVVM or MVI the pattern Android recommends? · MVVM: Several Streams · MVI: One Stream · What Google Actually Says About One Stream or Several · Using Both Together Questions answered: - Q: Should I migrate from MVVM to MVI? A: Only for screens where separate streams have produced inconsistent states, such as a spinner and an error showing together. Convert those screens to a single state object and leave the rest. The ViewModel is the boundary, so one screen can change style without any other screen noticing or needing a rewrite. - Q: Can I use both MVVM and MVI in the same app? A: Yes. Google's guidance is that the choice between one state stream and several depends on how the emitted items relate to each other, which is a per-screen question. Use one state object where values depend on each other, and separate streams where they are unrelated or update at very different rates. - Q: Does MVI work with Jetpack Compose? A: Yes. A composable collects one StateFlow with collectAsStateWithLifecycle() and sends events back as intents. One caution from the UI layer guide applies: every emission of the state object updates the UI that reads it, so a very large state object that changes often can cause more work than separate streams would. --- ## Offline-First Android Architecture with Room and WorkManager - URL: https://android-x.app/blog/offline-first-android-architecture - Markdown: https://android-x.app/blog/offline-first-android-architecture.md - Category: Data Layer - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 12 min read Build Android apps that work without a network. Local-first data patterns, sync strategies, conflict resolution, and background sync with WorkManager. Key takeaways: - The local data source is the source of truth. The screen reads Room and nothing else. - Android documents three write strategies. Writing locally first and queueing the network write is a lazy write, the right choice for critical data. - A network error must leave the row pending and let the worker retry. Only a rejected request is a permanent failure. - ExistingWorkPolicy.REPLACE cancels a sync already in flight. Use APPEND_OR_REPLACE for a sync requested after each write. - With last write wins, the device attaches the timestamp and the network data source decides. - Show pending quietly and failed loudly, and show nothing when everything is synced. Sections: Why Offline-First Is Not Optional · The Repository Pattern: Local-First Reads · How should an offline-first app handle writes? · Background Sync with WorkManager · Conflict Resolution Strategies · Showing Sync Status in the UI Questions answered: - Q: How do I handle writes made while offline? A: Write to the local database first and mark the row as pending, then queue a WorkManager job that needs a network connection. When it runs, push the pending rows and mark them synced. Leave a row pending after a network error so it is retried, and mark it failed only when the server rejects it. - Q: When should an app be offline-first? A: When it has core functions people need without a connection. Android defines an offline-first app as one that can perform all, or a critical subset, of its core functionality without internet access. A transaction that must happen online, such as a bank transfer, stays an online-only write even inside an offline-first app. - Q: Who should resolve a sync conflict, the device or the server? A: The server. In the last write wins approach Android describes, devices attach a timestamp to what they write and the network data source discards anything older than its current state. A device that compares timestamps locally can decide it won while another device decides the same thing about its own copy. --- ## Property-Based Testing in Kotlin: Beyond Unit Tests - URL: https://android-x.app/blog/property-based-testing-kotlin - Markdown: https://android-x.app/blog/property-based-testing-kotlin.md - Category: Testing - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 9 min read Property-based testing with Kotest generates hundreds of inputs and shrinks failures to a minimal case, surfacing edge cases unit tests never try. Key takeaways: - Property-based tests define rules that hold for all inputs, catching edge cases examples miss. - Four key property types: roundtrip, invariant, oracle, and idempotency. - Kotest runs each property 1,000 times by default and mixes in edge cases such as 0, -1 and Int.MAX_VALUE. - Build domain generators with Arb.bind, which shrinks every field. An arbitrary { } block does not shrink without an explicit Shrinker. - A failed run prints its seed; pass PropTestConfig(seed = ...) to replay it. - Test logic layers (ViewModels, repos, mappers) rather than UI components. Sections: The Limits of Example-Based Testing · Setting Up Kotest Property Testing · Which Properties Should You Test? · How Does Shrinking Find the Minimal Failure? · How Do You Reproduce a Failing Run? · Integrating with Android Testing Questions answered: - Q: When should I use property-based testing? A: Use it for pure functions with clear invariants: data transformations, serialization, parsers, mappers and arithmetic. Those have properties you can state in one line, such as a roundtrip or an idempotency rule. It is less useful for UI code or heavily stateful systems, where the input space is large and the property is hard to pin down. - Q: How is this different from fuzzing? A: Both generate inputs, but a property test checks a rule you wrote and then shrinks a failure to a minimal case, while fuzzing is usually aimed at crashes. Kotest also mixes in fixed edge cases such as 0, -1 and Int.MAX_VALUE, runs 1,000 iterations by default, and prints the seed so a failure can be replayed. - Q: Are property-based tests flaky? A: They are random, not flaky. Each run uses a new seed, so a property can pass many times and then fail on an input it had not tried before. Kotest prints the seed on failure and saves it under ~/.kotest/seeds, so the next local run replays the same failure until the code is fixed. --- ## Room Database Migrations: A Survival Guide - URL: https://android-x.app/blog/room-database-migrations - Markdown: https://android-x.app/blog/room-database-migrations.md - Category: Best Practices - Author: Rocky Elsalaymeh - Published: 2026-02-20 · Updated: 2026-10-11 - Reading time: 9 min read Handle schema changes gracefully with Room's migration system, destructive fallbacks, and automated migration testing strategies. Key takeaways: - Room 3.0 changed the migration signature to suspend fun migrate(connection: SQLiteConnection). The SQL and the concepts are unchanged. - Export schemas from version 1 and commit the JSON. Auto-migrations and migration tests both depend on it. - Auto-migrations can add, rename and delete tables and columns. They cannot move data between them. - DROP COLUMN needs SQLite 3.35.0, which the platform only ships from API 34. Below that, create, copy, drop and rename. - Test every migration and the whole chain from version 1 with MigrationTestHelper. - Destructive fallback permanently deletes user data. Keep it for caches. Sections: The Migration Problem · Which Room version are you migrating on? · Writing Your First Migration · Complex Migrations: New Tables and Data Transforms · Auto-Migrations: What Room Can Generate for You · Testing Migrations · Destructive Fallback: When to Use It Questions answered: - Q: What happens if I forget to add a migration? A: Room throws an IllegalStateException when it opens the database, because it cannot find a path from the version on the device to the current one. If you enabled destructive fallback it deletes the data in every table instead. Neither is recoverable after release, which is why the full-chain migration test exists. - Q: How do I test Room migrations? A: Add the room3-testing artifact and use MigrationTestHelper. Create the database at the old version from the exported schema, insert rows with SQL, run the migration, then query to confirm the data survived. The helper validates the schema for you. Add one more test that migrates from version 1 to the latest. - Q: Can a Room auto-migration delete or rename a column? A: Yes. Room raises a compile-time error for an ambiguous change, and you resolve it with an AutoMigrationSpec class annotated with @DeleteColumn, @RenameColumn, @DeleteTable or @RenameTable. What an auto-migration cannot do is transform or move data, such as splitting one table into two. Write a manual migration for that.