Records the decisions proposed in the M2a (fdf2093) and M2b (fad6fc9) reports: D-005 OpenAPI equality normalisations and CommonRules, D-006 bp_sum_not_10000 token, D-007 per-crate no-unwrap deny, D-010 Roborazzi, D-011 IncompleteCustomRangeException, D-012 Room 3 with M2b's entities and 1.json as a contract file, D-013 JDK 25 Robolectric flags, D-014 failOnNoDiscoveredTests (D-008, D-009 unused). Clarifications in §6, §8.2, §8.6, §9.6, §10.3, §10.4, §11, §14.2, §14.3, §14.5, §14.7, §14.8 and AC-11.7. PLAN-M5 builds on the existing entities; v0.4 readings added to PLAN-M3, M5, M7b to M7f. HANDOFF rewritten: next free D-040, spec v0.5, server schema 2, Room schema 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
PLAN-M5: Android data layer
1. Goal
Implement :data on top of the Room 3 entities and KvittDatabase v1 that M2b created (D-012): DAOs, the Ktor API client and DTOs, the encrypted token store, the sync engine and its WorkManager workers, every real repository behind the :core:model interfaces, and the APK updater. The phone then works fully offline and syncs per §14.4, proven by tests with Ktor MockEngine and the shared JSON examples.
2. Read first
- SPEC §14.2 Room entities (14.2.1 Mirrored tables, 14.2.2 Local-only tables, 14.2.3 Migrations, 14.2.4 UI contract).
- SPEC §14.4 Sync protocol v1, phone side: 14.4.1, 14.4.2 (phone steps), 14.4.3 (request shape), 14.4.5 Phone push procedure, 14.4.6, 14.4.8 Epoch recovery and re-pairing, 14.4.9 Limits and timing.
- SPEC §14.3 OpenAPI outline (14.3.1 headers, 14.3.2 error codes, 14.3.3 wire objects, 14.3.4 endpoints the app calls, 14.3.5).
- SPEC §8.2 Device tokens (phone storage), §8.5 Transport, §8.7 Privacy and logging.
- SPEC §4 FR-2 AC-2.4, AC-2.7 to AC-2.9; FR-3 AC-3.3; FR-4 AC-4.2, AC-4.3, AC-4.6; FR-6 to FR-9 (online-only writes); FR-8 AC-8.6 (defaults), AC-8.10; FR-10 AC-10.4; FR-11 AC-11.3 to AC-11.6; FR-12 AC-12.4, AC-12.5; FR-14 AC-14.1; FR-15 Offline and sync; FR-16 In-app update.
- SPEC §9.1 to §9.4, §9.6, §9.9 Online-only actions, §9.10.
- SPEC §6.2 (Local DB, DI, HTTP, Background, Images, Secrets, Tests rows; module table).
- SPEC §11.2 (row M5), §11.3, §14.7, §14.8.
- Files:
android/data/**as M2b left it (build.gradle.kts,db/Entities.kt,db/KvittDatabase.kt,schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json),android/README.md,contracts/examples/**,android/core/model/**,android/domain/**,android/core/fakes/**,android/build-logic/**,android/gradle/libs.versions.toml,CLAUDE.md(BUILD AND VERIFY, ARCHITECTURE RULES, ANTI-PATTERNS, THE PHONE).
3. Already established
- Room 3 (
androidx.room33.0.3, KSP only,SQLiteDriverwithsqlite-bundled; migrations take anSQLiteConnection) (D-012). M2b already created every entity of §14.2.1 and §14.2.2 andKvittDatabase(packagese.hagstedt.kvitt.data.db, filekvitt.db, version 1,exportSchema = true); the exportedandroid/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.jsonis a contract file (§14.8). M5 adds DAOs to the existing database and does not change the entities or the exported schema; a change it needs is a contract request to the orchestrator (spec bump, decision, next Room version 2 with a migration and migration test). Established for those entities: no foreign keys; names identical to the server's; camelCase properties with@ColumnInfo(name = "snake_case"); server-only columns not mirrored; timestampsString, datesString, moneyLong, bpInt, booleansBoolean;local_updated_aton every write (§14.2). - Entity classes, tables, columns, keys and indices exactly as §14.2.1 and §14.2.2; per-phone preferences in DataStore
prefs.preferences_pb(§14.2.2). - Local write semantics:
pending = true, new UUIDv7pending_mutation_id,updated_at= now in the fixed form (andcreated_aton create), local delete setsdeleted_at=updated_at= now; pending count = pending expenses + pending settlements (§14.2.1). - Pull per page in one Room transaction: epoch check, apply arrays in the fixed order, skip rows with local
server_version≥ incoming and pending expenses/settlements, upsert and replace children, audit insert-or-ignore, cursor and epoch update; repeat whilehas_more; thenlast_success_at(§14.4.2). - Push procedure steps 1 to 4 of §14.4.5, including the re-edit-during-request rule, equal-content
rejectedtreated as accepted,clock_skewhandling, 401 handling (device_revoked→ AC-3.3 wipe;unauthorized→SessionState.Unrecognised) (§14.4.5, §14.4.8). - Sync triggers: app start, every foreground, 2 s after the last local write (debounced), every 15 minutes in the background (network constraint), every FCM data message (expedited); one cycle at a time (Mutex; unique work
kvitt-sync,KEEPperiodic,APPEND_OR_REPLACEone-off); backoff exponential from 30 s (AC-15.3, §14.4.1, §14.4.9). - Headers on every call:
X-Kvitt-Protocol: 1,X-Kvitt-App-Version,Accept-Language,Authorization: Beareron authenticated calls; 426 → stop syncing and surface update (§14.3.1). - Token: Android Keystore AES-256-GCM key alias
kvitt_token_key,setUserAuthenticationRequired(false), non-exportable; ciphertext and IV in DataStoreauth.preferences_pb; base URL, user id, device id in plain text beside it; never logged or put in a URL (§8.2). - Online-only actions return
OnlineResult.Offlinewithout a validated network or within 30 s of a network error; a REST network failure changes nothing locally (§9.9). - Balances, previews, proposals and analytics are computed in
:datawith:domainand include pending changes; features never call:domain(§6.2, AC-10.4, AC-14.1). - Readings fixed in v0.4 (§14.2.4):
FieldError.erroris a §14.5.3 token,required, orvalidation_failedfor a §9.6 rule without a domain token;analytics()resolves a missingCUSTOMdate as today before callingresolveRange(AnalyticsStatehas no error field;IncompleteCustomRangeException, D-011, is never surfaced);DrillDownState.titleis the unlocalised parts joined by ", " (category custom name or lowercase default key, account name, bucket key); aSettlementRowin an account's ledger carries the allocation to that account, elsewhere the settlement total, withaccountCountof the whole settlement (AC-11.7). - Editor defaults from DataStore: last used account if active else oldest active; last used category in that account if active else
other(AC-8.6). - APK updater: release builds call
GET /apk/lateston cold start and at most every 6 hours while resumed; download to cache, verify size and SHA-256, install throughPackageInstaller; failure deletes the file; debug builds never check (FR-16). - Pictures through Coil with an auth header, cached by user id and
picture_version(AC-4.6, §6.2). - Tests use Ktor
MockEngineand typed fakes; no mocking libraries (§6.2, CLAUDE.md).
4. Owned paths
From SPEC §14.7, verbatim: android/data/** (owner M5; after landing: "later data plans").
Also from §14.7 (row android/settings.gradle.kts, android/build.gradle.kts, android/gradle.properties, android/gradle/**, android/build-logic/**): "later plans may add catalog entries and module includes (rebase resolves)".
Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator.
5. Deliverables
android/data/build.gradle.kts(exists, with Room 3 and KSP): add dependencies:core:model,:domain, Ktor, kotlinx.serialization, WorkManager, DataStore, Hilt, Coil.android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/**: DAOs, registered on the existingKvittDatabase(entities as M2b wrote them).android/data/schemas/**: unchanged; the build must re-export an identical1.json(adding DAOs does not change it)..../data/api/**: Ktor client with the §14.3.1 headers, DTOs for every wire object and endpoint body, error mapping to §14.3.2 codes..../data/auth/**: Keystore token store, session state..../data/sync/**: sync engine (push, pull, epoch recovery, rejection records), debounce, WorkManager workers..../data/repo/**: real implementations of every repository interface of §14.2.4..../data/update/**: APK updater..../data/connectivity/**:ConnectivityRepositoryper §9.9..../data/di/**: Hilt modules that:app(M8) can install.android/data/src/test/**: DTO round-trip tests over every file incontracts/examples/; sync engine tests withMockEnginefor each §14.4 rule including AC-15.6 (14 days offline, 200 changes, scripted clock); repository tests (balances with pending rows, editor defaults, online-only Offline results).android/data/src/androidTest/**(if Room needs a device for the schema test; only on the phone the start prompt names).
6. Steps
- Worktree. From the main checkout
/Users/jhag/projects/kvitt:git worktree add .claude/worktrees/agent-m5 -b agent-m5 main, then in.claude/worktrees/agent-m5:git reset --hard main. Copyandroid/local.propertiesfrom the main checkout (addsdk.dir=/opt/homebrew/share/android-commandlinetoolsif missing; never overwrite or commit). Runtools/install-git-hooks.sh. - Read §2 in order. Confirm M2b has landed (
android/core/model/onmain); if not, stop and report. - Run
python3 tools/dependency-report/latest.py; record the lines. Confirm current Room, KSP, Ktor, WorkManager, DataStore and Android Keystore APIs from official documentation (developer.android.com, ktor.io); any new catalog entry at its latest stable version; record versions. - Check M2b's entities and
1.jsonagainst §14.2.1 and §14.2.2 column by column (report any mismatch as a contract request, do not fix it silently); write the DAOs; confirm1.jsonis unchanged after the build. - DTOs and the round-trip test over every
contracts/examples/**file (decode, re-encode, compare JSON values) before writing the client. - Ktor client and error mapping; token store.
- Sync engine test-first with
MockEngine: each pull step, each push result case, epoch recovery,Unrecognisedand revoked paths, clock skew, no-progress stop, the AC-15.6 scenario. - Repositories, one by one, with tests; workers and triggers; APK updater (verification logic unit-tested;
PackageInstallercall isolated behind an interface). - Run the gate (§7). Commit on
agent-m5,git rebase main, rerun the gate. Never merge, push or remove the worktree.
7. Gate
From SPEC §11.2, verbatim: "Android gate; DTO round-trip tests over every file in contracts/examples/; sync engine tests with Ktor MockEngine covering §14.4 including the 14-day offline case (AC-15.6)".
Command, from android/: PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check.
Green means: BUILD SUCCESSFUL in this session; fresh XML shows the round-trip test covering every example file (count equals the number of files under contracts/examples/) and the sync tests including AC-15.6, 0 failures.
8. HUMAN steps
From SPEC §11.2, verbatim: "none".
9. Out of scope
- Any UI, ViewModel or string (M7a to M7f; M6 for
:core:design). :appwiring, navigation, Hilt component installation, FCM service and token registration, notification building, deep links (M8).- Changing
:core:modeltypes or interfaces (contract change via the orchestrator). - Server code;
infra/.
10. Report
- Branch
agent-m5, head commit hash after rebasing onmain. - Gate output: BUILD line, actionable tasks line, test counts from fresh XML (round-trip count vs. example file count, sync tests, repository tests).
- Versions added or changed, with sources; dependency-report lines.
- Mapping of §14.4.2 and §14.4.5 rules to test names.
- Numbering used or
TODO(spec)occurrences. - Contract requests (any missing field in §14.2.4, any entity or schema change), open questions, spec gaps (with section numbers).
11. Numbering
Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use TODO(spec) if none was given.