Architecture Overview
Scope: owns the system map, layer diagram, module map, and the descriptive ADR index table.
What AutoBudget is
AutoBudget is an offline-first Android budget tracker for the Malaysian
market. A NotificationListenerService captures bank and e-wallet push
notifications, a pure-Kotlin parser engine turns them into transactions, a
keyword classifier categorizes them, and everything is stored in a
SQLCipher-encrypted Room database and shown in a Jetpack Compose UI.
There is no server and no cloud. No app code performs network I/O; the release manifest nevertheless carries network permissions merged from the MapLibre map renderer (ADR-0015 Amendment).
System context
flowchart LR
subgraph Device
subgraph "Bank & wallet apps (user-enrolled)"
B1[Maybank2u / MAE]
B2[TNG eWallet / GrabPay / Boost]
B3[BigPay / CIMB / PBe / RHB / ShopeePay / Wise]
end
subgraph AutoBudget
L[NotificationListenerService]
P[CapturePipeline]
D[(SQLCipher Room DB)]
U[Compose UI]
K[Android Keystore]
end
subgraph "System services"
NL[NotificationManagerService]
end
end
B1 -->|notification| NL
B2 -->|notification| NL
B3 -->|notification| NL
NL -->|posted + replay| L
L --> P
P --> D
D <--> U
D -.passphrase encrypted.-> K
U -->|user actions| D
Layered architecture
flowchart TB
subgraph UI["ui/ — Compose screens + ViewModels"]
NAV[AutoBudgetNavHost]
VM[Home / Transactions / Budgets / Reports / Settings / Onboarding / Unmatched / Lock ViewModels]
SHARED[shared components: TransactionRow, MonthSelector, EmptyState, SectionHeader, CategoryIcon]
end
subgraph DOMAIN["domain/ — use cases and seams"]
CC[CategoryClassifier interface]
KC[KeywordClassifier]
TR[TransactionRunner seam]
SU[TransactionSaveUseCase]
DU[TransactionDeletionUseCase]
end
subgraph PARSER["parser/ — pure Kotlin, no Android deps"]
PE[ParserEngine]
AP[AmountParser]
ND[DirectionDetector / StatusDetector]
EJ[EmbeddedJsonParser]
NK[NotificationKey]
MN[MerchantNormalizer]
end
subgraph LISTENER["listener/ — capture"]
NLS[TransactionNotificationListener]
EX[NotificationExtractor]
NF[NotificationFilter]
CP[CapturePipeline]
TN[TransactionNotifier / NotificationGate]
LOC[LocationProvider]
end
subgraph DATA["data/ — Room, repositories"]
DB[AppDatabase + SQLCipher]
ENT[Entities]
DAO[DAOs]
REPO[Repositories]
SEED[DefaultCategories + BankRuleSets]
end
subgraph COMMON["common/ — money, export, security, time"]
MONEY[Currency / MoneyFormatter / MoneyText]
EXP[ExportCodec / UnmatchedJsonCodec / UnmatchedExportCodec]
LOCK[AppLockManager / LockClock]
TR[TimeRange]
NA[NotificationAccess]
end
NAV --> VM
VM --> REPO
VM --> COMMON
VM --> SHARED
REPO --> DAO
DAO --> ENT
ENT --> DB
SEED --> DAO
CP --> NF
CP --> PE
CP --> CC
CP --> REPO
CP --> NK
CP --> TN
NLS --> EX
NLS --> LOC
NLS --> CP
PE --> AP
PE --> ND
PE --> EJ
CC --> MN
CC --> DAO
MONEY --> AP
LOCK --> REPO
Layering rule: parser/ and the money codecs in common/ plus the export
codecs in data/export/ are pure Kotlin — no Android imports — so they are
unit-testable on the JVM without Robolectric. Everything else may depend on
Android. Nothing may depend upward: common/ never imports data/ (breaking
the old cycle), listener/ and qs/ never import ui/, domain/ has no
android.* imports, and parser/ never imports ui/. ui/ talks to data/
only through repositories and ViewModels. scripts/arch-guard.sh enforces
these edges in CI (issue #326).
Startup
AutoBudgetApp runs first-run seeding, the orphan sweep and the receipt-cache
sweep through StartupCoordinator, which retries once and exposes
Loading | Ready | Failed. MainActivity gates content with StartupGate:
a failure shows a recoverable "Try again" screen instead of an uncaught
coroutine exception (issue #321).
Module map
| Package | Responsibility | Key types |
|---|---|---|
com.zharif.autobudget |
App entry, main activity, debug fixture harness | AutoBudgetApp, MainActivity, DebugFixtureReceiver (debug) |
.listener |
System-bound capture | TransactionNotificationListener, CapturePipeline, NotificationExtractor, NotificationFilter, TransactionNotifier, TransactionNotificationText, NotificationGate, LocationProvider |
.parser |
Pure parsing: rules, amount/currency, direction/status, JSON payloads, dedupe keys, merchant normalization | ParserEngine, ParserRule, AmountParser, Currency (via common), EmbeddedJsonParser, NotificationKey, MerchantNormalizer, BankRuleSets |
.domain.category |
Classification seam + keyword implementation | CategoryClassifier, KeywordClassifier |
.domain.lock |
App-lock gate (PIN KDF, session, lockout) | AppLockManager, LockResult, LockSnapshot |
.data |
Room + SQLCipher, entities, DAOs, repositories, seeds, export codecs | AppDatabase, Entities, Daos, DatabasePassphrase, TransactionRepository, SettingsRepository, data/export/ExportCodec, … |
.di |
Hilt wiring | AppModule |
.common |
Money, shared deep-link builders, time ranges, FX, itemization, notification access | Currency, MoneyFormatter, MoneyText, DeepLinkUris, LockClock, TimeRange, Fx, Itemization, NotificationAccess |
.startup |
First-run seeding state + one retry | StartupCoordinator, StartupState |
.ui |
Compose screens + ViewModels + navigation + theme | AutoBudgetNavHost, per-feature screens/ViewModels, ui/startup/StartupGate, ui/theme, ui/components |
Capture & deduplication
The notification → transaction sequence, routing table, replay behavior, duplicate suppression and cross-source dedupe live in ingestion-pipeline.md; the dedupe key formula is defined in parser.md.
Why the design looks the way it does
Each decision below is formalized as a numbered ADR under
docs/adr/ with context, decision, and consequences — read the
ADR before changing behavior in its area:
| Decision | ADR |
|---|---|
occurredAt is the notification post time, no date extraction from text |
ADR-0001 |
Matched-rule provenance: ruleId FK populated via ParserRule.dbId |
ADR-0002 |
| NONE-confidence candidates route to the unmatched queue | ADR-0003 |
Rules loaded per notification from the rules table; no static fallback |
ADR-0004 |
New starter rules re-seeded onto existing installs once per seed version (sourceId + rules_seed_version) |
ADR-0019 |
| Keyword pre-filter, masked routing, session dedupe, 48h replay | ADR-0005 |
Filtering is in-memory (TransactionFilter over a month list) |
ADR-0006 |
Category overrides live behind the classification module, keyed (package, merchantNormalized) |
ADR-0007 |
Tracked-banks CSV lives in SettingsRepository with legacy renames |
ADR-0008 |
Money is Long minor units only; single Currency tables |
ADR-0009 |
| SQLCipher + Android Keystore passphrase; backups disabled | ADR-0010 |
16 KB page-size support via sqlcipher-android migration |
ADR-0011 |
| App lock: salted hash, lockout, biometric, auto-lock | ADR-0012 |
Hand-rolled Compose Canvas charts, no chart dependency |
ADR-0013 (superseded by ADR-0028) |
| Report charts via ComposeCharts 1.0.0 (donut + columns), adaptive full total, app-side labels/semantics | ADR-0028 |
| Categorical chart palette from full-tone accents, ≥3:1 card contrast; sub-2% slices merge into "Other" | ADR-0029 |
| Offline vector basemap for the location picker: bundled z≤9 PMTiles overview, user-imported street-level packs, MapLibre rendering, no app network I/O | ADR-0030 |
| Debug-only fixture injection harness | ADR-0014 |
| Offline-first: no app network I/O; MapLibre merges network permissions | ADR-0015 |
| Silent transaction-captured notification + parse-source tracking | ADR-0016 |
| Editor edits currency (reformat preserves minor units), date/time, and previews export JSON | ADR-0017 |
| Play Store distribution: Play App Signing upload key, AAB + APK, opt-in internal-track upload | ADR-0018 |
Play track promotion through one reusable publish workflow (upload/promote); manual alpha/beta/production dispatch, production draft default |
ADR-0080 |
| Android unit/lint and instrumented gates validate PRs, plus the merge queue when enabled; main pushes do not re-run the same emulator suite, release artifacts keep their own gates | ADR-0081 |
Activity hidden rows keep readable role colours plus a localized Hidden pill; every immediate setting is one named SettingsSwitchRow; active query/uncategorised filters, row actions, section expansion and locale/device dates are explicit |
ADR-0082 |
| Review-card money is labelled detected metadata only for one unambiguous amount; balance/limit/fee or multiple amounts show no payment amount, and the debug copy hint follows diagnostics | ADR-0083 |
| Cross-source dedupe: bank preferred over a Google Wallet mirror (same card charge recorded once) | ADR-0020 |
Captured-location columns ready on transactions/unmatched; capture + resolution deferred |
ADR-0021 |
| Custom category CRUD with category-aware delete | ADR-0022 |
| Full Material icon catalog with searchable lazy picker | ADR-0023 |
| Manual transaction itemization: child rows, signed costs, non-blocking mismatch warning | ADR-0024 |
| Offline FX conversion for foreign-currency aggregation | ADR-0025 |
| Location capture (opt-in) via cached last-known fix + offline coordinate picker | ADR-0026 |
| Background location capture: "Allow all the time" grant + live one-shot fix with cached fallback | ADR-0034 |
| Transaction attachments: metadata in DB, bytes in app filesDir, whitelist + 10 MB cap | ADR-0027 |
Single deep-link router: whitelist autobudget:// grammar, validated prefill params, no exported link components |
ADR-0031 |
Theme-aware offline basemap: color-token style asset + Kotlin light/dark palettes via LocalDarkTheme |
ADR-0032 |
| Receipt photo capture: system camera (no CAMERA permission), auto-detected perspective crop, staged as IMAGE attachments | ADR-0033 |
Public privacy policy at https://autobudget.zharif.my/privacy/ + in-app link |
ADR-0035 |
Budget carry-forward with selected-month editor and retained currency; clearing writes a sentinel 0 row |
ADR-0036 |
Export row carries the captured-location snapshot; CSV appends 7 location columns after items, ExportFile.version is 3 |
ADR-0037 |
| Notification sanitization drops leaked framework class-name tokens and collapses whitespace before digit masking | ADR-0038 |
Full Currency catalog + user-enabled picker subset (KEY_SUPPORTED_CURRENCIES); removal is hide-only |
ADR-0039 |
Budget card shows real utilization above 100%: uncapped % label, fill bar stays clamped to full width |
ADR-0040 |
Review-queue "Clear all" soft-dismisses pending rows (isDismissed = 1), confirm-gated; hard delete stays Settings-only |
ADR-0041 |
| Peer-tab crossfade, bounded reversible detail motion; Home/Reports month-scoped transaction pushes | ADR-0042 |
| Home statistics reflow in measured, constrained cells with complete amounts | ADR-0043 |
| Compact navigation keeps one bounded row of full labels, scrolling when narrow | ADR-0044 |
| Amount/currency fields reflow from measured text widths while preserving draft and focus | ADR-0045 |
| Transaction Back guards unsaved changes with Save / Discard / Keep editing | ADR-0046 |
| Budget actions in one trailing card overflow; separate confirmed limit removal and custom-category deletion | ADR-0047 |
| Searchable task-based Settings; explicit Save and discard guard for rates/keywords | ADR-0048 |
| Compact Home review count, direct queue action, optional explanation | ADR-0049 |
| Compact Activity controls: spaced pill search/filter sheet, top-bar Add outside the list (Budgets uses the same top-bar Add) | ADR-0050 |
| Persistent transaction Save above the keyboard; IME Next/Done reuse ADR-0046's guarded save path | ADR-0051 |
| Review guidance uses stored masking evidence; unknown reasons stay unknown and editor prefills remain suggestions | ADR-0052 |
| Bounded two-column Home with coordinated scrolling and summary-first traversal | ADR-0053 |
Export/import carries stable notificationKey (format v4); legacy rows get synthetic keys |
ADR-0054 |
| Import rejects malformed/unsupported backups with a typed, localized error and no ledger mutation | ADR-0055 |
| Editor validation errors name the field that raised them and clear once it is corrected | ADR-0056 |
| Category editor Back guards unsaved drafts with Save / Discard / Keep editing | ADR-0057 |
| Onboarding capture setup is optional: manual-only setup records no tracked packages, and empty Home offers a manual Add action | ADR-0058 |
| Onboarding bank picker lists installed apps only (Show all opts in) and preselects the installed subset, independent of visibility | ADR-0059 |
| Onboarding shows a localized step count, offers Back after consent (choices preserved, permissions untouched), and pins the primary action | ADR-0060 |
| Collapsed editor section summaries wrap rather than clip silently | ADR-0061 |
One spacing scale; one sm screen-level rhythm on every destination |
ADR-0062 |
| One settings row with a vertically centred trailing slot | ADR-0063 |
| One shared search field used by Activity and Settings | ADR-0064 |
The design language is enforced by tokens, shared components and ui-guard.sh |
ADR-0065 |
| Wide destinations share one adaptive content width (1200/600 dp); Reports pairs the snapshot with the charts | ADR-0066 |
The Activity destination keeps one user-facing name (tab label and header agree); the route id stays transactions |
ADR-0067 |
| Settings sections use one shared rounded card; rows stay transparent | ADR-0068 |
Emphasis, surface and type roles are named once (Roles.kt); grouping uses surfaceContainerLow, one primary-filled hero |
ADR-0069 |
| The editor's Location section previews a saved point on the passive offline map, with honest full/overview/missing/corrupt/out-of-coverage states | ADR-0070 |
| One content-motion policy: item motion (#199), onboarding step transitions (#200) and coordinated month transitions (#201) under ADR-0042 | ADR-0071 |
| Activity's empty state names its reason (empty month vs no search matches) and offers a combined query/filter reset | ADR-0072 |
Unmatched captures carry a durable nullable notificationKey; review-queue replay is idempotent across sessions |
ADR-0073 |
| A bank capture reconciles with a Google Wallet mirror in one transaction, merging in place so the mirror row id and user edits survive | ADR-0074 |
| One deletion owner removes a transaction's rows and its attachment bytes together; the orphan sweep reclaims leftovers while sparing a live draft's staged paths outright and falling back to a 10-minute grace window for files left by a gone process | ADR-0075 |
| JSON import validates every financial field before any write and commits the whole file in one transaction; read, validation and save failures stay distinguishable | ADR-0076 |
| Imported documents are read with a 16 MiB cap; an oversize picked file is rejected before it is fully read, with no ledger writes | ADR-0077 |
Opt-in screenshot/Recents protection sets whole-window FLAG_SECURE from cold start; default off, platform limits stated |
ADR-0078 |
| Warm ledger visual language: warm paper + forest palette, bundled DM Sans and tabular Newsreader, role/type steps, 12/20/24 dp spacing, 16/20/28 dp shapes, one motion vocabulary, dynamic-colour fallback and the money-figure policy | ADR-0079 |
| Reports selection persists after its tooltip timeout until toggle-off/month/data change; Home's budget marker requires a configured limit; one quiet review surface orders Home the same at both breakpoints | ADR-0084 |
| A budget card is one named edit/set-limit action with a visible affordance; View transactions stays separate and the left swipe only exists when a destructive action does | ADR-0085 |
| Saved attachments reopen read-only (in-app image viewer or one-file temporary provider grant), the receipt viewer adds identity and bounded non-gesture zoom/pan/reset, and typed WGS-84 location entry is validated without weakening the lock | ADR-0086 |
| The month picker measures/wraps full localized names, the short-height rail scrolls and reveals its selection (RTL included), Settings/category reuse the adaptive reading width, and motion/contrast choices stay measured and owned | ADR-0087 |
| Home statistics choose three/two/one measured columns from the widest word or monetary token at the active font scale; section actions stack under the title when needed and Home's inline action reserves the shell FAB column | ADR-0088 |
Related docs
- Data layer — ER diagram, entities, DAOs, migrations
- Ingestion pipeline — listener, filter, pipeline sequence
- Parser engine — rules, amount parsing, confidence, golden tests
- Classification — the
CategoryClassifierseam - Money — minor units, currency tables, formatting
- UI layer — navigation graph, screens, ViewModels, adaptive layout
- Security — app lock, masking, backup policy
- Tooling — build, 16 KB support, CI, tests, release