# 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 1. 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). 2. 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. 3. 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). 4. SPEC §8.2 Device tokens (phone storage), §8.5 Transport, §8.7 Privacy and logging. 5. 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. 6. SPEC §9.1 to §9.4, §9.6, §9.9 Online-only actions, §9.10. 7. SPEC §6.2 (Local DB, DI, HTTP, Background, Images, Secrets, Tests rows; module table). 8. SPEC §11.2 (row M5), §11.3, §14.7, §14.8. 9. 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.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`; migrations take an `SQLiteConnection`) (D-012). M2b already created every entity of §14.2.1 and §14.2.2 and `KvittDatabase` (package `se.hagstedt.kvitt.data.db`, file `kvitt.db`, version 1, `exportSchema = true`); the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is 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; timestamps `String`, dates `String`, money `Long`, bp `Int`, booleans `Boolean`; `local_updated_at` on 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 UUIDv7 `pending_mutation_id`, `updated_at` = now in the fixed form (and `created_at` on create), local delete sets `deleted_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 while `has_more`; then `last_success_at` (§14.4.2). - Push procedure steps 1 to 4 of §14.4.5, including the re-edit-during-request rule, equal-content `rejected` treated as accepted, `clock_skew` handling, 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`, `KEEP` periodic, `APPEND_OR_REPLACE` one-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: Bearer` on 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 DataStore `auth.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.Offline` without 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 `:data` with `:domain` and include pending changes; features never call `:domain` (§6.2, AC-10.4, AC-14.1). - Readings fixed in v0.4 (§14.2.4): `FieldError.error` is a §14.5.3 token, `required`, or `validation_failed` for a §9.6 rule without a domain token; `analytics()` resolves a missing `CUSTOM` date as today before calling `resolveRange` (`AnalyticsState` has no error field; `IncompleteCustomRangeException`, D-011, is never surfaced); `DrillDownState.title` is the unlocalised parts joined by ", " (category custom name or lowercase default key, account name, bucket key); a `SettlementRow` in an account's ledger carries the allocation to that account, elsewhere the settlement total, with `accountCount` of 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/latest` on cold start and at most every 6 hours while resumed; download to cache, verify size and SHA-256, install through `PackageInstaller`; 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 `MockEngine` and 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 existing `KvittDatabase` (entities as M2b wrote them). - `android/data/schemas/**`: unchanged; the build must re-export an identical `1.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/**`: `ConnectivityRepository` per §9.9. - `.../data/di/**`: Hilt modules that `:app` (M8) can install. - `android/data/src/test/**`: DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with `MockEngine` for 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 1. 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`. Copy `android/local.properties` from the main checkout (add `sdk.dir=/opt/homebrew/share/android-commandlinetools` if missing; never overwrite or commit). Run `tools/install-git-hooks.sh`. 2. Read §2 in order. Confirm M2b has landed (`android/core/model/` on `main`); if not, stop and report. 3. 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. 4. Check M2b's entities and `1.json` against §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; confirm `1.json` is unchanged after the build. 5. DTOs and the round-trip test over every `contracts/examples/**` file (decode, re-encode, compare JSON values) before writing the client. 6. Ktor client and error mapping; token store. 7. Sync engine test-first with `MockEngine`: each pull step, each push result case, epoch recovery, `Unrecognised` and revoked paths, clock skew, no-progress stop, the AC-15.6 scenario. 8. Repositories, one by one, with tests; workers and triggers; APK updater (verification logic unit-tested; `PackageInstaller` call isolated behind an interface). 9. 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`). - `:app` wiring, navigation, Hilt component installation, FCM service and token registration, notification building, deep links (M8). - Changing `:core:model` types or interfaces (contract change via the orchestrator). - Server code; `infra/`. ## 10. Report - Branch `agent-m5`, head commit hash after rebasing on `main`. - 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.