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 theautobudget_cryptoSharedPreferences (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"plusdata_extraction_rules.xml/backup_rules.xmlprevent 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.