6dce4c73a9
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>
89 lines
11 KiB
Markdown
89 lines
11 KiB
Markdown
# 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.
|