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 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 |
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 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, 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.