Read this file before making any Android change.
The Android app should match the iOS app in product scope, but it must feel fully native to Android. We align the functional contract across Android and iOS, but we do not synchronize the designs between them. On Android, the UI and interaction design should stay maximally native to Android and Material 3.
We do not want an Android app that imitates iPhone UI. We want a modern Android app built with current Google-recommended patterns, official libraries, and minimal custom behavior.
- Language: Kotlin
- UI stack: Jetpack Compose
- Design system: Material 3
- Minimum supported Android version: Android 14,
minSdk = 34 - Compile target: Android 17,
compileSdk = 37 - Runtime target: Android 17,
targetSdk = 37 - Testing focus: Android 17 / API 37 only
- Primary local storage: Room on top of SQLite
The development focus is Android 14, 15, 16, and 17. We do not spend effort validating or polishing older Android versions.
AndroidX and other dependency versions in gradle/libs.versions.toml must stay at the highest stable versions compatible with compileSdk = 37, our current stable baseline. Automated dependency-drift updates must not adopt dependencies that require a preview SDK beyond API 37, because Gradle is not run during their validation and cannot catch that break locally.
Use Google-provided Android components and Android interaction patterns whenever they fit the problem.
Prefer:
- Jetpack Compose
- Material 3 components, tokens, layout patterns, and theming
NavigationSuiteScaffold,NavigationBar,NavigationRail,TopAppBar,Scaffold, sheets, dialogs, and standard Compose state handling- Android-standard navigation, motion, typography, feedback, and accessibility behavior
Keep the visual direction simple:
- dark theme by default
- keep the orange control color aligned with the iOS app
- do not force iOS visuals onto Android
- prefer native Material presentation over custom chrome
If an implementation starts needing custom design or custom logic on top of a standard Android component, stop and propose a simpler platform-native alternative first.
Prefer official AndroidX and Google libraries before considering external dependencies.
Default stack:
- Jetpack Compose
- Material 3
- Navigation Compose
ViewModel+StateFlow- Room for local persistence
- WorkManager for future sync and outbox work
Avoid:
- third-party UI kits
- custom design systems that fight Material 3
- hand-rolled database layers when Room already fits
- extra framework layers that duplicate standard Android architecture guidance
If Google already provides a supported solution, use it unless there is a concrete product requirement not covered by it.
The Android app should mirror the current top-level product structure:
- Review
- Progress
- AI
- Cards
- Settings
The goal is a working Android app that can run in an emulator and on-device while staying aligned with the shared product contract.
Keep the UI structure, navigation, and local storage foundation aligned with the current Android app architecture.
Run commands from apps/android/.
- Build the debug app:
./gradlew :app:assembleDebug - Build AndroidTest APKs:
./gradlew :app:assembleDebugAndroidTest :data:local:assembleDebugAndroidTest - Build the screenshot-only app variant:
./gradlew :app:assembleMarketingScreenshot :app:assembleMarketingScreenshotAndroidTest
apps/android/app/src/main/generated/baselineProfiles/baseline-prof.txt and startup-prof.txt are
committed artifacts. They ship inside the release AAB, so app startup and the first review are
AOT-compiled on device instead of relying only on the profiles Compose libraries bundle.
They are generated by hand and never in CI. No build and no CI job needs a device.
To regenerate:
- Start one rootable API 37 emulator. Play Store system images cannot be rooted, so use a
google_apisimage:sdkmanager --install "system-images;android-37.0;google_apis;arm64-v8a", then create an AVD from it. Stop every other emulator first and verifyadb deviceslists only that one device. - Run
./gradlew :app:generateBaselineProfile. The plugin's own task names (:app:assembleNonMinifiedRelease,:baselineprofile:connectedNonMinifiedReleaseAndroidTest, thebenchmarkReleaseequivalents) work too, including from Android Studio's Generate Baseline Profile run configuration: the release-secret guard inapp/build.gradle.ktsdeliberately ignores them even though their names contain "Release", because they need no signing keys, noANDROID_VERSION_CODE, and noANDROID_SENTRY_DSNand ship nothing. - Commit the changed profile files.
The generator lives in apps/android/baselineprofile/. It is a build tool, not test coverage, and
drives the real app through UiAutomator using the production Compose test tags, which
FlashcardsApp publishes as accessibility resource ids. It profiles the plugin-created
nonMinifiedRelease build type, which must stay unminified so the recorded class names match a
release build.
The generator module and :app share the benchmark version in gradle/libs.versions.toml.
- Add a new app language safely:
docs/add-language-checklist.md - Run Android marketing screenshot captures reliably:
docs/marketing-screenshot-runbook.md - Track Android marketing screenshot generator inventory:
docs/marketing-screenshots.md
The marketing screenshot docs cover locale-prefixed runs as well. The current supported wrapper layout is one unified five-shot Android marketing flow that captures screenshots 1, 2, 3, 4, and 5 from one seeded guest workspace and one instrumentation run. Use that one wrapper when adding or validating multi-language Play screenshot content.
Android app-internal translations are Play-first.
- Keep the Android source tree authoritative for the base English strings, stable string keys/plurals, base locale metadata, and the explicit supported-language list used to advertise app languages to Android system settings.
- Google Play App strings translation is the source of truth for translated Android UI copy. Do not sync Play-managed translations back into repository-owned Android
values-xxtrees. - Do not rely on AGP locale generation from checked-in resources as the source of supported translated languages. Play-managed languages must still be advertised explicitly from the checked-in Android locale plumbing so Android's per-app language settings can list them.
- Keep the explicit Android supported-language list aligned with the languages enabled for Play App strings in the release you are shipping.
- Keep store listing text, screenshots, and other marketing localization separate from in-app Android strings. Those are Play Console and media-asset concerns, not a reason to reintroduce repository-managed Android UI translations.
- Screenshot-only UI translation overlays belong in
apps/android/app/src/marketingScreenshot/resand are packaged only into the dedicatedmarketingScreenshotbuild type used by the manual screenshot wrappers. They must not be used to change normaldebugorreleasebuilds. - When a new Android app language is needed, add the English source strings or locale-ready formatting in repo, update the explicit locale-advertising config, upload a draft release to Google Play, review the Play-managed translations there, verify the Play-delivered build, and publish later from Play Console.
- A local debug build is useful only for base-resource sanity and locale-plumbing checks. Treat the Play-delivered draft build as the source of truth for translated UI copy and final per-app language availability.
Store committed Android marketing assets and reference screenshots in apps/android/docs/media/.
Use that directory for assets such as:
- Google Play screenshots
- feature graphic source files
- exported PNGs used in store listings
Current committed Android feature graphic files:
- source HTML template:
apps/android/docs/media/play-store-feature-graphic/index.html - exported store PNGs:
apps/android/docs/media/play-store-feature-graphic/<locale>-feature-graphic.png - supported locales:
en-US,ar,zh-CN,de-DE,hi-IN,ja-JP,ru-RU,es-419,es-ES,es-US
These files are updated manually and are part of the repository on purpose.
Media-only changes inside apps/android/docs/media/ must not trigger Android CI builds.
When it is faster, ask an LLM to create or capture these assets directly on a local virtual Android device. That workflow works well for:
- opening the current app build in an emulator
- creating realistic demo cards and AI chats
- taking clean store screenshots by hand
- generating a feature graphic source and exporting the final PNG
For the current Android Play Store feature graphics, the expected workflow is:
- Edit
apps/android/docs/media/play-store-feature-graphic/index.html. - Run
bash scripts/android/export-android-feature-graphic.sh <locale>for one locale, orbash scripts/android/export-android-feature-graphics.shfor the full supported set. - Verify that each exported file in
apps/android/docs/media/play-store-feature-graphic/is 1024 x 500.
The current local Android app uses:
compileSdk = 37targetSdk = 37minSdk = 34- Room on top of SQLite for local storage
- Material 3 + Compose + Navigation Compose +
NavigationSuiteScaffold
Test only on the final supported Android target.
- Do not try to cover the Android app exhaustively with tests
- Do not add isolated unit tests by default
- Prefer native integration, parity, and instrumentation tests when they validate a real module boundary or user flow
- Run Android tests only against Android 17 / API 37
- Do not spend time on test matrices for older API levels
- Do not add compatibility code for older Android versions unless explicitly requested
- Prefer background local emulator runs without a visible emulator window by default
- When starting a local headless emulator manually, prefer
-gpu autoand avoid the deprecated-gpu swiftshader_indirectoverride - Preserve the usual test artifacts, logs, screenshots, and reports when running Android tests in the background
- Open a visible Android emulator only when the user explicitly asks for it at that time
- Before a local instrumentation run, stop all other Android emulators, verify
adb devicesshows only the one target emulator, and prefer a clean rebuild plus one clean test run so stale emulator state does not contaminate the result
The most trusted Android checks are the managed-device app instrumentation flows because they exercise the real app closest to production behavior.
The Android app uses native Android and Compose testing:
- targeted integration coverage runs through native instrumentation and Compose UI testing in
apps/android/app/src/androidTestandapps/android/data/local/src/androidTest - shared FSRS scheduler parity stays in
apps/android/data/local/src/test/java/com/flashcardsopensourceapp/data/local/model/FsrsSchedulerParityTest.ktand usestests/fsrs-full-vectors.json - manual Firebase Test Lab app UI instrumentation runs through the full
apps/android/app/src/androidTest/java/com/flashcardsopensourceapp/appinstrumentation tree as part of the manualAndroid Releaseworkflow, withlivesmoke/LiveSmokeTest.ktandnotifications/NotificationTapSmokeTest.ktkept as the highest-confidence stateful flows there - the live smoke flow relies on stable Compose test tags from the production UI modules, not on a separate mock shell
The Android live smoke scenario matches the other clients on purpose:
- iOS equivalent:
apps/ios/Flashcards/FlashcardsUITests/LiveSmokeUITests.swift - Web equivalent:
apps/web/e2e/live-smoke.spec.ts
Android CI/CD is documented in docs/android-ci-cd.md.
That document also lists the required GitHub repository variables for Google Cloud authentication and Firebase Test Lab, plus the helper sync command bash scripts/android/setup-github-android.sh.
The repository policy for Android CI/CD is:
- GitHub Actions is the primary CI entrypoint
- Firebase Test Lab is the cloud device test runner
cloudbuild.android.yamlis the Google-native Cloud Build entrypoint- Google auth from GitHub must use Workload Identity Federation, not a JSON key
PR Checksis the required aggregate gate; it runs Android unit tests, debug builds, and lint for Android-impacting pull requests and adds the GitHub-hosteddata:localinstrumentation only when the Android data layer or shared Android Gradle configuration changes. Automatic Android CI onmainruns only thedata:localemulator backstop. Neither uploads to Google Play or submits Firebase Test Lab- the manual
Android Releaseworkflow runs the same GitHub-hosted Android gate, submits Firebase Test Lab app instrumentation, then uploads a Google Play production-track draft - one shared
ANDROID_VERSION_CODEis resolved once per release run and reused across Android release artifacts and the Play draft bundle - one shared manager-readable release identifier, currently
vc<versionCode>-r<runId>a<attempt>-s<shortSha>, is reused in the Play release name and Firebase Test Lab result naming so the same release stays traceable across GitHub, Play, and Firebase - Firebase Test Lab app instrumentation is manual-only from the top-level
firebase_test_lab_submissionjob inAndroid Release; submission is required before the Play draft upload starts - after pushing to
main, watchAndroid CIwhen Android-impacting files changed; it runs independently from the AWS/Web release workflow - after the workflow uploads the AAB, review Play App strings translations in Play Console, confirm the Play language set still matches the app's explicit supported-language list, verify the Play-delivered build, and publish the release there manually
Before making any change, read the existing screens and composables in the area you are touching.
Follow the patterns already present:
- match the composable structure, ViewModel wiring, and StateFlow usage already established in neighboring screens
- use the same naming conventions for composables, view models, state classes, and Compose test tags already in the codebase
- if a shared composable, utility, or extension already exists for what you need, use it instead of adding a new one
- do not introduce a new architecture layer or pattern unless the existing one is clearly broken for the task at hand
If you are unsure how something is done, read two or three existing screens first. The answer is almost always already there.
When reviewing or implementing Android changes, challenge complexity early.
If the proposed approach introduces custom design, custom behavior, or custom infrastructure where Android already provides a clear native pattern, propose the simpler Android-native version before writing more code.