Tooling
Scope: owns build stack, codegen, CI, test layout, release process.
How the project builds, tests, and ships.
Build stack
| Component | Version | Notes |
|---|---|---|
| Gradle | 9.8.2 | wrapper; checksum-pinned distribution |
| AGP | 9.5.0 | com.android.application; compileSdk release(37) |
| Kotlin | 2.4.21 | Root build.gradle.kts classpath lifts Kotlin 2.4.21 above the AGP-embedded KGP 2.2.10 (documented AGP 9 route) to consume Kotlin 2.3+-compiled libraries |
| KSP | 2.3.12 | Room + Hilt processors; decoupled from the compiler version since KSP 2.3.0 |
| Compose BOM | 2026.09.00 | Material 3, window-size-class, icons |
| Room | 2.8.5 | KSP, schema export on |
| Hilt | 2.60.1 | + hilt-navigation-compose |
| SQLCipher | 4.19.1 | sqlcipher-android — natively 16 KB-aligned native libs |
| kotlinx-serialization | 1.12.0 | export/import, settings JSON |
| coroutines | 1.11.0 | |
| biometric | 1.1.0 | |
| compose-charts | 1.0.0 | report charts (ADR-0028) |
| maplibre-compose | 0.20.0 | offline vector basemap in the location picker (ADR-0030); maplibre-compose-runtime-opengl-android supplies the Android render backend |
| minSdk / targetSdk | 26 / 37 | Java 11 source/target |
gradle/libs.versions.toml is the single version catalog. Release build:
R8 minify + shrink resources. release is signed only with the Play upload key
(RELEASE_* env vars in CI) and fails without it — no debug-signed artifact
can be mistaken for an official release (issue #286). A real
assembleRelease/bundleRelease also requires an explicit -PversionCode
(issue #346); the releaseLocal build type runs the same R8 config signed with
the debug key and keeps the local default — use
./gradlew assembleReleaseLocal for local/E2E installs.
lint {} is a gate, not advice (issue #344): warningsAsErrors,
checkDependencies and abortOnError are on, and the committed
app/config/lint/baseline.xml records only pre-existing debt. Regenerate the
baseline with ./gradlew :app:updateLintBaseline only after reviewing each new
entry.
Dependency verification
Downloaded module artifacts are checksum-verified against
gradle/verification-metadata.xml (933 components at the 2026-11-02 baseline),
and gradle.properties sets org.gradle.dependency.verification=strict
(issue #342). A tampered or unexpected artifact fails dependency resolution
before compilation; a new dependency must be added deliberately, not silently.
After a planned dependency change, regenerate and review the metadata:
cd android-app
./gradlew --write-verification-metadata sha256 :app:lint :app:assembleDebugAndroidTest :app:assembleReleaseLocal :app:ktlintCheck
Commit the resulting gradle/verification-metadata.xml with the dependency
bump. Dependabot covers the gradle ecosystem weekly (issue #341) — minor and
patch updates arrive grouped, majors individually so the toolchain bump is
deliberate. A Dependabot PR fails dependency verification until its metadata
refresh is committed; that is deliberate, so no artifact enters the build
unreviewed. scripts/test-supply-chain.sh pins both the Dependabot config and
the strict-verification setup.
Build-config fields GIT_SHA (short git rev-parse --short HEAD) and
BUILD_DATE (UTC ISO 8601) are added per-variant via
androidComponents.onVariants + lazy providers.exec, so they resolve when
GenerateBuildConfig runs and configuration caching stays intact. They back
the About section of Settings. No CI step needed — actions/checkout
provides the git metadata and date -u is on every runner.
SUPPORT_EMAIL (Settings > About contact line) is a plain defaultConfig
buildConfigField, overridable per environment with -PsupportEmail=...;
defaults to app-dev@zharif.my.
JDK note
gradle-daemon-jvm.properties sets toolchainVersion=25 and the CI
workflows install JDK 25 (Temurin), so the daemon reuses it — no foojay
auto-download. AGP 9 needs JDK 17+ to run. See
CI notes.
Icon-catalog generator
android-app/scripts/generate_icon_catalog.sh regenerates
ui/components/MaterialIconCatalog.kt (the list of all Material "filled"
icon names + a chunked name→vector resolver, ADR-0023) from the
material-icons-extended sources jar. It defaults to the newest sources jar
in the Gradle cache; pass a jar path to target a specific version. Run it
whenever the Compose BOM bumps material-icons-extended; the generated file
is committed.
Design-language guard
scripts/ui-guard.sh enforces the one design language (ADR-0062–ADR-0069) with
targeted, allowlist-backed rules (scripts/ui-guard-allowlist.txt), failing
non-zero with FAIL: lines like docs-guard.sh. It checks that no file outside
ui/components/SearchField.kt builds its own search field — structurally, any
OutlinedTextField/BasicTextField/TextField in a file that also carries
search intent (a search-named resource, quoted label or search* identifier),
so a differently typed or hardcoded search box cannot evade it (issue #347) —
that every top-level
destination applies Spacing.titleToContent (and carries no raw 8/16 dp top
literal), that nothing under ui/settings/ hand-rolls a chevron or a raw
ListItem row, that settings sections route through
ui/components/SettingsSectionCard.kt (SettingsRow.kt paints no row background
and no screen defines its own section container), and that the composable owning
the title inset uses the one Spacing.sm screen-level rhythm — rule 6 rejects a
raw Arrangement.spacedBy/padding gap or a different token step, while ignoring
nested card and surface interiors. Rule 6 runs fail/pass self-test fixtures on
every invocation (raw gap fails; shared token and a card-interior literal pass).
The ui-guard job of docs-guard.yml runs it on pull requests.
bash scripts/ui-guard.sh
Third-party notices
android-app/THIRD_PARTY_NOTICES.md lists every bundled third-party component
and links its licence text under android-app/third-party/: the DM Sans and
Newsreader fonts, SQLCipher (BSD-3), MapLibre (BSD-2), compose-charts,
Material icons, AndroidX/Compose/Room/Hilt/Kotlin (Apache-2.0) and the
Protomaps/OpenStreetMap basemap data (ODbL 1.0). The repository LICENSE links
to it. scripts/check-third-party-notices.sh (run by docs-guard.yml) fails
when a named component is still declared in gradle/libs.versions.toml but
missing from the notices, when a referenced licence text disappears, or when a
bundled font is not listed (issue #348).
16 KB page-size support
Google Play requires Android 15+ targets to support 16 KB page sizes. The app
uses net.zetetic:sqlcipher-android, whose libsqlcipher.so has been built
16 KB-aligned since 4.6.1 — no build-time patching required. The legacy
android-database-sqlcipher library is end-of-life and will never ship a
16 KB-compatible build, which is why the app migrated off it (ADR-0011).
Release permission gate
The release manifest is not permission-minimal. The app declares
POST_NOTIFICATIONS and the three location permissions
(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION,
ACCESS_BACKGROUND_LOCATION); MapLibre's 0.20 OpenGL runtime adds INTERNET
and ACCESS_NETWORK_STATE (ADR-0015 Amendment); androidx.biometric adds
USE_BIOMETRIC and USE_FINGERPRINT; androidx.core adds its signature
permission. apkanalyzer manifest permissions on the release APK must return
exactly:
android.permission.ACCESS_BACKGROUND_LOCATION
android.permission.ACCESS_COARSE_LOCATION
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_NETWORK_STATE
android.permission.INTERNET
android.permission.POST_NOTIFICATIONS
android.permission.USE_BIOMETRIC
android.permission.USE_FINGERPRINT
com.zharif.autobudget.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION
The gate runs in build-artifacts.yml immediately after assembleRelease and
before any GitHub-release or Play upload (issue #300). The same job first
asserts the pinned upload certificate (scripts/check-upload-cert.sh, issue
340) and the merged release manifest/backup rules
(scripts/check-release-manifest.sh, issue #353) — no debug receiver, no
debug-injection action, and cloud backup/device transfer exclude every domain.
Run the permission gate locally after a release build (repo root; the APK path
is optional):
bash scripts/check-release-permissions.sh \
android-app/app/build/outputs/apk/release/app-release.apk
It sorts the actual set, prints every added/removed permission, and exits non-zero on any mismatch. A new entry is a release blocker until the docs, Play answers, and this gate are updated deliberately — this is what stops a later dependency bump from silently re-adding a network-dependent SDK. MapLibre itself still performs no request in the app: maps render from the bundled PMTiles overview or a user-imported pack.
Fake-notification harness
cmd notification post -p <package> was removed on API 35+ (posts go out as
com.android.shell), so debug builds include a broadcast receiver:
DebugFixtureReceiver(debug source set only) injects fixture notifications straight intoCapturePipelineviacom.zharif.autobudget.DEBUG_INJECT(extras:pkg,title,body,ts).ParserEnginemergesBankRuleSets.debugShellRules(12 generic rules) whenBuildConfig.DEBUG, so shell-posted fakes parse end-to-end.AutoBudgetApp.onCreate(debug) auto-addscom.android.shellto tracked packages — but only if at least one package is already tracked (a fresh, pre-onboarding install tracks nothing by design).android-app/scripts/post_fake_notifications.sh --broadcastdrives it;--duplicateproves dedupe.
android-app/scripts/post_fake_notifications.sh --broadcast
android-app/scripts/post_fake_notifications.sh --duplicate --broadcast
Path note: Gradle commands run from
android-app/; repo-root scripts live inscripts/, app-side scripts inandroid-app/scripts/. Commands below use full paths.
Tests
Unit tests run on the JVM (no Robolectric). CI runs ./gradlew test
(all variants); locally ./gradlew testDebugUnitTest is enough for app-code
work. Highlights by area — not an exhaustive inventory, see
app/src/test/java/com/zharif/autobudget/:
| Area | Files |
|---|---|
| Parser | ParseGoldenTest, AmountParserTest, DirectionStatusTest, EmbeddedJsonParserTest, NotificationKeyTest + fixtures/*.json |
| Listener | NotificationExtractorTest, NotificationFilterTest, NotificationGateTest, TransactionNotificationTextTest |
| Money/export | MoneyFormatterTest, ExportCodecTest, CurrencyTest |
| Security | AppLockManagerTest, AppLockViewModelTest |
| Codec | UnmatchedJsonCodecTest, UnmatchedExportCodecTest |
| Review queue | UnmatchedViewModelTest |
| Data | ConvertersTest, ParseSourceTest (lives in data/model/) |
| Design system | RoleContractTest, GalleryInventoryTest (debug gallery inventory), AdaptiveContentWidthTest |
Instrumented tests (connectedDebugAndroidTest) — highlights; full set under
app/src/androidTest/java/com/zharif/autobudget/: LockScreenTest,
SecuritySectionTest, AppearanceSectionTest, TransactionRowTest,
UnmatchedContentTest, DonutChartTest, TrendChartTest,
SqlcipherEncryptionTest
(encryption round-trip + wrong-passphrase rejection + not-plaintext).
Golden fixtures enforce ≥90% HIGH-confidence parses (ParseGoldenTest) —
see parser.md.
JVM coverage is measured with JaCoCo (issue #351). ./gradlew
:app:jacocoTestDebugUnitTestReport writes the HTML/XML report, and
:app:jacocoTestDebugUnitTestCoverage enforces a 40 % line-coverage floor
over app code (UI/Compose and generated classes excluded; measured 45.8 % on
2026-11-02). CI runs both with test and uploads jvm-coverage. The floor is
a non-regression tripwire, not a target: raise it when the measured baseline
improves, never lower it to make a red change pass.
CI
| Workflow | Trigger | Job |
|---|---|---|
android-tests.yml |
PR → main and merge_group when a merge queue is enabled; deliberately no push → main rerun (ADR-0081) (ignores .idea/**, .opencode/**, docs/**, **.md; workflow edits and Android sources trigger; PR runs cancel superseded heads while merge-queue runs are never cancelled) |
test (./gradlew test) + JaCoCo report and line-coverage floor, lint, ktlintCheck; plus connectedDebugAndroidTest on a KVM-accelerated emulator (instrumented job, 30 min cap; the tree declares 744 instrumented @Test methods across 117 classes on 2026-11-02, the 2026-10-18 430-test run took 16m04s, while the 2026-11-02 899-test local run took 18m54s — re-measure in CI and shard before the 30-minute cap rather than assuming the old duration) — the whole app/src/androidTest suite runs unfiltered, so ManualOnlyJourneyTest is reported by name, and its JUnit/HTML reports upload as the instrumented-results artifact on success or failure (best-effort: artifact-quota errors emit a warning and job summary instead of failing the lane), with scripts/summarize-instrumented-results.sh writing the failing class/method names to the job summary without a second emulator run; beside it, three PR smoke lanes cover the API 26, API 36 and API 37 device images and the release variant without re-running the full suite — smoke-min-api (Minimum API Smoke (API 26)) runs SqlcipherEncryptionTest, ManualOnlyJourneyTest, DeepLinkColdStartTest and AppLockGateTest on API 26, smoke-target-api (Target API Smoke (API 37.0)) runs the same critical journey on the newest supported platform, and smoke-release (Release Smoke (minified APK)) builds the debug-signed assembleReleaseLocal variant, asserts the APK is debug-key signed (CN=Android Debug, no production secret), then exercises it out of process — launch MainActivity, assert it resumes, assert no crash was logged, keep a screenshot (instrumentation is impossible there: the androidTest source set only builds for the debug variant, and a debug test APK cannot instrument an R8-minified app). Each lane pins its system-images coordinate in setup-android (unavailable image fails setup, never silently skips), caps its job at 20 min (smoke-min-api, smoke-target-api) / 50 min (smoke-release) — the release lane's emulator-boot-timeout: 1800 keeps the AVD-boot budget separate from that 50-minute job budget, after a 25-minute cap cancelled the job mid-boot — and uploads its evidence with if: always() (best-effort: artifact-quota errors warn, never fail the lane); the four emulator lanes run on the self-hosted kz runner group (runs-on: { group: kz }) and are serialized twice over — a needs chain plus if: always() orders them within a run, and a job-level concurrency group (group: kz-emulator, queue: max) keeps only one emulator lane running at a time across runs as well, FIFO-queueing later runs instead of cancelling them — because the group's runners (prod-worker0_0..3) share one host, and three concurrent KVM emulators hung the instrumented lane's VM (detected a hanging thread 'QEMU2 main loop'), after which its device disappeared mid-suite (DeviceException: No connected devices!); the unit/lint gate deliberately stays outside that group so fast feedback is never queued behind an emulator lane |
docs-guard.yml |
PRs touching docs/, AGENTS.md, CONTEXT.md, README.md, the guard scripts (scripts/ui-guard.sh, scripts/test-workflow-paths.sh, scripts/test-ci-smoke-matrix.sh, scripts/test-build-artifacts-version.sh, scripts/test-play-release-workflow.sh), app UI sources, or the android-tests.yml/build-artifacts.yml/play-release.yml/reusable-play-upload.yml workflows |
docs-guard job runs scripts/test-workflow-paths.sh (Android CI path-filter/check-name contract), scripts/test-ci-smoke-matrix.sh (smoke-lane contract), scripts/test-build-artifacts-version.sh (release version contract), scripts/test-play-release-workflow.sh (manual Play promotion contract), scripts/test-release-artifacts.sh (signing/manifest gates), scripts/test-supply-chain.sh (Dependabot + dependency verification), scripts/test-ci-hygiene.sh (timeouts, .idea, versionCode, map-pack, QA-driver wiring, PR/merge-queue trigger contract), scripts/test-lint-config.sh, python3 -m unittest discover -s scripts -p test_android_ui_qa.py (UI QA driver), then scripts/docs-guard.sh (link/path checks, doc budgets, ADR parity, test/code count parity); ui-guard job runs scripts/ui-guard.sh (ADR-0065 design language) |
build-artifacts.yml |
GitHub release published, or manual workflow_dispatch with a tag input (re-run/re-upload an existing version) |
version (validates the tag, computes versionName/versionCode; no LLM dependency), notes (optional AI release notes, continue-on-error; runs for both triggers, only the release-edit step is gated on real release events), build (needs: version — assembleRelease APK + bundleRelease AAB), upload APK + AAB to release; optionally calls reusable-play-upload.yml for the internal track (needs: notes only for the whatsnew text) |
play-release.yml |
manual workflow_dispatch only (tag, track alpha/beta/production/custom, release-status auto/draft/inProgress/completed, user-fraction) |
testing (alpha = closed, beta = open, play-closed-testing) and production (play-production) both call reusable-play-upload.yml with mode: promote; auto resolves to completed for testing and draft for production |
reusable-play-upload.yml |
workflow_call only |
validates tag/track/mode/status/fraction/versionCode, prepares the Play whatsnew text, WIF auth; mode: upload downloads the release AAB and uploads it, mode: promote updates the target track to the already-published versionCode (no re-upload); serialized package-wide by play-edit-com.zharif.autobudget |
release.yml |
push → main | semantic-release (via zharif/reusable-workflows), conventional commits |
Workflow-only changes are first-class: android-tests.yml deliberately does
not ignore .github/**, so a PR editing the Android test workflow itself
runs the same required gates it configures (Unit Testing & Linting Android
App, Instrumented Testing on Emulator). It validates PRs and, when a merge
queue is enabled, merge-group entries only. A merge queue run tests the exact
tree that lands; without one, a direct/admin merge accepts the PR-head result.
A later push to main does not re-run the same emulator suite (ADR-0081);
release artifact gates are unchanged. Docs-only PRs (Markdown files, docs/**)
and .opencode/**/.idea/** stay ignored on purpose, so they do not burn an
emulator run; if branch protection requires those job names, GitHub reports a
skipped workflow as pending rather than successful. docs-guard.yml watches
android-tests.yml and runs scripts/test-workflow-paths.sh, the contract
that rejects the old blanket workflow exclusion and the duplicate main-push
trigger, keeps docs-only paths
ignored, and pins the two check names. scripts/test-ci-smoke-matrix.sh is the
companion contract for the smoke lanes: it fails if a lane loses its image pin,
selector, timeout or if: always() evidence upload, if a signing secret creeps
into a lane, or if the full API 36 suite gains an include filter.
docs-guard.yml also watches build-artifacts.yml and runs
scripts/test-build-artifacts-version.sh (the tag/version-code contract), so a
release-workflow edit is guarded even though android-tests.yml ignores docs.
All three contracts live outside their workflows by design: a workflow-only PR
whose path filter is broken would never run a test embedded in that workflow.
Versioning: tag v1.2.3 → versionName=1.2.3, versionCode = MAJ*10000 + MIN*100 + PAT.
The independent version job validates the tag as exactly
vMAJOR.MINOR.PATCH (no leading zeros or prerelease suffix) with MAJOR at
most 210000/6 digits, MINOR/PATCH 0–99, and the resulting versionCode in
1–2100000000 (Play's ceiling). Component strings are length-bounded before
arithmetic, so overflow cannot wrap into a colliding version; an invalid tag
fails there with an ::error:: message before Gradle runs. Version metadata
is computed in that job, never by the optional AI notes job. The contract is
guarded by
bash scripts/test-build-artifacts-version.sh, which parses the workflow and
runs its embedded tag calculation against valid and invalid tags; run it after
editing build-artifacts.yml.
CHANGELOG.md is generated by semantic-release using the angular
preset (see .releaserc.json). The conventionalcommits preset package
(conventional-changelog-conventionalcommits ≥ v9) is incompatible with the
release-notes-generator bundled with semantic-release v25 — it produces
header-only, empty release notes. Marketplace actions are SHA-pinned;
reusable workflows from zharif/reusable-workflows are tag-pinned (@v2,
deliberate zizmor exception); permissions: {} least-privilege. Gradle
builds use gradle/actions/setup-gradle for dependency/build caching.
Play Store publishing has one implementation, reusable-play-upload.yml;
callers only choose the track, mode and gate. The automatic path is off by
default: build-artifacts.yml calls it with mode: upload,
track: internal only when vars.ENABLE_PLAY_UPLOAD == 'true'. Testing-track
(alpha = closed, beta = open) and production promotion are manual
play-release.yml dispatches with mode: promote (ADR-0080). Upload mode
downloads the signed AutoBudget-<version>.aab already attached to the GitHub
release — it never rebuilds — and uploads it. Promote mode computes the tag's
versionCode, verifies Play already lists it on a track, then updates the
target track through the Play Developer API; it never re-uploads a bundle
(Play rejects an existing versionCode). Both modes validate the tag/track/
mode/status/staged-rollout fraction and write the release notes to
distribution/whatsnew/whatsnew-en-US for whatsNewDirectory (plain text,
capped at 500 characters (Unicode code points); falls back to the
CHANGELOG.md section, then the bare version string). Release signing uses the
upload key (Play App Signing) and requires the RELEASE_* env vars / secrets;
release refuses to build without them. The upload action receives
the WIF credentials file via steps.auth.outputs.credentials_file_path
(serviceAccountJson input) — google-github-actions/auth with
create_credentials_file: true writes the ADC file and
r0adkll/upload-google-play requires that input explicitly. Promote mode
calls the Play Developer API with the auth step's access token, scoped to
https://www.googleapis.com/auth/androidpublisher. Play edits are
serialized package-wide (play-edit-com.zharif.autobudget, queue: max),
because Play allows one open edit per app/service account. Production
dispatches default to a draft release behind the play-production
environment.
AI release notes
On a published release, build-artifacts.yml runs an AI Release Notes job
alongside the deterministic version job; the artifact build does not wait
for it. Only the optional Play upload waits, for whatsnew text:
OpenCode (the run-opencode-task composite action from
zharif/reusable-workflows) reads the commits between the previous and new tag
(prompt: .github/prompts/release-notes.md)
and returns {title, notes_markdown, play_notes}. The notes job declares
contents: read — enough for actions/checkout to fetch the tag, but no write
token, so no step with the agent action can modify the repository (issue #287;
a bare permissions: {} made the checkout fail with "Repository not found").
A separate apply-notes job with contents: write runs only gh release edit
with the title and markdown passed as job outputs; play_notes becomes the Play
whatsnew text. Model/keys reuse the CI_AI_* vars/secrets from _opencode.yml.
Failure handling is deliberate: the notes job runs with continue-on-error —
if OpenCode returns no usable JSON, the original semantic-release notes are kept
and Play falls back to the CHANGELOG.md section. It carries no
version outputs: the build job needs only the version job, so an AI
failure, timeout or empty result cannot skip or change versionName/
versionCode. The Play upload job's explicit condition tolerates a failed or
skipped notes job (whatsnew falls back to CHANGELOG.md) but requires
needs.version.result == 'success' and needs.build.result == 'success';
!cancelled() means cancelling the workflow blocks the upload. Manual tag
rebuilds (workflow_dispatch) run the same notes job — only the release
edit step checks event_name == 'release', and the Play whatsnew output is
produced either way.
ktlintCheck wires the committed baseline explicitly
(ktlint { baseline = file("config/ktlint/baseline.xml") } in
android-app/app/build.gradle.kts, issue #301) and regenerates it with
./gradlew :app:ktlintGenerateBaseline. Without that block the plugin looked
for a baseline directly under the app module (which does not exist), so the
committed file suppressed nothing. The regenerated baseline is currently
empty — the tree
has no outstanding violations — so every rule is enforced and any new violation
fails CI. Baseline entries are matched by rule + file (line numbers are
refreshed on regeneration).
CI note: workflow and daemon both use JDK 25 — see getting-started.
Map packs (offline basemap, ADR-0030)
The bundled z≤9 Malaysia overview lives at
app/src/main/assets/maps/my-overview-z9.pmtiles; street-level packs are
not committed — they are cut from Protomaps' public daily planet builds
and attached to GitHub releases for users to import in Settings › Backup & data.
scripts/cut-map-pack.sh 2026-08-24 # writes dist/autobudget-my-z13-20260824.pmtiles
scripts/cut-map-pack.sh 2026-08-24 9 # writes dist/autobudget-my-z9-20260824.pmtiles
The archive name uses the actual maxzoom argument (issue #346); the
availability probe is a one-byte ranged GET rather than curl --head, because
Protomaps' bucket does not answer HEAD consistently.
Measured sizes for the MY bbox (99.6–119.3 E, 0.8–7.5 N), build 2026-08-24:
| Max zoom | Archive size |
|---|---|
| ≤9 | 5.3 MB |
| ≤13 | 114 MB |
| ≤14 | 226 MB |
| ≤15 | 456 MB |
Data © OpenStreetMap contributors (ODbL); keep the style's attribution
intact. The style (assets/maps/style.json), glyph fonts, and the overview
are the only committed map assets.
Emulator quick start
The optional local UI-audit harness, scripts/oneplus12-qa.sh, builds and
verifies installed debug APK hashes on a dedicated phone-sized AVD, runs the
device suite and drives UI journeys. Run artifacts and the emulator remain
under the ignored cache directory. Setup, destructive-reset scope and coverage
limits are owned by Testing.
adb install app/build/outputs/apk/debug/app-debug.apk
adb shell cmd notification allow_listener com.zharif.autobudget/.listener.TransactionNotificationListener
# complete onboarding in UI, then (from android-app/):
scripts/post_fake_notifications.sh --broadcast
Release process
- Merge conventional commits to
main→ semantic-release bumps version, writesCHANGELOG.md, tagsvX.Y.Z. - Tag published →
build-artifacts.ymlbuilds and uploadsAutoBudget-<version>.apkandAutoBudget-<version>.aabto the release, and (if enabled) the AAB to Play. - To rebuild/re-upload an existing version (e.g. after a failed Play
upload), run
build-artifacts.ymlmanually from the Actions tab with thetaginput set to the existing tag. - Local release artifacts land in
releases/.
Docs site
Everything under docs/ is published as a static site by Zensical,
configured in root zensical.toml (theme, curated nav, mermaid
superfences). Local preview: pip install zensical && zensical serve;
production build: pip install zensical && zensical build.
Publishing is Cloudflare Pages Git integration — no repository workflow:
build command pip install zensical && zensical build, output directory
site; every push to main republishes.
Maintenance rule: adding, renaming, moving, or deleting any page under docs/
updates both its subtree index and the curated nav in zensical.toml in the
same change (ownership: context-map.md).
Docs screenshots
User-guide and README images under docs/assets/screens/ are generated, not
shot by hand: scripts/generate_screenshots.sh builds the debug APK, boots or
reuses an emulator, seeds a deterministic demo month via the debug-only
DEBUG_SEED broadcast (ADR-0014 harness extension), then lands on each screen
through autobudget://<tab> deep links and screencaps it. Every capture waits
for a rendered, settled screen instead of a fixed sleep: the app process, a
foreground MainActivity, the screen's anchor label (third SCREENS field:
each route's title, e.g. Home, Transactions), a non-blank frame (sampled
colour count) and two consecutive frames with the same sampled-pixel signature
(animations/graphics settled; a blinking focus cursor is ignored). The wait is
bounded by READY_TIMEOUT (default 25s), and a timeout fails loudly, naming
what never appeared — no splash or blank image can be published as evidence. scripts/android_ui_qa.py applies the same
process/anchor/blank/stable readiness gate through Device.wait_ready (and an
anchor-free wait_settled before every shot), so its PNG/XML evidence is
equally trustworthy. The companion workflow (docs-screenshots.yml) is
workflow_dispatch-only and used on demand to refresh the images: it boots the
device with the shared android-emulator-runner action (same setup as the
instrumented-test job) and runs the script with --use-running, so no cold AVD
boot is hand-rolled. The script sets hide_error_dialogs and retries a missing
anchor, so a launcher ANR on a slow CI emulator cannot steal focus or block a
capture. Its drift step still fails when the PNGs differ —
byte-comparing screencaps across emulator runs is not yet deterministic — and
uploads the refreshed set as the screenshots-refreshed artifact; download and
commit those in the same PR as the UI change (or regenerate locally with
bash scripts/generate_screenshots.sh when you have adb and an emulator).
Docs recordings
scripts/generate_recordings.sh records the silent interaction clips embedded
in docs/user-guide/tour.md — one per
emulator-reproducible flow (onboarding, capture + dedupe, Home review + months,
Activity search/filter/hide/delete, manual add/edit + unsaved guard,
itemization/attachment/location, review convert/dismiss, budget
limit/category/swipe, report months + drill-down, settings
theme/currency/export-import, listener revoke/repair, PIN lock/unlock). It
reuses the screenshots' pinned docs-shots AVD, DEBUG_SEED demo month, SysUI
demo mode and pm clear-only-debug scope, drives each flow through
scripts/android_ui_qa.py, records with adb shell screenrecord, then pulls and
re-encodes to a silent faststart H.264 MP4 (8–20 s, ≤2 MB; a size guard shrinks
scale/bit-rate/fps rather than shipping an oversized clip). Only the demo
dataset and the debug fixture harness ever appear — never a real notification or
account.
The on-demand docs-screenshots.yml workflow runs the screenshot pass and then
the recording pass on one emulator (raised timeout) and always uploads the
screenshots-refreshed and recordings artifacts; it never commits. Download
the artifacts and commit the media deliberately. unsaved-changes and
transaction-detail keep their blinking-cursor caveat (those two PNGs are not
byte-stable, so the drift step stays intentionally failing), and hardware-only
biometric unlock is out of automated scope.