From a035edfb54ce40fde57d3638a38c273224214d46 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 12 Jul 2026 17:01:23 +0200 Subject: [PATCH] docs(android): detail Stage D progress + remaining path + session decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make ANDROID_PLAN.md self-contained so a fresh session resumes Stage D from the repo alone. Records: - the done foundations (D.1 bundled dicts + loader tier, D.2 local-guest identity) with their exact modules and the committed checkpoint (bcd5a1d); - the session decisions (owner-approved): the blocking Login is bypassed on native only, soft registration reuses the Profile screen, the Telegram/VK link buttons are hidden on native (VK's web redirect strands the Capacitor app; native tg/vk is a later stage), local-guest name = localized common.guest; - the native-gated loader-tier correction (a web `./dict/` fetch would hit the gateway's own session-gated /dict/ route, so the bundled tier is only tried on native); - the detailed remaining path — D.3 boot rewrite with the exact blocking-login site (app.svelte.ts ~989-991), D.4 reconciliation + the silent seam wiring, D.6 Profile tg/vk gating — plus the offline-first e2e strategy (simulate native by injecting window.Capacitor) and its initNativeShell/@capacitor/app gotcha. --- ANDROID_PLAN.md | 179 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 125 insertions(+), 54 deletions(-) diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 97791da..d77436d 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -91,7 +91,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl. `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app store-update SDK noted Out of scope. -- **D–G — pending.** +- **D. Offline-first — 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The + additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in + + tests remain (see §D for the detailed remaining path and the session decisions). Done: + `ui/scripts/bundle-dicts.mjs` (release DAWGs → `dist/dict/@.dawg`, keyed on + `VITE_DICT_VERSION`, `OUT_DIR` override for the e2e); the `dict/loader.ts` **bundled tier** (between + IndexedDB and network, **native-gated** — see the §D.1 correction); `__DICT_VERSION__` vite define + + declaration; `lib/localguest.ts` (persisted device-local guest id, no DB row) + `common.guest` i18n; + `NewGame.svelte` offline vs_ai/hotseat creates fall back to `__DICT_VERSION__` and + `localGuestId()`/`t('common.guest')` when there is no session (inert until the boot lands). + `svelte-check` 0, `vitest` 591, native + web builds clean. +- **E–G — pending.** Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate *every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today @@ -394,69 +404,130 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. **Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged. -### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) +### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — 🚧 IN PROGRESS Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts` and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch with no network**. -1. **Bundle dictionaries in the APK.** - - New build step `ui/scripts/bundle-dicts.mjs`: copy the three DAWGs from the unpacked - **`scrabble-dictionary` release** (`scrabble-dawg-.tar.gz` → a dict dir passed via - `DICT_DIR`; **NOT** `../scrabble-solver/dawg`) → `ui/dist/dict/.dawg` (the - `dictKey(variant, version)` naming the loader's bundled tier fetches). Run only in the native - pipeline (after `pnpm build`, before `cap sync`). Web builds stay slim. `VITE_DICT_VERSION` must - equal the release's `DICT_VERSION` so the bundled `(variant, version)` matches what the loader - requests. - - Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); - declare it in `vite-env.d.ts`. - - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) - and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` - (relative — served from app assets on native, 404 on web → falls through). On a hit, build the - `Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that - lets a never-online device obtain a dictionary. - - Offline local-game creation must request the **bundled version**: where `NewGame.svelte` - offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested - `(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in - `NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`). -2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display - name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the - human seat's account id in a local vs_ai game when there is no server session (today that id comes - from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local - identities (already so). -3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`: - - Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still - boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still - needs a prior online session). - - In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on - OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest, - preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online - path (guest/email) when reachable. - - `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the - `standalone && hasEmail` web gate for native), or bypass preload entirely on native since the - dicts are bundled. -4. **Lazy server-guest reconciliation** — when native and online and no server session: background - `auth.guest`, cache the session, adopt it; unlock online features. Guard against duplicates (only - when no cached session). Local games remain device-only. **Add the silent update-path variant here** - (a flag on the transport `exec`/a dedicated silent `auth.guest`) so reconciliation swallows - `update_required` and stays offline — no overlay. This is the seam deferred from C. -5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline - flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both - the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the - local-guest id. -6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing - flow), adopting the server guest first if present. +**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the +web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and +the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12). -**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock -returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a -Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and -starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the -gate's server path in Go tests.) +**Decisions locked this session (owner-approved) — bake these before implementing:** +- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current + online-session rule (they still need a prior session). The native channel always lands the user in the + lobby, online or offline. +- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it + already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D. +- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page + redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI), + and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no + redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a + **separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram + login in the native build". +- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest"). + +1. **Bundle dictionaries in the APK. ✅ DONE.** + - `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release** + (dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `/dict/@.dawg`. + `dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps + `scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`). + Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at + `dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip + it and stay slim. + - Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`) + + declared in `vite-env.d.ts`. + - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB + tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is + **native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative + `fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route** + (a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in + a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return + (no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests). + - Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and + hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less + local guest gets the bundled `(variant, version)`. +2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a + device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the + old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')` + at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat + (`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and + `name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities + (`buildSeats` — unchanged). +3. **Cold offline-first boot — ▢ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High + blast-radius: this is app startup for every platform — gate every change on the native channel and + leave the web / PWA / TG / VK paths byte-for-byte.** + - The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**: + `} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }` + — the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the + offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)` + (**non-sticky** auto-offline, so reconciliation may clear it — cf. the sticky arg at ~line 968), and + land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to + `/login` on native. + - The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot, + also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby + renders without a session — offline mode shows local games and `NewGame` gates its offline flows on + `guest || offlineMode.active`. + - `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a + **native-no-session** path so a native cold start can boot offline as the local guest with no cached + session/profile. Keep the web rule intact. + - `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** — the dicts are + bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate. +4. **Lazy server-guest reconciliation + the silent seam — ▢ TODO** (the seam was deferred from C). + - **Silent seam:** the C transport calls `reportUpdateRequired()` in three places — `exec` result-code + branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a + **silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: { + silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface + (`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` — the e2e reconciliation + leg goes through the mock). Foreground `auth.*` keep the overlay. + - **Reconciliation:** when native + online + **no** server session → background `authGuestSilent`, + `saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then + **clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard + duplicates (only when no cached session — never mint a second server guest). On `update_required` it + stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and + on network-recovery (a connection-online handler). +5. **Both offline modes render for the local guest — ✅ mostly (verify at D close).** `NewGame` gates the + offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both + "quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id + (D.2). +6. **Soft registration = reuse the `Profile` screen — ▢ TODO.** + - Profile is already reachable for an **online** guest (guests keep the Profile tab — + `SettingsHub.svelte:27` only hides friends/wallet for guests; `:33` hides profile/friends/wallet in + **offline** mode). So once the native guest is online and reconciled (D.4 clears the auto-offline), + Profile shows and email sign-in works. Offline you cannot register anyway (every method needs the + server), so the offline-hidden behaviour is correct — **no change is needed to reach Profile**, provided + D.4 clears the auto-offline when online. + - **Hide tg/vk on native:** `Profile.svelte` — the Telegram link button (`~line 485`, gated on + `telegramLinkable = loginWidgetAvailable()`, `:62`) and the VK link button (`~line 499`). Add a + `clientChannel()` android/ios check to hide both on the native build; keep email + account management. + (See the decision block above for why.) + +**Tests — ▢ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path +stays in the Go tests): +- **Unit (vitest, node):** the bundled-dict tier — a `fetch` mock returning a dawg blob **with the native + channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the + tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline` + native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style + reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.) +- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform: + () => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled + tier + the native boot). Place bundled dicts at **`dist-e2e/dict/@dev.dawg`** — extend the + `playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node + scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset → `dev`, matching `__DICT_VERSION__`). Boot with the + network hook off (`window.__conn.offline()` / the offline pref) → assert the **lobby** (not login), play a + local vs_ai move, start a hotseat game; then network on → reconciliation lights up. + **GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await + import('@capacitor/app')` and call `App.addListener('backButton', …)` — there is no real Capacitor + bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell` + tolerate a missing bridge) or the boot may throw. **Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on -silently establishes a server guest and lights up online play. +silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md` +→ "Native Android build", with the extra `DICT_DIR= VITE_DICT_VERSION= node +scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`. ### E. CI — signed APK artifact