docs(android): detail Stage D progress + remaining path + session decisions
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.
This commit is contained in:
+125
-54
@@ -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/<variant>@<version>.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-<DICT_VERSION>.tar.gz` → a dict dir passed via
|
||||
`DICT_DIR`; **NOT** `../scrabble-solver/dawg`) → `ui/dist/dict/<dictKey>.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`) → `<OUT_DIR|dist>/dict/<variant>@<version>.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/<variant>@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=<release> VITE_DICT_VERSION=<ver> node
|
||||
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
|
||||
|
||||
### E. CI — signed APK artifact
|
||||
|
||||
|
||||
Reference in New Issue
Block a user