Skip to content

Data Layer

Scope: owns the DB schema story — entities, DAOs, migrations, schema history, encryption wiring.

Everything that persists lives behind Room + SQLCipher. There is exactly one database (autobudget.db, schema version 8), owned by AppDatabase.

ER diagram

erDiagram
    ACCOUNTS ||--o{ TRANSACTIONS : "accountId (SET NULL)"
    CATEGORIES ||--o{ TRANSACTIONS : "categoryId (SET NULL)"
    RULES ||--o{ TRANSACTIONS : "ruleId (SET NULL)"
    CATEGORIES ||--o{ CORRECTIONS : "categoryId (CASCADE)"
    CATEGORIES ||--o{ BUDGETS : "categoryId (CASCADE)"
    TRANSACTIONS ||--o{ TRANSACTION_ITEMS : "transactionId (CASCADE)"
    TRANSACTIONS ||--o{ ATTACHMENTS : "transactionId (CASCADE)"

    ACCOUNTS {
        long id PK "autoincrement"
        string bankLabel
        string maskedAccount
        string type "AccountType"
        string currency
        boolean isActive
    }
    CATEGORIES {
        long id PK "autoincrement"
        string name
        string icon "material icon id or legacy emoji"
        string kind "CategoryKind EXPENSE|INCOME"
        boolean isDefault
        int sortOrder
    }
    TRANSACTIONS {
        long id PK "autoincrement"
        string notificationKey "unique — dedupe"
        long accountId FK "nullable"
        long amountMillis "minor units"
        string currency
        string direction "Direction"
        string status "Status"
        string merchant
        string rawText "sanitized"
        long occurredAt "notification post time"
        long capturedAt
        string sourcePackage
        long categoryId FK "nullable"
        long ruleId FK "nullable"
        string note
        boolean isReviewed
        boolean isHidden "status != COMPLETED"
        string parseSource "ParseSource JSON|REGEX"
        string rawJson "embedded payload span, JSON only"
        double latitude "nullable — captured-location fix"
        double longitude "nullable"
        float accuracyMeters "nullable"
        string locationProvider "nullable"
        long locationCapturedAt "nullable"
        string placeName "nullable — future reverse geocode"
        string placeId "nullable"
    }
    RULES {
        long id PK "autoincrement"
        string packageName
        string label
        string pattern "Java named-group regex"
        string amountGroup
        string merchantGroup
        string accountGroup
        string directionKind "AUTO|DEBIT|CREDIT"
        string statusKind "AUTO|COMPLETED|PENDING|FAILED|REVERSED"
        int priority
        boolean isEnabled
        string sourceId "unique, nullable — stable seed id (ADR-0019)"
    }
    CORRECTIONS {
        long id PK "autoincrement"
        string packageName
        string merchantNormalized
        long categoryId FK "unique (packageName, merchantNormalized)"
    }
    SETTINGS {
        string key PK
        string value
    }
    UNMATCHED {
        long id PK "autoincrement"
        string sourcePackage
        string rawText
        long capturedAt
        boolean isDismissed
        boolean isMasked "v3+"
        double latitude "nullable — captured-location fix"
        double longitude "nullable"
        float accuracyMeters "nullable"
        string locationProvider "nullable"
        long locationCapturedAt "nullable"
        string placeName "nullable — future reverse geocode"
        string placeId "nullable"
    }
    BUDGETS {
        long id PK "autoincrement"
        long categoryId FK "unique (categoryId, monthStart)"
        long amountMillis
        string currency
        long monthStart
    }
    TRANSACTION_ITEMS {
        long id PK "autoincrement"
        long transactionId FK "CASCADE"
        string name
        long amountMillis "signed minor units"
        int position
    }
    ATTACHMENTS {
        long id PK "autoincrement"
        long transactionId FK "CASCADE"
        string fileName
        string mimeType
        long sizeBytes
        string type "AttachmentType TXT|IMAGE|PDF|CSV|XLSX|DOCX"
        string storedPath "relative path under filesDir/attachments"
        long createdAt
    }

Tables

Table Purpose Notes
accounts Bank account / card records Parsed from account group; masked number. Not heavily used in v1 UI.
categories User-defined spending categories Seeded from DefaultCategories (10 rows); icon is a Material icon id string.
transactions The core value: parsed transactions Unique notificationKey; FKs accountId/categoryId/ruleId all SET NULL; isHidden filters non-COMPLETED; parseSource records JSON-vs-regex origin, rawJson stores the embedded payload span (JSON only). Nullable captured-location columns + resolved-place placeholders (ADR-0021).
rules Per-bank parse rules Seeded from BankRuleSets (62 rules / 12 packages). Indexed on packageName, priority, and sourceId (unique). sourceId is the stable seed id (e.g. mae_payment_of) used to re-seed new starter rules onto existing installs once per seed version (ADR-0019).
corrections User category overrides Unique (packageName, merchantNormalized); CASCADE on category delete.
settings String key/value store Currency, tracked packages, category keywords, lock hash, theme, notify_on_parse, reports tap-hint seen.
unmatched Review-queue notifications isMasked added in v3.
budgets Per-category, per-month targets Unique (categoryId, monthStart); CASCADE on category delete.
transaction_items Manual itemization lines (ADR-0024) Child of transactions; FK transactionId CASCADE; amountMillis signed (negative = discount); position orders lines.
attachments Transaction attachment metadata (ADR-0027) Child of transactions; FK transactionId CASCADE; bytes live in filesDir/attachments keyed by storedPath; type is AttachmentType via Converters.

Enums are stored as strings via Converters (Direction, Status, AccountType, CategoryKind, ParseSource, AttachmentType) with stable dbValue names.

DAOs

DAO Key queries
TransactionDao observeVisible, observeMonth(start, end), observeMonthIncludingHidden, observeExpenses (COMPLETED + EXPENSE only), observeSince(start) (6-month trend), insert (IGNORE on conflict — returns -1 on duplicate), getById, deleteAll, findWalletMatch / findNonWalletMatch (cross-source dedupe, ADR-0020)
CategoryDao observeAll, getByName(kind, name) (used by classifier), count, insertAll (seed), update, delete
AccountDao observeActive, observeAll, upsert (REPLACE), setActive
RuleDao enabledForPackage (enabled only, priority-ordered), all, insert (IGNORE), update, upsert (REPLACE), delete, count — used by seedDefaults reconcile (ADR-0019)
CorrectionDao categoryForMerchant(packageName, merchantNormalized) (lookup), upsert (REPLACE), delete, observeAll
SettingsDao get(key), observe(key), put (REPLACE), remove
UnmatchedDao observePending, observePendingCount, dismiss(id), delete, deleteAll
BudgetDao observeForMonth(monthStart), forCategory, upsert (REPLACE), delete(id)
TransactionItemDao observeForTransaction(txId), getByTransaction(txId), insertAll, deleteByTransaction(txId), replaceItems(txId, items) (@Transaction delete+insert — atomic save)
AttachmentDao observeForTransaction(txId), getByTransaction(txId), insert, insertAll, deleteById(id), deleteByTransaction(txId), replaceAttachments(txId, attachments) (@Transaction delete+insert)

Schema versions & migrations

Version Change Migration
1 Baseline: accounts, categories, transactions, rules, corrections, settings, unmatched
2 Add budgets table MIGRATION_1_2
3 Add unmatched.isMasked (INTEGER NOT NULL DEFAULT 0) MIGRATION_2_3
4 Add transactions.parseSource (TEXT NOT NULL DEFAULT 'REGEX') and transactions.rawJson (TEXT, nullable) MIGRATION_3_4
5 Add rules.sourceId (TEXT, nullable) + unique index on it (stable seed id for re-seeding) MIGRATION_4_5
6 Add captured-location columns (latitude, longitude, accuracyMeters, locationProvider, locationCapturedAt) + resolved-place placeholders (placeName, placeId) — all nullable — to transactions and unmatched (ADR-0021) MIGRATION_5_6
7 Add transaction_items table (FK transactionIdtransactions CASCADE, index on transactionId) for manual itemization (ADR-0024) MIGRATION_6_7
8 Add attachments table (FK transactionIdtransactions CASCADE, index on transactionId) for transaction attachments (ADR-0027) MIGRATION_7_8

Room schema JSON is exported to app/schemas/ (see ksp { arg("room.schemaLocation", …) } in app/build.gradle.kts) — the basis for schema validation and future migration tests.

Encryption

flowchart LR
    A[DatabasePassphrase.passphrase] -->|AES/GCM decrypt| B[Keystore key: autobudget_sqlcipher_key]
    B -->|256-bit AES key, never exported| K[Android Keystore]
    A --> C[SupportOpenHelperFactory]
    C --> D[Room.databaseBuilder.openHelperFactory]
    D --> E[autobudget.db encrypted by SQLCipher]
  • The SQLCipher passphrase is a random 32-byte value generated on first launch, encrypted with an Android Keystore AES-GCM key (autobudget_sqlcipher_key), and stored base64 in the autobudget_crypto SharedPreferences (encrypted_db_passphrase).
  • The Keystore key never leaves the device. Wiping app data destroys the key and the ciphertext — history is unrecoverable.
  • android:allowBackup="false" plus data_extraction_rules.xml / backup_rules.xml prevent the encrypted DB from leaking into cloud backups or device-to-device transfers.

Repository layer

Repositories are the single funnel for DAO access:

Repository Backs
TransactionRepository insert (dedupe-aware), month/visible/expenses flows, resolveCrossSourceDuplicate (bank preferred over Wallet mirror, ADR-0020), applyCategoryOverride (upserts correction + updates tx with isReviewed=true), item flows (observeItems/getItems/replaceItems)
CategoryRepository ensureSeeded, update/delete
AccountRepository active/all accounts, upsert, setActive
RuleRepository seedDefaults (seeds on first launch, then reconciles new starter rules once per seed version), enabledForPackage, editor CRUD; maps ParserRule ↔ RuleEntity via sourceId
Category overrides — no standalone repository; upsert/lookup live on TransactionRepository.applyCategoryOverride (ADR-0007)
SettingsRepository key/value get/put/remove + normalization; implements SettingsStore seam; tracked-packages CSV normalization (legacy package renames)
UnmatchedRepository pending list/count, dismiss, delete
BudgetRepository month budgets, upsert, delete
AttachmentRepository stages file bytes into filesDir/attachments, owns file lifecycle (delete row + file), replaceAttachments on save, stagedForTransaction/observeForTransaction, validation (MIME whitelist + 10 MB cap via StageResult)
ExportRepository CSV/JSON content generation for the system save-dialog (SAF), JSON import (parses amounts through AmountParser, re-creates itemization), deleteAllData
MapPackRepository offline PMTiles basemap packs for the location picker: validates + stages imported .pmtiles files, exposes pack status, removes packs (ADR-0030)

SettingsStore is a tiny interface (get/put/remove) so AppLockManager can be unit-tested against an in-memory fake.

Wiring

Hilt: DatabaseModule provides the SupportOpenHelperFactory (passphrase, loads libsqlcipher.so), the AppDatabase (with addMigrations(MIGRATION_1_2, …, MIGRATION_7_8)), and all ten DAOs. AppModule binds SettingsStore → SettingsRepository, CategoryClassifier → KeywordClassifier, and LocationProvider → AndroidLocationProvider, and provides TransactionNotifier.