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 10), owned by AppDatabase.

ER diagram

Entities and relationship cardinality only — column types, keys and nullability live in Tables and Schema versions and migrations.

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)"

    SETTINGS
    UNMATCHED

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 (nullable, JSON only). Nullable captured-location columns + resolved-place placeholders (ADR-0021). Indexed on sourcePackage and occurredAt, plus the composite (sourcePackage, occurredAt) used by cross-source dedupe.
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) plus an index on categoryId (the FK-child scan on category delete); 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; nullable captured-location columns as on transactions (ADR-0021); nullable unique notificationKey added in v9 gives replay-idempotent identity (ADR-0073) — legacy rows keep NULL and coexist.
budgets Per-category, per-month targets; effective limit carries into later months (ADR-0036) Unique (categoryId, monthStart); CASCADE on category delete; sentinel row amountMillis = 0 means "cleared from this month onward".
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.

AppDatabase.VERSION is the single schema-version constant: the @Database annotation, the migration-chain fixture and the tests all read it (issue #354). androidx.room.Database is CLASS-retained for KSP, so tests never read it by runtime reflection.

Every table except settings has an autoincrement long id primary key (settings is keyed by its key string); foreign keys and their delete behavior are in the Notes column, and every nullable or migration-sensitive column is called out here or in Schema versions and migrations.

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 and insertOrMergeCrossSource (one-transaction cross-source dedupe/merge, ADR-0020/ADR-0074)
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, insert (IGNORE on conflict — returns -1 on duplicate notificationKey, ADR-0073), dismiss(id), dismissAll() (bulk soft dismiss, ADR-0041), delete, deleteAll
BudgetDao observeEffectiveForMonth(monthStart) (per category: row with MAX(monthStart) <= monthStart — carry-forward, ADR-0036), effectiveForCategory, upsert (REPLACE)
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)
ExportDao snapshot() — @Transaction read of visible transactions, their items, categories and accounts in one consistent snapshot (children via one join against transactions, not one query per row and not an IN (…) id list, so ledger size has no bound-variable ceiling — issues #249, #292); visibleTransactions, visibleItems, allCategories, allAccounts

Schema versions and 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 transactionId → transactions CASCADE, index on transactionId) for manual itemization (ADR-0024) MIGRATION_6_7
8 Add attachments table (FK transactionId → transactions CASCADE, index on transactionId) for transaction attachments (ADR-0027) MIGRATION_7_8
9 Add unmatched.notificationKey (TEXT, nullable) + unique index on it — durable review-queue identity, legacy rows keep NULL (ADR-0073) MIGRATION_8_9
10 Add corrections.categoryId and the transactions(sourcePackage, occurredAt) composite index — FK-child scan and cross-source dedupe (issue #356) MIGRATION_9_10

Room schema JSON is exported to app/schemas/ (see ksp { arg("room.schemaLocation", …) } in app/build.gradle.kts) — the basis for schema validation, and for the 1→10 upgrade-chain tests (MigrationChainTest, MigrationCoverageTest), which drive the production MIGRATION_* objects with populated fixtures.

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, recordCapture (atomic cross-source reconcile: bank preferred over a Wallet mirror, merged in place, ADR-0020/ADR-0074), 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; user-deleted starters are recorded as sourceId tombstones so a bump cannot revive them — issue #316), enabledForPackage (DB rows every time, compiled regex cached by content signature — issue #325), editor CRUD; maps ParserRule ↔ RuleEntity via sourceId
Category overrides — no standalone repository; lookup is CorrectionDao.categoryForMerchant, and both read and write live inside the CategoryClassifier seam (rememberOverride) (ADR-0007)
SettingsRepository key/value get/put/remove, multi-key snapshot reads (getMany), + normalization; implements SettingsStore seam; tracked-packages CSV normalization (legacy package renames)
UnmatchedRepository pending list/count, dismiss, dismissAll, delete
BudgetRepository effective month budgets (carry-forward read), upsert, clear (sentinel 0 row — ADR-0036)
AttachmentRepository stages file bytes into filesDir/attachments, owns those bytes (deleteFiles/sweepUnreferencedFiles — the row delete cascades the metadata), resolves saved files for viewing through canonical containment (resolvableFileForStored, ADR-0086), replaceAttachments on save, stagedForTransaction/observeForTransaction, validation (MIME whitelist + magic-byte check + 10 MB cap via StageResult), re-encodes JPEG/PNG with a bounded sample to strip EXIF/GPS (other image formats are rejected), sweeps the app cache directory (receipt captures) on start (issue #308)
ExportRepository CSV/JSON content generation for the system save-dialog (SAF), JSON import (validates every financial field up-front, parses amounts through the strict whole-token StrictAmount codec — exact minor units, a fraction the currency cannot represent is rejected, never truncated — re-creates itemization, round-trips the captured-location columns, and hides every non-COMPLETED imported row like capture does — issue #315), deleteAllData. The export read is one ExportDao.snapshot() transaction, so parents, children and metadata come from one consistent snapshot (issue #249); the import runs inside one injected TransactionRunner transaction, so a rejected file or a mid-import failure changes nothing (ADR-0076)
MapPackRepository offline PMTiles basemap packs for the location picker: validates, size-caps (2 GB) and streams imported .pmtiles files into place with an atomic rename-over-target, exposes pack status, removes packs (ADR-0030)

The detail editor's atomic save is owned by domain/TransactionSaveUseCase, not by Room: the row, its itemization, its attachment metadata and any review-queue dismissal commit together inside one injected TransactionRunner transaction, while TransactionDetailViewModel keeps the draft, validation, working serialization, navigation and staged-file cleanup (#222). An edit whose row no longer exists fails with MissingTransactionException (mapped to SaveTransactionResult.Failed) before any dependent write; TransactionDao.update returns the affected-row count so the delete-between-read-and-write race fails the same way, and no category override is written for a missing transaction (#290). Deletion is the mirror image: domain/TransactionDeletionUseCase is the single owner (ADR-0075) called by the Activity list, the detail editor and ExportRepository.deleteAllData; the row delete commits inside one TransactionRunner transaction and the now-unreferenced bytes are unlinked after, with a leftover reclaimed by sweepOrphans, which spares every path a live draft owns — each draft holds a durable per-draft reservation (StagedDraftReservations, 30-day TTL) that survives process death, so the protection is not just an in-memory registry — and falls back to a 10-minute grace window for files no draft claims. An unreadable or invalid reservation store is fail-closed: the sweep deletes nothing and reports FileCleanup.skipped. Room is opened with explicit WAL (WRITE_AHEAD_LOGGING), so the repository's DB reads inside the staged-file mutex never wait behind an open write transaction (issue #318). A draft descriptor restored after delete-all is checked against the ledger: a vanished row lands in the terminal not-found state instead of a phantom editor (issue #319).

SettingsStore is a tiny interface (get/getMany/put/remove) so AppLockManager (now in domain/lock/, issue #326) can be unit-tested against an in-memory fake; getMany is one SQLCipher query so the lock ticker no longer reads eight keys sequentially (issue #324).

Wiring

Hilt: DatabaseModule provides the SupportOpenHelperFactory (passphrase, loads libsqlcipher.so), the AppDatabase (with addMigrations(*AppDatabase.PRODUCTION_MIGRATIONS.toTypedArray()) — one list shared with MigrationCoverageTest, issue #354), and all 11 DAOs. AppModule binds SettingsStore → SettingsRepository, CategoryClassifier → KeywordClassifier, LocationProvider → AndroidLocationProvider, TransactionRunner → RoomTransactionRunner, AttachmentSource → AndroidAttachmentSource, and NotificationTapIntents → AndroidNotificationTapIntents, and provides TransactionNotifier.