Skip to content

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 into CapturePipeline via com.zharif.autobudget.DEBUG_INJECT (extras: pkg, title, body, ts).
  • ParserEngine merges BankRuleSets.debugShellRules (12 generic rules) when BuildConfig.DEBUG, so shell-posted fakes parse end-to-end.
  • AutoBudgetApp.onCreate (debug) auto-adds com.android.shell to 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 --broadcast drives it; --duplicate proves 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 in scripts/, app-side scripts in android-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

  1. Merge conventional commits to main → semantic-release bumps version, writes CHANGELOG.md, tags vX.Y.Z.
  2. Tag published → build-artifacts.yml builds and uploads AutoBudget-<version>.apk and AutoBudget-<version>.aab to the release, and (if enabled) the AAB to Play.
  3. To rebuild/re-upload an existing version (e.g. after a failed Play upload), run build-artifacts.yml manually from the Actions tab with the tag input set to the existing tag.
  4. 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.