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, no cloud, and no network permission in the app.

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/ — classification seam"]
        CC[CategoryClassifier interface]
        KC[KeywordClassifier]
    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/export codecs in common/ 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: parser/ never imports ui/; ui/ talks to data/ only through repositories and ViewModels.

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
.data Room + SQLCipher, entities, DAOs, repositories, seeds AppDatabase, Entities, Daos, DatabasePassphrase, TransactionRepository, SettingsRepository, …
.di Hilt wiring AppModule
.common Money, export/import codecs, app lock, time ranges, FX, itemization, notification access Currency, MoneyFormatter, MoneyText, ExportCodec, UnmatchedJsonCodec, UnmatchedExportCodec, AppLockManager, LockClock, TimeRange, Fx, Itemization, NotificationAccess
.ui Compose screens + ViewModels + navigation + theme AutoBudgetNavHost, per-feature screens/ViewModels, ui/theme, ui/components

Capture & deduplication

The notification → transaction sequence, routing table, replay behavior and cross-source dedupe live in ingestion-pipeline.md; the dedupe key formula is defined in parser.md. In short: a bounded session-level set skips rebind re-processing, and the DB unique index on transactions.notificationKey is the durable guarantee — replay on connect is idempotent through both.

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), 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 network ADR-0030
Debug-only fixture injection harness ADR-0014
Offline-first, no INTERNET permission 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
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
  • 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