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.7.1 | wrapper; checksum-pinned distribution |
| AGP | 9.3.1 | com.android.application; compileSdk release(37) |
| Kotlin | 2.3.10 | AGP 9.x built-in Kotlin embeds KGP 2.2.10; root build.gradle.kts classpath lifts KGP to 2.3.10 (documented AGP 9 route) to consume Kotlin 2.3-compiled libraries |
| KSP | 2.3.11 | Room + Hilt processors; decoupled from the compiler version since KSP 2.3.0 |
| Compose BOM | 2026.08.00 | Material 3, window-size-class, icons |
| Room | 2.8.4 | KSP, schema export on |
| Hilt | 2.60.1 | + hilt-navigation-compose |
| SQLCipher | 4.18.0 | sqlcipher-android — natively 16 KB-aligned native libs |
| kotlinx-serialization | 1.11.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.14.0 | offline vector basemap in the location picker (ADR-0030) |
| minSdk / targetSdk | 26 / 37 | Java 11 source/target |
gradle/libs.versions.toml is the single version catalog. Release build:
R8 minify + shrink resources, signed with the Play upload key when RELEASE_*
env vars are set (CI) and the debug key otherwise — either way
assembleRelease yields an installable APK for local/E2E verification.
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.
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).
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/) |
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.
CI
| Workflow | Trigger | Job |
|---|---|---|
android-tests.yml |
PR → main (ignores .github/**, .idea/**, .opencode/**, docs/**, **.md) |
test (./gradlew test), lint, ktlintCheck; plus connectedDebugAndroidTest on a KVM-accelerated emulator (instrumented job, 20 min cap) |
docs-guard.yml |
PRs touching docs/, AGENTS.md, CONTEXT.md, README.md | runs scripts/docs-guard.sh: link/path checks, doc budgets, ADR parity |
build-artifacts.yml |
GitHub release published, or manual workflow_dispatch with a tag input (re-run/re-upload an existing version) |
notes (AI release notes — runs for both triggers; only the release-edit step is gated on real release events), then build assembleRelease (APK) + bundleRelease (AAB) with version from tag; upload APK + AAB to release; optionally upload AAB to Play (internal) |
release.yml |
push → main | semantic-release (via zharif/reusable-workflows), conventional commits |
Versioning: tag v1.2.3 → versionName=1.2.3, versionCode = MAJ*10000 + MIN*100 + PAT.
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 upload is off by default: build-artifacts.yml uploads the AAB
to the internal track only when vars.ENABLE_PLAY_UPLOAD == 'true'. Release
signing uses the upload key (Play App Signing) when the RELEASE_* env vars /
secrets are present; otherwise it falls back to the debug key. The Play 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.
When the Play upload is enabled, build-artifacts.yml also writes the release
notes to distribution/whatsnew/whatsnew-en-US and passes it to the action via
whatsNewDirectory, so the Play internal track release carries the release
notes (plain text, truncated to Play's 500-char limit; falls back to the bare
version string if empty).
AI release notes
On a published release, build-artifacts.yml runs an AI Release Notes job
before building: 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 job applies title +
notes_markdown to the GitHub release via gh release edit; play_notes
becomes the Play whatsnew text. Model/keys reuse the CI_AI_* vars/secrets from
_opencode.yml. Failure handling is deliberate: the job runs with
continue-on-error and never receives a GitHub token (a plain bash step does
the edit) — if OpenCode returns no usable JSON, the original semantic-release
notes are kept and Play falls back to the CHANGELOG.md section. 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 uses a committed baseline (app/config/ktlint/baseline.xml)
generated with ktlintGenerateBaseline — pre-existing style deviations are
tracked there (matched by rule + file + exact line/column), so only new
violations fail CI. Edits that shift lines inside a baselined file unmask its
entries — adjust them or run ./gradlew ktlintGenerateBaseline to fold fixed
files out of the baseline.
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 › Data.
scripts/cut-map-pack.sh 2026-08-24 # writes dist/autobudget-my-z13-20260824.pmtiles
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
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. The companion
workflow (docs-screenshots.yml) is currently disabled — byte-comparing
screencaps across emulator runs proved non-deterministic — so regenerate
locally when a screen changes (needs adb and an emulator) and commit the
refreshed images in the same PR as the UI change.