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.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 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/)

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.3versionName=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

  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. 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.