android-release-readiness-audit
Audit an Android/Compose app for production readiness with evidence instead of recall — release-build and signing checks, machine-readable lint triage via the SARIF report, and the four gaps that are
SKILL.mdskills/managed/android-release-readiness-audit/SKILL.md
GitHubAndroid release-readiness audit
Produce a prioritised, evidence-backed gap list. Never answer this question from recall — every claim below has a one-line command that proves it.
Split the answer into two tiers, because they are different bars: Tier 1 = blockers for any real install (even self-hosted); Tier 2 = blockers for a public store release. Recommend Tier 1 first, and specifically the items that can destroy user data.
1. Does a release artifact even exist
java -cp gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain \
:app:assembleRelease --console=plain 2>&1 | sed -n '/^e:/p;/FAILED/p;/BUILD/p'
ls -la app/build/outputs/apk/release/
app-release-unsigned.apk is the tell: no signing config. Confirm with
grep -c "signingConfig" app/build.gradle.kts. Creating a keystore is the user's
call — it is a credential they should own; flag it, do not generate one.
Note the APK size here. With isMinifyEnabled = false and no proguardFiles
(grep both), a Compose + Supabase/ktor app lands around 60–70 MB.
2. Lint, machine-readable
Do not eyeball the HTML report. Run :app:lintRelease, then parse the SARIF. A flavoured app
writes lint-results-<flavour>Release.sarif (Ironvellum: lint-results-fossRelease.sarif), and a
lint-results-release.sarif left from before the flavours still sits there reading as current
(verified 2026-09-24): point the script at the flavoured file.
import json, pathlib, collections
d = json.loads(pathlib.Path("app/build/reports/lint-results-release.sarif").read_text(encoding="utf-8"))
run = d["runs"][0]
rules = {r["id"]: r for r in run["tool"]["driver"].get("rules", [])}
sev, by_rule = collections.Counter(), collections.Counter()
for res in run.get("results", []):
rid = res.get("ruleId", "?")
lvl = res.get("level") or rules.get(rid, {}).get("defaultConfiguration", {}).get("level", "warning")
sev[lvl] += 1; by_rule[(lvl, rid)] += 1
print(dict(sev)); print(by_rule.most_common(15))
IconLauncherShape + IconDuplicates in quantity = placeholder launcher icon
copied across densities. DefaultLocale on formatted numbers matters if you ever
localise.
3. The four gaps that are almost always missing
Room migration tests. ls app/src/androidTest — frequently absent entirely.
Check exportSchema and whether fallbackToDestructiveMigration was removed:
grep -n "version = \|exportSchema\|fallbackToDestructiveMigration" app/src/main/kotlin/**/Database.kt
grep -c "MIGRATION_" app/src/main/kotlin/**/Database.kt
Hand-written migrations + no destructive fallback + no instrumentation tests means one bad migration is a crash on launch with no recovery. This is usually the single highest-risk item; say so plainly.
Auto-backup vs the app's own privacy claim. If the manifest has
allowBackup="true" and no dataExtractionRules/fullBackupContent, the local DB
is uploaded to Google. Grep the UI for privacy copy ("never leave this device",
"stays on your phone") — if any exists, the manifest contradicts it and that is a
real defect, not a nit.
Crash reporting. grep -ciE "crashlytics|sentry|bugsnag" app/build.gradle.kts gradle/libs.versions.toml
→ 0 means production is blind.
Silently blank BuildConfig secrets. Config read from a gitignored
local.properties with getProperty(key, "") ships a release with empty values
and the feature dead at runtime instead of failing the build. Verify the fallback
default and whether anything fails fast.
4. Store-only requirements (Tier 2)
targetSdkmust be a stable API level; verify against Play's current target API policy rather than assuming — a too-new value is rejected.- Sensitive permission families (Health Connect
health.READ_*, location, background) need a store data-safety declaration and a hosted privacy policy URL. An in-app rationale activity is necessary but not sufficient. - OAuth consent screens left in Testing only admit listed test users.
- Hardcoded UI strings (only
app_nameinstrings.xml) block localisation.
5. Also check, cheaply
grep -rn "TODO\|FIXME\|not implemented" app/src/main/kotlin --include=*.kt | head
ls app/src/test/**/ && ls app/src/androidTest 2>/dev/null
Tests that cover only domain/ while Repository, DAOs and every screen are
untested is worth stating explicitly — it explains why UI regressions only ever
surface on-device.
Reporting
Table per tier with a concrete file:line for every claim. Mark anything you could
not verify as [INFERENCE] with the check that would settle it. Close by naming the
2–3 data-destroying items to do first, and ask before anything requiring a
credential the user should own.