diff --git a/SPEC.md b/SPEC.md index 693d910..67a44af 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.12** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**. +Version **0.13** (2026-10-18). Schema version **3**. Sync protocol version **1**. Push payload version **1**. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. @@ -118,19 +118,19 @@ The server starts with an empty database. The household is created from the comm * AC-2.1 The admin creates a `partner` invite from Settings. It is refused with `household_full` when two users exist. Creating a new `partner` invite cancels any pending one. * AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone). * AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which. -* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050). -* AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=`, the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data. While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". -* AC-2.6 The app opens from `kvitt://invite?server=…&code=…` and from `https://icehack.asuscomm.com:8443/i/` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). +* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050). +* AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=` (followed by `&from=` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". +* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://icehack.asuscomm.com:8443/i/[?from=…]` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). * AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending. * AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). * AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). As built (M8), the request is made after the first successful sync after pairing. -* AC-2.10 A `partner` pairing continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. +* AC-2.10 A `partner` pairing (E-3 `invite_kind` = `partner`, D-086) continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. Every other pairing goes to Home, including a member pairing a second phone with a `device` invite. * AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each. * AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield `rate_limited` for the rest of that window. A failed accept is any 4xx answer of E-3 other than 429. After the tenth failure, further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; the window is fixed from the first counted request, not sliding (D-031). ### FR-3 Devices and revocation -* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version, and "This device" for the current one. +* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one. * AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. * AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button. * AC-3.4 A revoked device never receives pushes again. @@ -282,7 +282,7 @@ The server starts with an empty database. The household is created from the comm * AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt. * AC-16.4 A verification failure deletes the file and shows "Download failed, try again". * AC-16.5 Debug builds never check for updates. -* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.check()`; when `UpdateState` is still `None` afterwards it reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"). A network failure cannot be told apart because `check()` returns nothing; accepted for v1, with a contract request in §11.3 (D-073). +* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.check()`, which returns an `UpdateCheck` (D-092, revising D-073): `UpToDate` (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"); `Failed` (no answer or another error) reports "Could not reach the server" (`common_could_not_reach_server`); `Available` shows the update banner. Debug builds report `UpToDate` without a request (AC-16.5). ### FR-17 Splitwise import @@ -412,6 +412,7 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). | Build | Gradle with version catalog `android/gradle/libs.versions.toml`, Gradle daemon on JDK 25 (pinned), bytecode target Java 17. With AGP 9, `check` runs only the debug unit-test variant. `versionCode` and `versionName` come from the Gradle properties `kvitt.versionCode` and `kvitt.versionName`, defaulting to `1` and `dev` (§10.6) | Existing pin | | Tests | JUnit, Compose screenshot tests with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with `@Config(sdk = [36])` (D-010), Room tests (DAOs and migrations) on Robolectric with the framework SQLite driver, migration tests built from the exported schema JSON rather than `room3-testing` (D-042), Ktor `MockEngine`. Unit tests run on JDK 25 with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` for Robolectric, set in `build-logic` for every Android module (D-013). `failOnNoDiscoveredTests` is off only for modules without `src/test` (the shells) and on everywhere else (D-014) | No mocking libraries (`CLAUDE.md`) | | Typeface | The Material 3 default typeface; the app bundles no font file (D-020) | No licence step, no font resource | +| App icon | Adaptive launcher icon, vectors only (D-095): `android/app/src/main/res/mipmap-anydpi/ic_launcher.xml` with background colour `#9C5238` (the light theme's primary, `values/ic_launcher_colors.xml`) and foreground and monochrome layers (`drawable/ic_launcher_foreground.xml`, `drawable/ic_launcher_monochrome.xml`) drawing the `receipt_long` app mark at 48 dp, centred in the 108 dp canvas; the manifest's `android:icon` and `android:roundIcon` point at it, which clears lint `MissingApplicationIcon` | No raster assets; themed icons use the monochrome layer | Module graph (directory `android/`): @@ -422,7 +423,7 @@ Module graph (directory `android/`): | `:core:design` | Android library | Built by M6 (`3080804`) from `docs/ux/design-spec.md` §2 and §3: `KvittTheme`, tokens (`KvittColors`, `KvittShapes`, `KvittMotion`), typography, shared components (money text, avatar, category tile and pickers, balance header, sync pill, banners, charts drawn with `Canvas`, D-063; the ledger rows for expenses and settlements used by S-5, S-12 and S-16; the expense editor fields used by S-7 and S-19, D-019), `MoneyFormatter`, `DateFormatter`, shared strings | `:core:model` | | `:core:fakes` | Android library, used only by tests, screenshot tests and debug previews | Scenario-driven fakes of every repository interface (they call `:domain` for computed values so fakes never disagree with the real rules) | `:core:model`, `:domain` | | `:data` | Android library | Room DB, DAOs, entities, Ktor API client and DTOs, sync engine, WorkManager workers, token store, repository implementations, APK updater | `:core:model`, `:domain` | -| `:adapter:fcm` | Android library | `FirebaseMessagingService`, registration by Firebase Installation ID (D-083), maps messages to `PushEvent` (`:core:model`; as built by M8 `PushEvent` still lives in `:adapter:fcm`, the move is on the M12e list of §11.3) | `:core:model` | +| `:adapter:fcm` | Android library | `FirebaseMessagingService`, registration by Firebase Installation ID (D-083), maps messages to `PushEvent` (`:core:model`, `model/Push.kt`, moved there by M12e, D-095) through `PushMapping`; `PushListener` stays in the adapter | `:core:model` | | `:feature:onboarding` | Android library | Pairing, partner profile step, waiting for partner, device removed | `:core:model`, `:core:design` | | `:feature:home` | Android library | Home (global balance, accounts), sync status, update banner | `:core:model`, `:core:design` | | `:feature:account` | Android library | Account detail, account editor | `:core:model`, `:core:design` | @@ -552,7 +553,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * The server stores only `token_hash` = lowercase hex SHA-256 of the full token string. * Tokens do not expire. They stop working only on revocation. * Every authenticated request sends `Authorization: Bearer `. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`. -* The server updates `devices.last_seen_at` and `devices.app_version_code` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer; otherwise the stored value is kept. +* The server updates `devices.last_seen_at`, `devices.app_version_code` and `devices.app_version_name` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer, and `app_version_name` only when the request carries `X-Kvitt-App-Version-Name` with 1 to 32 characters after trimming (D-090); otherwise the stored value is kept. E-3 stores both from the accepting request. * On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text, together with the pairing status (D-043). * The token is never logged, never put in a URL, never included in a crash report or screenshot. @@ -858,7 +859,7 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed. -**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env file stays `infra/env.example`, written by M2c-pi. The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. +**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env file stays `infra/env.example`, written by M2c-pi. The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `90505a8`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. ### 10.1 Hosts and network @@ -1036,7 +1037,7 @@ kvitt-import --db /data/kvitt.sqlite --file --account "/` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). All six landed on 2026-10-18 (`b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (`3a1bd14`, D-075 to D-079). M7g (landed `34a2590`) did the feature-side cleanups after M6b. * **M6b** (landed `3a1bd14`) is the `:core:design` fix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row. -* **Contract requests for a later contract pass (M12e)**, collected from the M5, M6, M7 and M8 reports; none is a contract change in v0.12, each needs the §14.8 rule when taken (M12e started 2026-10-18): - * the `pair()` result lacks the invite kind, so S-2 shows for any first `member` pairing, device invites included (AC-2.4, AC-2.10); - * the `kvitt://invite` link carries no admin name, so `onboarding_code_help` ("You get the code from %1$s …") cannot be filled and S-1 shows the generic help line (AC-2.6); - * `SyncSummary` has no "last attempt failed" flag; `SyncStatusPill` is called with `failed = false` (§13.3 S-4); - * `Rejection` has no actor, so the stale-change copy on S-16 names the partner; - * `Device` has only `appVersionCode`, so S-20 rows show "Kvitt 11" where the design shows the version name "1.1"; - * `RecurringRepository.create` needs a `TemplateId` for a new template; the S-19 editor sends `TemplateId("")`; - * `UpdateRepository.check()` has no result, so S-22 cannot tell "up to date" from a network failure (AC-16.6, D-073); - * `SettleState` lacks the list of accounts the settlement leaves untouched; S-9 derives it from Home balances, which include the settlement being edited (D-074); - * the D-027 re-stamp (re-stamp `updated_at` of pending rows after a `clock_skew` rejection) is still not in `:data`; - * the 128 px picture size: `:data` always fetches E-11 at 512; a 128 variant needs a size parameter in `:core:model` and `:data` (§14.2.4 "Pictures"); the owner answers whether it is needed; - * `PushEvent` lives in `:adapter:fcm` as built by M8; §6.2 places it in `:core:model`, where it should move (M8); - * S-22's "Licenser för öppen källkod" does nothing: no licences screen and no strings for one exist (S-22 and the §6.2 strings rule); open point OQ-5 for the owner, default "not in v1" (M8); - * the detail screens (`AccountDetailScreen`, `ExpenseDetailScreen`, `SettlementDetailScreen` and others) take no `snackbarHostState`, so `:app` draws an overlay snackbar host that can overlap a FAB or the bottom bar (M8); - * S-7 and S-8 hand `:app` resolved snackbar `String`s instead of resource ids (M8); - * `:app` has no launcher icon (lint `MissingApplicationIcon`, present before M8 too) (M8); +* **M12e** (landed `1470a2f` and `90505a8` on 2026-10-18, D-086 to D-095) took the contract requests that M5, M6, M7 and M8 had collected here, as one contract change on both sides (§14.8): the invite kind from `pair()` (D-086), the admin's name in invite links (D-087), `SyncSummary.lastAttemptFailed` (D-088), `Rejection.actor` (D-089), the device version name with server schema 3 (D-090), `NewRecurringTemplate` without an id (D-091), `UpdateCheck` (D-092), `SettleState.untouched` (D-093), the D-027 re-stamp in `:data` (D-094), and `PushEvent` in `:core:model`, the six screen-hosted snackbar hosts, snackbar resource ids from S-7 and S-8 and the launcher icon (D-095). The 128 px picture size was left unchanged by assumption: `:data` keeps fetching E-11 at 512 and no size parameter was added, since v1 does not need one (the owner's yes/no stays open, HANDOFF §3). Room stays at schema 1 (the phone stores no devices). A phone running the M12e APK needs a server at schema 3 (§14.8 "Compatibility"). +* **Remaining contract requests** (none is a contract change in v0.13; each needs the §14.8 rule when taken): + * S-22's "Licenser för öppen källkod" does nothing: no licences screen and no strings for one exist (S-22 and the §6.2 strings rule); open point OQ-5 for the owner, default "not in v1"; * the server sends the Firebase Installation ID in the FCM HTTP v1 `message.token` field, accepted during Firebase's transition; it should send it in the `fid` field (§14.6, D-083). * **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10. * **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. -* **M8** (landed `369e516` and `00a14a3`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). Not yet done at landing (phone not attached): the end-to-end pairing, offline and push test on the owner's phone and the design-spec §2.7 typeface check (HANDOFF §3). Its contract requests are on the M12e list above. +* **M8** (landed `369e516` and `00a14a3`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). Not yet done at landing (phone not attached): the end-to-end pairing, offline and push test on the owner's phone and the design-spec §2.7 typeface check (HANDOFF §3). Its contract requests were taken by M12e (D-086 to D-095), except the `fid` field (remaining contract requests above). * **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024). * **M2c-pi**: the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint (D-023). * **M9** (landed `ab298ab`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`. * **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `6b60ef7`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `2e2ebe7`) and M12c (landed `e499107`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6. * **M12d** (D-059, landed `bdba9d3`) added the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (`docs/ux/design-spec.md` §3.7 and §6) are no longer needed. -* **M6** (landed `3080804`) built `:core:design` with charts on Compose `Canvas` (D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for **M8** (no change now): `:app` provides `LocalAvatarPictures` with an `AvatarPictures` that builds the authenticated E-11 Coil request from the user id and `pictureVersion` (§14.3.4); `:app` wraps every screen in `ProvideHousehold(me, partner)`; `:app` provides `LocalKvittClock` (tests and previews pass a fixed clock); `SyncSummary` gains a "last attempt failed" field so `SyncStatusPill` can drop its `failed` parameter (§13.3 S-4). The design-spec §2.7 open point (that `FontFamily.Default` keeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; still pending after M8 landed (phone not attached). +* **M6** (landed `3080804`) built `:core:design` with charts on Compose `Canvas` (D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for **M8** (no change now): `:app` provides `LocalAvatarPictures` with an `AvatarPictures` that builds the authenticated E-11 Coil request from the user id and `pictureVersion` (§14.3.4); `:app` wraps every screen in `ProvideHousehold(me, partner)`; `:app` provides `LocalKvittClock` (tests and previews pass a fixed clock); `SyncSummary` gains a "last attempt failed" field so `SyncStatusPill` can drop its `failed` parameter (§13.3 S-4); done by M12e: `SyncSummary.lastAttemptFailed`, and `SyncStatusPill`, `syncPillVariant` and `syncLineText` no longer take `failed` (D-088). The design-spec §2.7 open point (that `FontFamily.Default` keeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; still pending after M8 landed (phone not attached). * **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. --- @@ -1225,7 +1217,7 @@ Only things that cannot be decided without the owner. Everything else in this sp | OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and `Total balance` / `Totalsumma` row, payment category `Payment` / `Betalning`, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. | M9, M11 | answered | | OQ-3 | `TODO(spec)` LAN address and SSH user of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there (expected `/ssl` on Home Assistant OS); which SSH add-on is used. | M2c-pi | none; M2c-pi blocks on it | | OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered | -| OQ-5 | Open-source licences on S-22: is a licences screen wanted in v1? "Licenser för öppen källkod" currently does nothing, and no screen or strings exist for it (§11.3 M12e list). | M12e or M10 | not in v1 | +| OQ-5 | Open-source licences on S-22: is a licences screen wanted in v1? "Licenser för öppen källkod" currently does nothing, and no screen or strings exist for it (§11.3 remaining contract requests). | M10 | not in v1 | --- @@ -1252,8 +1244,8 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs * Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back. * The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room). * Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`. -* Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to (Home shows them through its `snackbarHostState`); the account-archived snackbar has an undo action (`common_undo`, "Ångra"). -* Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a first `member` pairing (AC-2.10; the invite-kind request of §11.3), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (`partner == null`). +* Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to; the account-archived snackbar has an undo action (`common_undo`, "Ångra"). As built by M12e (D-095): six screens host `:app`'s shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar; `:app` has no overlay host. Features hand `:app` string resource ids, never resolved strings: `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, and `onDeleted()` on S-7 and S-8 takes no argument, `:app` then shows `snackbar_expense_deleted`. +* Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a `partner` pairing only (AC-2.10, D-086; a member pairing a second phone with a `device` invite goes to Home), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (`partner == null`). ### 13.3 Screens and what each must show @@ -1261,28 +1253,28 @@ Each screen must be designed in these states where they apply: **loaded**, **emp | Id | Screen | Must show | Actions | |---|---|---|---| -| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (always the generic help line in v1: the invite link carries no admin name for `onboarding_code_help`, §11.3); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | -| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown after any first `member` pairing until `pair()` reports the invite kind (§11.3) | Continue | +| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code | +| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue | | S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings | -| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 (`SyncStatusPill` takes a `failed` flag for "Sync failed" until `SyncSummary` gets a "last attempt failed" field; Home passes `failed = false`, a contract request for M12e, §11.3); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | +| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | | S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) | | S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel | -| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) | -| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: