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.
|
`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
|
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
|
||||||
store-update SDK noted Out of scope.
|
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
|
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
|
*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
|
**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.
|
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
|
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`
|
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
|
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
|
||||||
with no network**.
|
with no network**.
|
||||||
|
|
||||||
1. **Bundle dictionaries in the APK.**
|
**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the
|
||||||
- New build step `ui/scripts/bundle-dicts.mjs`: copy the three DAWGs from the unpacked
|
web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and
|
||||||
**`scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz` → a dict dir passed via
|
the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12).
|
||||||
`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.
|
|
||||||
|
|
||||||
**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock
|
**Decisions locked this session (owner-approved) — bake these before implementing:**
|
||||||
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a
|
- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
|
||||||
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and
|
online-session rule (they still need a prior session). The native channel always lands the user in the
|
||||||
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the
|
lobby, online or offline.
|
||||||
gate's server path in Go tests.)
|
- **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
|
**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
|
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
|
### E. CI — signed APK artifact
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user