SPEC v0.26: D-127 pull-to-refresh on Home and account detail
AC-15.3, §9.9 and §13.3 (S-4, S-5) name the pull as a sync now, not
disabled offline. design-spec: S-4's "Pull-to-refresh is not offered"
replaced; S-4/S-5 refreshing frames, interactions and TalkBack;
KvittPullToRefresh in §3.6; ContainedLoadingIndicator row in §3.1;
spatial fast and the named motion in §2.11. HANDOFF: D-127 landed
(3778f5f), Pi live, both phones paired, import done; next free D-129 /
v0.28 after the parallel D-128 (v0.27).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -131,3 +131,4 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-124 | 2026-10-19 | Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the `:8443` of D-120 to D-123). (1) Server and contracts (`a86ad24`): the OpenAPI server is `https://{host}` (`contracts/openapi.yaml`, `openapi.rs` `PUBLIC_SERVER_URL`); examples E-7 `public_url` and E-12 `link` use `https://kvitt.example.invalid` without a port; `tools/contracts-check.py` expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` in `infra/pi/compose.yaml`; the health checks through NPM use `https://<KVITT_PUBLIC_HOST>/health` on 443 (`infra/pi/lib.sh`); `infra/publish-apk.sh` prints the release server without a port; `infra/README.md` step 7 is "Router: no new rule" (verify the existing TCP 443 rule to `<HA_HOST>`); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (`038c603`): `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 `https` intent filter carries no `android:port`, which matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443` for `https`, `:80` for `http`); the S-1 fixture `GOLDEN_SERVER` is `https://kvitt.example.org` (28 S-1 goldens re-recorded, only the server row changed). | Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §14.2.4, §14.3, E-7, E-12 |
|
||||
| D-125 | 2026-10-20 | The Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project: `compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`; containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi); no install script, no `/opt`, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain `docker compose` commands. Details: `.env` holds exactly the seven keys compose reads (`KVITT_REGISTRY`, `KVITT_TAG`, `KVITT_PUBLIC_HOST`, `HA_HOST`, `KVITT_UID`, `KVITT_GID`, `RCLONE_FOLDER_ID`); `RCLONE_REMOTE` fixed to `gdrive:` in compose; private packages → one-time `docker login gitea.lan`; each container mounts only its own secret file read-only (`create_host_path: false`); read-only root, 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; secret files the owner's, mode 0600. Equivalents of the removed scripts (`install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`): backup `docker compose exec backup /backup.sh`; bootstrap `docker compose exec server kvitt-server bootstrap`; partner invite `docker compose exec server kvitt-server invite-partner`; deploy = backup, set `KVITT_TAG`, `pull`, `up -d`; rollback = the previous tag; APK = `scp` the APK, then `latest.json`, into `data/apk/` (old APKs removed by hand); digests via `docker image inspect`. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, `/opt/kvitt`, `.env` keys), D-105 (uid and `rclone.conf` owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed `6e5981d`. Rehearsed locally with Podman as uid 501: both containers healthy, `/health` 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. | Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/README.md` "Pi: what the owner does", `infra/RESTORE.md`, `PLAN-M11.md` and `PLAN-M2c-pi.md` notes |
|
||||
| D-126 | 2026-10-21 | App gate in NPM. Outside the home LAN, Nginx Proxy Manager closes the connection (`return 444`, nothing sent) of every request that does not carry the header `X-Kvitt-App: <key>` with the app key; requests from the home LAN `192.168.1.0/24` pass without it. Every route is covered (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*` included); only `/.well-known/acme-challenge/` passes, served by NPM from its own webroot, so Let's Encrypt can renew (Force SSL redirects the challenge to 443). The gate is five server-level rewrite lines at the top of the Custom Nginx Configuration (`set $kvitt_gate 0`; an `if` per accepted key; `if ($realip_remote_addr ~ "^192\.168\.1\.")`; `if ($uri ~ "^/\.well-known/acme-challenge/")`; `if ($kvitt_gate = 0) { return 444; }`), which run before location selection. The LAN check reads `$realip_remote_addr` (the TCP peer), not `$remote_addr`, because NPM's `nginx.conf` applies `real_ip_header X-Real-IP` to 10/8, 172.16/12 and 192.168/16 peers, so `$remote_addr` can be set by the client (by every client if NPM saw a Docker bridge address, security review I-5); the ACME exemption tests the normalised `$uri`, so `../` cannot reach Kvitt through it. The Rust server neither sees nor checks the header; `contracts/openapi.yaml` is unchanged (a proxy-level header, §14.3.1); the per-device bearer token stays the authentication (§8.2), and the gate is a perimeter. The app (`34e5cad`) sends `X-Kvitt-App` in `KvittApi`'s common headers (every API call, `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` when built with the property `kvitt.appKey` (`-Pkvitt.appKey`, else `kvitt.appKey` in `android/local.properties`, else empty = no header; set, it must be 16 to 128 printable ASCII characters without white space, or the build stops with a message giving the length, never the value), modelled on D-121; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` in `:data` (`toString` hides it); `infra/publish-apk.sh` unchanged (it builds on the Mac). The key: the owner's `openssl rand -hex 32`, only in `android/local.properties` and NPM's block; never in git, `.env`, docs or logs. Rotation: a second `if` line with the new key, the new APK through the update path (FR-16), then the old line removed. Both phones must run a keyed APK before the gate goes into NPM (`infra/README.md` step 4a). Accepted consequences: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; port 80 still answers NPM's 301, Block Common Exploits' patterns NPM's 403, and the TLS handshake shows the certificate; nothing protects against a bandwidth flood (not winnable at home). Caveat: the LAN check needs NPM to see real client addresses (a bridge address matches nothing, a router-rewritten address everything; NAT hairpin's `192.168.1.1` is inside the /24); the owner's acceptance test is the landing page on home Wi-Fi (shows) and with Wi-Fi off (connection error). Rehearsed on the Mac in NPM v2.16.0's openresty 1.31.1.1 with its includes. | Owner decision 2026-10-21: "have auth on everything", the key "is the app itself", the owner controls the APK's distribution (first install on both phones by hand), and "the landing page should work if you are on the same LAN, that is OK. In this case, the 'secret' is that you have my wifi password." An NPM access list cannot check a header, and its basic auth would take the `Authorization` header the bearer token needs. | SPEC AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1; `infra/README.md` steps 4, 4a, 5, 6, Operations; `android/app/build.gradle.kts`, `KvittApi.kt`, `PictureAuth.kt`, `DataModule.kt`, `android/README.md` |
|
||||
| D-127 | 2026-10-21 | Pull-to-refresh on Home and account detail (revises design-spec S-4 "Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3)"). S-4 (Home) and S-5 (account detail) offer pull-to-refresh; pulling past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu". The indicator stays while the sync runs and goes when it returns, success or failure. A failure is reported only by the sync pill (design-spec §3.6); there is no new error UI. S-5 has no pill, so a failed pull there shows nothing until Home's pill. Offline (§9.9) the pull animates and ends at once without a sync. A pull while a sync runs starts no second sync. Shared `KvittPullToRefresh(refreshing, onRefresh, content)` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator: a 48 dp `primary-container` disc (Cookie9 shape, circle fallback); the `sync` icon at 18 dp in `on-primary-container`, turning 270° with the pull and popping from 80 % to full size at the threshold on a new `KvittMotion.spatialFast()` spring (0.6 / 800), with a `GestureThresholdActivate` haptic; a 32 dp `primary` arc (stroke 3, track `primary` at 24 %) that fills to the threshold and turns indeterminate while refreshing. The pinned Material 3 1.4.0 has no `LoadingIndicator`, `ContainedLoadingIndicator` or `LinearWavyProgressIndicator`; `ContainedLoadingIndicator` replaces the arc when the pinned Material 3 ships it, in `Refresh.kt` only. TalkBack reads "Synkar…" (`sync_syncing`, reused; `docs/ux/strings.csv` unchanged) as a polite live region while refreshing; S-16 "Synka nu" remains the TalkBack path. Consequences: `HomeUi.Ready.refreshing`, `AccountDetailUi.Ready.refreshing`, `HomeActions.onRefresh`, `AccountDetailActions.onRefresh`; `HomeViewModel` and `AccountDetailViewModel` take `SyncRepository`; test fixture `GatedSyncRepository` in `:core:design` testFixtures; no change to `:core:model`, Room, the sync protocol, the server or `strings.csv`; new goldens `S-4-home-refreshing` and `S-5-account-refreshing` (three variants each: sv light, sv dark, en light); 959 Android tests. Landed `3778f5f`. | Owner request 2026-10-21: pull-to-refresh on Home and the account screen "with some nice fitting design when you pull and then some nice loading that fits the UX theme of the app". The indicator reuses the hero's first-sync tile look (design-spec §3.6 `Loading`) and the `sync` icon of the pill and "Synka nu", so it reads as Kvitt's own; reporting failure only in the pill keeps sync never blocking (§3.6). | SPEC AC-15.3, §9.9, §13.3 (S-4, S-5); design-spec §2.11, §3.1, §3.6, S-4, S-5; `android/core/design/.../component/Refresh.kt`, `KvittMotion.kt`, `KvittDimens.kt`, `:feature:home`, `:feature:account` |
|
||||
|
||||
Reference in New Issue
Block a user