Skip to content

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
  • Data layer — ER diagram, entities, DAOs, migrations
  • Ingestion pipeline — listener, filter, pipeline sequence
  • Parser engine — rules, amount parsing, confidence, golden tests
  • Classification — the CategoryClassifier seam
  • 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