Companion to stozo04/stevengates#31, which did this for the website (Lighthouse 100s, enforced via Lighthouse CI — see stozo04/stevengates#35 for how it landed). This issue is the Android translation: same philosophy (audit first, fix what the audit finds, then lock it in CI so it can't regress silently), different instruments. Written to be executable by an agent; follow the Process section strictly.
What
Make app quality a demonstrated feature, not a claimed one: green Android Vitals, a real accessibility pass (TalkBack-usable, Accessibility Scanner clean, accessibility checks running inside the existing Compose test suite), measured startup/jank numbers with budgets, and CI enforcement so future features pay their quality cost visibly in PR review.
Why
- The README's central claim is currently unverifiable. It says "follows every Jetpack best practice Google recommends" and "Google-first architecture." A reviewer who opens the repo finds no accessibility work and no CI gate proving the performance story. Same argument as stevengates#31: for this audience, unverified claims are worse than no claims.
- Accessibility appears to be genuinely untouched. GitHub code search finds zero occurrences of
contentDescription, semantics, or AccessibilityChecks in the repo (verify locally with grep -rn "contentDescription" app/src/ — code search can lag). For a camera app whose primary UI is icon buttons (shutter, flip camera, gallery, speed slider, save/share), that likely means TalkBack users get an app full of unlabeled buttons. This is both the biggest gap and the cheapest differentiation — almost no indie camera app does this properly.
- Google grades this app whether we participate or not. Android Vitals (Play Console) tracks real-user crash rate, ANR rate, excessive battery drain, and slow cold starts on every certified device. Crossing the bad-behavior thresholds (~1.09% user-perceived crash rate, ~0.47% ANR rate) demotes Play Store discovery and can put a warning on the listing. We already triage crashes (
.github/workflows/crashlytics-autotriage.yml); this issue extends that posture from "react to crashes" to "hold a quality bar."
- Half the machinery already exists — finish the story. A
baselineprofile module with BaselineProfileGenerator.kt is already wired (app/build.gradle.kts line ~308: baselineProfile(project(":baselineprofile"))). There is a rich Compose androidTest suite (CameraScreenTest, GalleryScreenTest, BoomerangEditorScreenTest, OnboardingScreenTest, etc.) that accessibility checks can piggyback on with a few lines. What's missing is the accessibility layer, the measured budgets, and the CI gate.
Process — read first
- Read
CLAUDE.md, docs/ANDROID_STANDARDS.md, docs/DEFINITION_OF_DONE.md, docs/STATIC_ANALYSIS.md, and docs/TEST_COVERAGE.md before writing anything. This repo has a deliberate tiered-gate design (advisory || true steps promoted to hard gates only per the documented procedure) and a convention that the pr-reviewer skill decides PR verdicts, not the build (app/build.gradle.kts lint block: abortOnError = false with an explanatory comment). Do not fight these conventions — extend them.
- Verify every file/line reference in this issue against current code and note drift in your plan.
- If any "Decision required from Steven" item below is unresolved (no answer in issue comments), STOP and post a comment listing exactly what you need, with a recommendation for each. Do not guess.
- Write a short plan (files, tests, verification) before implementing; include it in the PR description.
- One branch per issue, scope discipline, conventional commits, do not merge — same rules as always. If this issue turns out to be too large for one PR, propose a split in a comment (suggested seams: a11y semantics pass / a11y test enforcement / macrobenchmark + CI) and wait for approval.
How
Phase 0 — Audit first (establish the real baseline; no code changes)
- Vitals snapshot (Steven task — agent cannot access Play Console): record in this issue's comments the current Android Vitals numbers (user-perceived crash rate, ANR rate, slow cold start %) and the latest pre-launch report summary if one exists. If pre-launch reports have never been looked at, upload the current build to the internal testing track and let one generate.
- Accessibility audit (agent, on device/emulator):
grep -rn "contentDescription\|semantics(" app/src/main/java/ — inventory what exists (expected: nothing).
- Walk every screen with TalkBack enabled (Onboarding → PermissionExplanation → Camera → Loopifying → BoomerangEditor/Trim → Gallery → Share). Record per screen: unlabeled controls, focus order problems, announcements that lie or say nothing. The screen composables live under
app/src/main/java/io/github/stozo04/openloop/ui/.
- Run Google's Accessibility Scanner app over the same screens; save its findings (touch-target sizes, contrast, missing labels) as screenshots/notes in the issue.
- Performance baseline (agent):
- Cold-start:
adb shell am start -W -n io.github.stozo04.openloop/.MainActivity x 10, record median TTID; note whether the baseline profile is actually installed/compiled (adb shell dumpsys package dexopt | grep -A2 openloop).
- Verify the
baselineprofile module actually generates and the profile ships in the release AAB (check for baseline-prof.txt in the merged artifacts). It existing in the build script is not the same as it working.
- Jank: capture a Perfetto trace or
dumpsys gfxinfo io.github.stozo04.openloop while scrolling the Gallery grid and while the Loopifying progress screen animates; record janky-frame %.
- Post the full baseline as a comment on this issue before changing anything. Fixes must map to findings.
Phase 1 — Accessibility (the substantive half)
- Semantics/labels pass over every screen in
app/src/main/java/io/github/stozo04/openloop/ui/: every Icon/IconButton gets a real contentDescription (or explicit null ONLY when a sibling text label makes it decorative); sliders get stateDescription/semantics { } so TalkBack announces the actual speed value ("1.5x"), not "50 percent"; the capture button announces what it does and its state; progress screens announce progress. Strings go in strings.xml, not literals — they must be translatable.
- Touch targets >= 48dp everywhere Accessibility Scanner flagged; use
Modifier.minimumInteractiveComponentSize() or padding, not layout rewrites.
- Contrast: fix any Scanner-flagged text/background pairs (< 4.5:1 normal, < 3:1 large text) in
ui/theme/Theme.kt tokens, mirroring the token-level (not per-usage) approach that worked in stevengates#35.
- Reduced motion: respect the OS "remove animations" setting (
Settings.Global.ANIMATOR_DURATION_SCALE == 0) for the loopifying/editor animations — verify Compose animations honor it; anything hand-rolled must check it.
- Enforce it in the existing test suite: the repo already has Compose UI tests in
app/src/androidTest/.../ui/. Enable accessibility checks on the Compose test rule (Compose UI test 1.8+ integrates the Accessibility Test Framework; the API is composeTestRule.enableAccessibilityChecks() — verify the exact API against the Compose BOM version in gradle/libs.versions.toml and upgrade the BOM if needed). Turn them on for every existing screen test rather than writing new tests. Suppressions require a written justification comment, same spirit as lint-baseline.xml.
Phase 2 — Performance, measured
- New
:macrobenchmark module (androidx.benchmark macro-junit4), sibling to the existing :baselineprofile module and reusing its target setup: StartupTimingMetric for cold start, FrameTimingMetric for Gallery scroll and the editor. This is the Android equivalent of the Lighthouse numbers — it turns "feels fast" into a number that can regress visibly.
- Set budgets from the Phase 0 baseline, not from wishes (the stevengates#31 lesson: the issue's example budget was unreachable and had to be renegotiated mid-PR — measure first, then commit): e.g. cold start median <= baseline + 10%, janky frames <= baseline + 2pp. Write the numbers and the rationale in
docs/ANDROID_STANDARDS.md or a new docs/PERFORMANCE.md.
- Verify the baseline profile earns its keep: run the startup benchmark
CompilationMode.None vs CompilationMode.Partial — if the profile isn't measurably helping, that's a finding to report, not hide.
Phase 3 — Lock it in CI
- There is currently no Gradle build/test workflow in
.github/workflows/ (only markdown static-analysis, doc-layout, and crashlytics-autotriage). Add android-ci.yml: JDK setup + Gradle cache, ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug on every PR. Unit tests + build are hard gates; lint stays advisory per the repo's documented tier design (docs/STATIC_ANALYSIS.md) — surface the report, let the pr-reviewer skill judge it.
- Instrumented tests with accessibility checks in CI: run the androidTest suite (now a11y-enabled per item 9) on an emulator via
reactivecircus/android-emulator-runner (API 34, x86_64) or Gradle-managed devices — whichever docs/ conventions prefer; note the camera tests may need the emulator's virtual camera or the synthetic fixtures in app/src/androidTest/assets/. If the full suite is too slow for PRs (> 15 min), split: a11y-critical screen tests on PR, full suite nightly via schedule: + workflow_dispatch. State the split explicitly in the workflow comments.
- Macrobenchmarks in CI are noisy on shared runners — run them on a
schedule: (nightly/weekly) with results published as a workflow artifact + a tracked JSON in the repo (or Firebase Test Lab physical devices if the free quota suffices — decision gate below). PR-blocking on emulator benchmark numbers is a known flake generator; don't do it without hysteresis.
- Vitals watchdog (lightweight): extend the existing crashlytics-autotriage pattern with a scheduled job that pulls crash-free-users % from the Firebase/Play APIs and files an issue when it dips below the agreed floor — ONLY if Steven approves the API credential setup (decision gate below).
- Once green and stable: state the enforced gates and measured numbers in
README.md (it already makes the quality claim — back it) and in the Play Store listing materials under docs/play-store/ if applicable.
Decision required from Steven (answer in comments before the dependent work starts)
- D1 — Play Console data: paste current Android Vitals numbers + whether pre-launch reports exist (Phase 0.1). Agent has no Play Console access. Recommendation: also enable pre-launch reports on every internal-track upload if not already on — they're free.
- D2 — CI device strategy: GitHub-hosted emulator (free, slower, camera emulation quirks) vs Firebase Test Lab (real devices, free daily quota, needs a service-account secret in repo settings). Recommendation: start with the GitHub emulator for the a11y test gate; revisit FTL for benchmarks.
- D3 — Vitals API automation (item 16): requires creating a Google Cloud service account with Play Developer Reporting / Firebase access and adding it as a repo secret. Recommendation: defer — do the manual monthly check first; automate only if it proves annoying.
- D4 — Budget sign-off: after Phase 0, the agent posts proposed budget numbers (item 11) as a comment; Steven approves before they become CI gates.
Dependencies / coordination
- None blocking. stozo04/stevengates#38 (fresh OpenLoop screenshot for the website) is unrelated code-wise, but a fresh capture session for it could double as the Accessibility Scanner walk — batch them if convenient.
- The existing e2e skills (
.claude/skills/run-e2e*) drive real devices via PowerShell on Steven's machine; nothing here replaces them. CI gates are the floor, the skills remain the ceiling.
Verification — required, no exceptions
- Baseline comment posted (Phase 0) BEFORE any fix lands; every fix in the PR maps to a baseline finding.
- TalkBack walk video or step log per screen, before AND after, attached to the PR.
- Accessibility Scanner: before/after screenshots per screen.
./gradlew :app:testDebugUnitTest green; androidTest suite green WITH accessibility checks enabled (list any suppressions + justification in the PR body).
- Startup + frame benchmarks: before/after numbers in the PR, with the compilation-mode comparison from item 12.
- The new CI workflow(s) green on the PR itself.
- Any deviation from this issue documented in the PR body with reasoning — do not silently diverge.
What
Make app quality a demonstrated feature, not a claimed one: green Android Vitals, a real accessibility pass (TalkBack-usable, Accessibility Scanner clean, accessibility checks running inside the existing Compose test suite), measured startup/jank numbers with budgets, and CI enforcement so future features pay their quality cost visibly in PR review.
Why
contentDescription,semantics, orAccessibilityChecksin the repo (verify locally withgrep -rn "contentDescription" app/src/— code search can lag). For a camera app whose primary UI is icon buttons (shutter, flip camera, gallery, speed slider, save/share), that likely means TalkBack users get an app full of unlabeled buttons. This is both the biggest gap and the cheapest differentiation — almost no indie camera app does this properly..github/workflows/crashlytics-autotriage.yml); this issue extends that posture from "react to crashes" to "hold a quality bar."baselineprofilemodule withBaselineProfileGenerator.ktis already wired (app/build.gradle.ktsline ~308:baselineProfile(project(":baselineprofile"))). There is a rich ComposeandroidTestsuite (CameraScreenTest, GalleryScreenTest, BoomerangEditorScreenTest, OnboardingScreenTest, etc.) that accessibility checks can piggyback on with a few lines. What's missing is the accessibility layer, the measured budgets, and the CI gate.Process — read first
CLAUDE.md,docs/ANDROID_STANDARDS.md,docs/DEFINITION_OF_DONE.md,docs/STATIC_ANALYSIS.md, anddocs/TEST_COVERAGE.mdbefore writing anything. This repo has a deliberate tiered-gate design (advisory|| truesteps promoted to hard gates only per the documented procedure) and a convention that the pr-reviewer skill decides PR verdicts, not the build (app/build.gradle.ktslint block:abortOnError = falsewith an explanatory comment). Do not fight these conventions — extend them.How
Phase 0 — Audit first (establish the real baseline; no code changes)
grep -rn "contentDescription\|semantics(" app/src/main/java/— inventory what exists (expected: nothing).app/src/main/java/io/github/stozo04/openloop/ui/.adb shell am start -W -n io.github.stozo04.openloop/.MainActivityx 10, record median TTID; note whether the baseline profile is actually installed/compiled (adb shell dumpsys package dexopt | grep -A2 openloop).baselineprofilemodule actually generates and the profile ships in the release AAB (check forbaseline-prof.txtin the merged artifacts). It existing in the build script is not the same as it working.dumpsys gfxinfo io.github.stozo04.openloopwhile scrolling the Gallery grid and while the Loopifying progress screen animates; record janky-frame %.Phase 1 — Accessibility (the substantive half)
app/src/main/java/io/github/stozo04/openloop/ui/: everyIcon/IconButtongets a realcontentDescription(or explicitnullONLY when a sibling text label makes it decorative); sliders getstateDescription/semantics { }so TalkBack announces the actual speed value ("1.5x"), not "50 percent"; the capture button announces what it does and its state; progress screens announce progress. Strings go instrings.xml, not literals — they must be translatable.Modifier.minimumInteractiveComponentSize()or padding, not layout rewrites.ui/theme/Theme.kttokens, mirroring the token-level (not per-usage) approach that worked in stevengates#35.Settings.Global.ANIMATOR_DURATION_SCALE== 0) for the loopifying/editor animations — verify Compose animations honor it; anything hand-rolled must check it.app/src/androidTest/.../ui/. Enable accessibility checks on the Compose test rule (Compose UI test 1.8+ integrates the Accessibility Test Framework; the API iscomposeTestRule.enableAccessibilityChecks()— verify the exact API against the Compose BOM version ingradle/libs.versions.tomland upgrade the BOM if needed). Turn them on for every existing screen test rather than writing new tests. Suppressions require a written justification comment, same spirit aslint-baseline.xml.Phase 2 — Performance, measured
:macrobenchmarkmodule (androidx.benchmark macro-junit4), sibling to the existing:baselineprofilemodule and reusing its target setup:StartupTimingMetricfor cold start,FrameTimingMetricfor Gallery scroll and the editor. This is the Android equivalent of the Lighthouse numbers — it turns "feels fast" into a number that can regress visibly.docs/ANDROID_STANDARDS.mdor a newdocs/PERFORMANCE.md.CompilationMode.NonevsCompilationMode.Partial— if the profile isn't measurably helping, that's a finding to report, not hide.Phase 3 — Lock it in CI
.github/workflows/(only markdown static-analysis, doc-layout, and crashlytics-autotriage). Addandroid-ci.yml: JDK setup + Gradle cache,./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebugon every PR. Unit tests + build are hard gates; lint stays advisory per the repo's documented tier design (docs/STATIC_ANALYSIS.md) — surface the report, let the pr-reviewer skill judge it.reactivecircus/android-emulator-runner(API 34, x86_64) or Gradle-managed devices — whicheverdocs/conventions prefer; note the camera tests may need the emulator's virtual camera or the synthetic fixtures inapp/src/androidTest/assets/. If the full suite is too slow for PRs (> 15 min), split: a11y-critical screen tests on PR, full suite nightly viaschedule:+workflow_dispatch. State the split explicitly in the workflow comments.schedule:(nightly/weekly) with results published as a workflow artifact + a tracked JSON in the repo (or Firebase Test Lab physical devices if the free quota suffices — decision gate below). PR-blocking on emulator benchmark numbers is a known flake generator; don't do it without hysteresis.README.md(it already makes the quality claim — back it) and in the Play Store listing materials underdocs/play-store/if applicable.Decision required from Steven (answer in comments before the dependent work starts)
Dependencies / coordination
.claude/skills/run-e2e*) drive real devices via PowerShell on Steven's machine; nothing here replaces them. CI gates are the floor, the skills remain the ceiling.Verification — required, no exceptions
./gradlew :app:testDebugUnitTestgreen; androidTest suite green WITH accessibility checks enabled (list any suppressions + justification in the PR body).