docs: add ANDROID_PLAN.md — native Android (Capacitor) RuStore MVP plan
This commit is contained in:
+532
@@ -0,0 +1,532 @@
|
||||
# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first
|
||||
|
||||
> Self-contained implementation plan for the standalone Android app, to be executed in a fresh
|
||||
> session (another device). Work through the breakdown A–G in order; each part states its own
|
||||
> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here.
|
||||
|
||||
---
|
||||
|
||||
## Context (why)
|
||||
|
||||
`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and
|
||||
VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already
|
||||
declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`,
|
||||
`file://`-safe hash router, relative asset base, a reserved native share branch,
|
||||
`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is
|
||||
scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`.
|
||||
|
||||
Two problems native introduces that the web never had:
|
||||
|
||||
1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so
|
||||
client and server are always paired. In a store a user may run a months-old bundle; if the
|
||||
FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all.
|
||||
Today there is **no** client↔server version contract — the only "update" path is the web-only
|
||||
`location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**.
|
||||
|
||||
2. **First launch must work with no network.** The app must open straight into a guest experience
|
||||
and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the
|
||||
internet is off on first launch. The current offline mode is a *returning-user* feature: it needs
|
||||
a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires
|
||||
`hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**:
|
||||
bundle dictionaries in the APK and boot as a device-local guest with no server session.
|
||||
|
||||
**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production
|
||||
gateway, (b) is protected by the version gate so future incompatible server changes turn old
|
||||
installs away cleanly, and (c) opens offline-first as a soft guest with local play.
|
||||
|
||||
## Locked decisions (owner interview)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| First store | **RuStore** (Google Play is a later variant — see below) |
|
||||
| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) |
|
||||
| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network |
|
||||
| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) |
|
||||
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
|
||||
| appId (permanent) | **`ru.eruditgame.app`** |
|
||||
| App display name | **`Эрудит`** (Cyrillic) |
|
||||
|
||||
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
||||
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
||||
rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create
|
||||
`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into
|
||||
`development` (contour review), then promote `development → master`, tag, dispatch the Android build.
|
||||
The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope
|
||||
`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is
|
||||
client-only (no server change).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites to start DEVELOPMENT (on the other device)
|
||||
|
||||
Have all of these before writing code:
|
||||
|
||||
- **JDK 17** (Temurin/OpenJDK).
|
||||
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
|
||||
cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`.
|
||||
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
|
||||
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
||||
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
||||
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
||||
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
||||
sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries
|
||||
(`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and
|
||||
the solver library the backend/CI use.
|
||||
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel,
|
||||
API 34).
|
||||
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
||||
is only needed to exercise the version gate locally (optional).
|
||||
|
||||
Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end** —
|
||||
they gate *release*, not development.
|
||||
|
||||
---
|
||||
|
||||
## Identity model (answers "how do guests work offline vs in the DB")
|
||||
|
||||
Split the **local play identity** from the **server account**. This *extends* the current model
|
||||
(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged.
|
||||
|
||||
- **Local guest (device-local, no DB row).** A device-generated id + a default display name,
|
||||
persisted on the device. Exists from the very first launch, with no network. It fills the human
|
||||
seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a
|
||||
DB row (consumes no server resources).
|
||||
- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the
|
||||
network, its session cached and reused. Unlocks online features (matchmaking, friends, online
|
||||
games). Exactly one per device — guarded by the cached session (never mint if one exists).
|
||||
- **Registered account.** Email upgrade as today; adopts the current server guest.
|
||||
- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on
|
||||
the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity
|
||||
transitions. Reconciliation to a server guest does **not** migrate them.
|
||||
|
||||
**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the
|
||||
background, cache the session, adopt it. This is best-effort — see the gate interaction below for how
|
||||
a too-old client stays offline instead of being interrupted.
|
||||
|
||||
---
|
||||
|
||||
## The client-version gate — architecture (read before touching gate code)
|
||||
|
||||
**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers
|
||||
payload.** The transport is two layers: a protobuf Connect envelope
|
||||
(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a
|
||||
FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are
|
||||
version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in
|
||||
an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**.
|
||||
|
||||
**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every
|
||||
Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute`
|
||||
(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes
|
||||
(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client
|
||||
makes **zero** successful requests but sees a recognizable signal, not a crash.
|
||||
|
||||
**Two return shapes, one meaning:**
|
||||
- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client
|
||||
already throws `GatewayError(result_code)` for any non-`ok`.
|
||||
- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`).
|
||||
|
||||
**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any
|
||||
build, however old, can always recognize "update required": (1) the protobuf envelope field numbers
|
||||
in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code`
|
||||
string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive**
|
||||
(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS
|
||||
payload.
|
||||
|
||||
**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire
|
||||
change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until
|
||||
deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is
|
||||
unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred.
|
||||
|
||||
**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch
|
||||
(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can
|
||||
always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal
|
||||
"update" overlay must be raised **only on a user-initiated online action**, never on the silent
|
||||
background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow
|
||||
it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the
|
||||
`update_required` code and does not route it to the global overlay trigger; foreground calls do.
|
||||
|
||||
---
|
||||
|
||||
## Work breakdown
|
||||
|
||||
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
|
||||
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
|
||||
|
||||
### A. Capacitor scaffolding
|
||||
|
||||
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
|
||||
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
|
||||
`"android:assets": "capacitor-assets generate --android"`.
|
||||
- `ui/capacitor.config.ts` (new):
|
||||
```ts
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'ru.eruditgame.app',
|
||||
appName: 'Эрудит',
|
||||
webDir: 'dist',
|
||||
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
|
||||
// ship through the store; the client-version gate turns away a build too old to speak the
|
||||
// current wire contract (docs/ARCHITECTURE.md §2).
|
||||
};
|
||||
export default config;
|
||||
```
|
||||
- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither
|
||||
`.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI
|
||||
reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`,
|
||||
`/.gradle`, `/local.properties`, `*.keystore`, `*.jks`).
|
||||
- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so
|
||||
the web/mock bundle never pulls it:
|
||||
```ts
|
||||
import { clientChannel } from './channel';
|
||||
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
|
||||
const ch = clientChannel();
|
||||
if (ch !== 'android' && ch !== 'ios') return;
|
||||
const { App } = await import('@capacitor/app');
|
||||
App.addListener('backButton', () => {
|
||||
if (atNavigationRoot()) void App.exitApp();
|
||||
else history.back();
|
||||
});
|
||||
}
|
||||
```
|
||||
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
||||
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
|
||||
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
|
||||
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
|
||||
|
||||
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
||||
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||||
|
||||
### B. Native web-build correctness (file:// origin)
|
||||
|
||||
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
||||
```ts
|
||||
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
|
||||
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
|
||||
// origin. Centralised so every absolute-URL construction resolves identically.
|
||||
export function gatewayOrigin(): string {
|
||||
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
|
||||
return configured || (typeof location !== 'undefined' ? location.origin : '');
|
||||
}
|
||||
```
|
||||
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
|
||||
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
|
||||
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
|
||||
(`transport.ts:32` already computes the equivalent inline — leave it.)
|
||||
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
|
||||
add, next to `if (inMiniApp()) return;`:
|
||||
```ts
|
||||
import { clientChannel } from './channel';
|
||||
const ch = clientChannel();
|
||||
if (ch === 'android' || ch === 'ios') return;
|
||||
```
|
||||
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
|
||||
```ts
|
||||
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
|
||||
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
|
||||
export function purchasesHidden(): boolean {
|
||||
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
|
||||
}
|
||||
```
|
||||
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
||||
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub
|
||||
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||
4. **Native env matrix** — see Build & env.
|
||||
|
||||
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
||||
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||
|
||||
### C. The client-version gate
|
||||
|
||||
#### C1. Backend (gateway)
|
||||
|
||||
- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of
|
||||
the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare:
|
||||
```go
|
||||
package clientver
|
||||
import ("strconv"; "strings")
|
||||
type Version struct{ Major, Minor, Patch int }
|
||||
func Parse(s string) (Version, bool) {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] }
|
||||
p := strings.SplitN(s, ".", 4)
|
||||
if len(p) < 3 { return Version{}, false }
|
||||
var v Version; var err error
|
||||
if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false }
|
||||
if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false }
|
||||
if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false }
|
||||
return v, true
|
||||
}
|
||||
func Less(a, b Version) bool {
|
||||
if a.Major != b.Major { return a.Major < b.Major }
|
||||
if a.Minor != b.Minor { return a.Minor < b.Minor }
|
||||
return a.Patch < b.Patch
|
||||
}
|
||||
```
|
||||
Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal.
|
||||
- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate
|
||||
off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if
|
||||
non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`.
|
||||
- **Server `gateway/internal/connectsrv/server.go`**:
|
||||
- consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`;
|
||||
`errors.go`: `errUpdateRequired = errors.New("client too old, update required")`.
|
||||
- `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`.
|
||||
`NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if
|
||||
unparseable).
|
||||
- helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on
|
||||
`!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`.
|
||||
- `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before
|
||||
`registry.Lookup`), so `msgType` is set and the payload is untouched:
|
||||
```go
|
||||
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||
result = resultUpdateRequired
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired,
|
||||
}), nil
|
||||
}
|
||||
```
|
||||
- `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`.
|
||||
- `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal.
|
||||
- Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler
|
||||
never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable
|
||||
header / equal version ⇒ pass.
|
||||
|
||||
#### C2. Client (ui)
|
||||
|
||||
- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll):
|
||||
```ts
|
||||
export const UPDATE_REQUIRED = 'update_required';
|
||||
let required = $state(false);
|
||||
export const updateRequired = { get active(): boolean { return required; } };
|
||||
export function reportUpdateRequired(): void { required = true; }
|
||||
```
|
||||
- **`ui/src/lib/transport.ts`**:
|
||||
- `headers()` (line 37) always attaches the version header:
|
||||
```ts
|
||||
const headers = (): Record<string, string> => {
|
||||
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
|
||||
if (token) h.authorization = `Bearer ${token}`;
|
||||
return h;
|
||||
};
|
||||
```
|
||||
- result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();`
|
||||
before the throw.
|
||||
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||||
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||||
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does
|
||||
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
|
||||
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
|
||||
seam. Foreground `auth.*` keep the overlay behaviour.
|
||||
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||||
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||
`update.title/body/action` (add siblings to the maintenance keys).
|
||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||||
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||||
- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`);
|
||||
Playwright `update.spec.ts` driving `__update.on()`.
|
||||
|
||||
**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)
|
||||
|
||||
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 `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`
|
||||
→ `ui/dist/dict/<variant>@<DICT_VERSION>.dawg` (dictKey naming). Run only in the native pipeline
|
||||
(after `pnpm build`, before `cap sync`). Web builds stay slim.
|
||||
- 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` (the silent variant from C2), cache the session, adopt it; unlock online features.
|
||||
Guard against duplicates (only when no cached session). Local games remain device-only. On
|
||||
`update_required`, stay offline silently (no overlay).
|
||||
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
|
||||
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.)
|
||||
|
||||
**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.
|
||||
|
||||
### E. CI — signed APK artifact
|
||||
|
||||
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
||||
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
||||
1. Checkout this repo **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup
|
||||
block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts.
|
||||
2. `pnpm install --frozen-lockfile`.
|
||||
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
|
||||
`VITE_DICT_VERSION` = the release dict version.
|
||||
4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`).
|
||||
5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17).
|
||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`,
|
||||
`build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||||
7. `npx cap sync android`.
|
||||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||||
tag (scheme below).
|
||||
9. `cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAME` with the
|
||||
signing config reading the keystore + `ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/
|
||||
`ANDROID_KEY_PASSWORD` from env.
|
||||
10. Upload `ui/android/app/build/outputs/apk/release/*.apk` as an artifact.
|
||||
|
||||
Watch to green with `python3 ~/.claude/bin/gitea-ci-watch.py` (background). RuStore upload is manual
|
||||
for the MVP.
|
||||
|
||||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
||||
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
||||
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
||||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
||||
|
||||
### F. Docs (bake in the same PR)
|
||||
|
||||
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
|
||||
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
|
||||
reconciliation) and the **native Android build** (Capacitor bundle model, bundled dicts,
|
||||
`versionCode` scheme, RuStore, `file://`-origin implications).
|
||||
- `docs/FUNCTIONAL.md` (+ `docs/FUNCTIONAL_ru.md` mirror): user stories — offline-first guest launch
|
||||
(vs_ai + hotseat with no network), soft registration, "update required when too old", native
|
||||
distribution (no purchases in the MVP).
|
||||
- `docs/TESTING.md`: the new Go tests (`clientver`, gate), the client tests, the bundled-dict tier
|
||||
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
|
||||
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
||||
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
||||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
|
||||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
|
||||
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
|
||||
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||||
ships an incompatible wire change.
|
||||
|
||||
### G. Release + owner handoff
|
||||
|
||||
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
||||
4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
|
||||
5. Owner uploads to RuStore (see publication prerequisites).
|
||||
|
||||
---
|
||||
|
||||
## Google Play variant (later — design now so we don't repaint)
|
||||
|
||||
One codebase, two build variants selected by flags:
|
||||
- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when
|
||||
RuStore billing lands, RuStore sells normally.
|
||||
- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional
|
||||
difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries
|
||||
both cases, so no repaint is needed.
|
||||
|
||||
**Open compliance item to settle when the GP build is actually built (deferred, not now):** Google
|
||||
forbids not just external billing but *steering* users to other payment methods or app stores. The
|
||||
existing `isGooglePlayBuild()` stub "points the user to the RuStore build" — that steering likely
|
||||
violates Google policy. When building for GP, change the stub to a neutral "not available here" with
|
||||
no pointer to RuStore. Tracked here so it is not forgotten; it does not affect the RuStore MVP. Google
|
||||
Play also requires an **AAB** (not APK) and its own signing/Play App Signing — a separate CI target.
|
||||
|
||||
---
|
||||
|
||||
## Build & env matrix
|
||||
|
||||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
||||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
||||
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
||||
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
||||
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||||
|
||||
**Web/contour/prod builds:** unchanged. `VITE_GATEWAY_URL` empty (same-origin),
|
||||
`GATEWAY_MIN_CLIENT_VERSION` unset (gate dormant). Nothing about web behaviour changes until a future
|
||||
breaking release deliberately sets the min version.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites to start PUBLICATION (RuStore)
|
||||
|
||||
- **RuStore developer account** — a registered legal entity or self-employed (самозанятый) RU status,
|
||||
verified. Gates publication (not the build).
|
||||
- **Release keystore** — generate once and **back up immutably** (losing it means never updating this
|
||||
app again):
|
||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`.
|
||||
Set Gitea repo secrets: `ANDROID_KEYSTORE_BASE64` (base64 of the .jks),
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||||
- **The signed APK** — from the `android-build` artifact.
|
||||
- **Store listing** — reserve `ru.eruditgame.app`; title «Эрудит»; RU description; phone screenshots;
|
||||
icon/feature graphic; category; age rating; a hosted **privacy-policy URL**; the RuStore content
|
||||
declaration.
|
||||
- (For the later **Google Play** variant: a Google Play Console account ($25), the GP build (payments
|
||||
hidden + the anti-steering fix), an **AAB**, Play App Signing, and the Play Data-safety form.)
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **Keystore loss** → app can never be updated. Back up off-host immediately.
|
||||
- **Gate dormant until first bump** → its real firing only happens on a future breaking release. The
|
||||
Go `server_test.go` tests + the Playwright `__update` e2e prove the whole path now.
|
||||
- **Update overlay interrupting offline play** → the gate×offline rule (overlay only on user-initiated
|
||||
online actions; silent reconciliation swallows `update_required`).
|
||||
- **`X-Client-Version` stripped by the edge** → Caddy forwards request headers by default (the RPC
|
||||
route already reaches the gateway); no Caddyfile change. Confirm via the smoke (bump min → overlay).
|
||||
- **APK size from bundled dicts** → a few MB per variant; acceptable. Could trim to RU-only later.
|
||||
- **RuStore account gate** (legal status) → surface to the owner before release.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
|
||||
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
|
||||
the native build, a soft "recommended update" tier, native splash/status-bar plugins, push
|
||||
notifications (FCM), and automated RuStore-API upload.
|
||||
|
||||
## End-to-end verification
|
||||
|
||||
- **Unit/integration:** `go test ./gateway/...` (clientver + gate), `cd ui && pnpm check &&
|
||||
pnpm test:unit && pnpm test:e2e`, `gofmt -l .` clean, `go vet ./gateway/...`.
|
||||
- **Gate proof:** gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` ⇒ overlay; unset ⇒ normal.
|
||||
- **Offline-first proof:** native `assembleDebug`, install, **airplane mode**, cold launch → guest
|
||||
lobby → play a local vs_ai move and a 2-player hotseat game; network on → server guest established.
|
||||
- **Native build proof:** guest sign-in online, a move, Back navigates then exits at root, a
|
||||
share/export link points at `erudit-game.ru`, purchases absent.
|
||||
- **Signed pipeline:** dispatch `android-build`; watcher green; the release APK installs and launches.
|
||||
- **Contour:** the `feature/android-native` PR deploys cleanly (no schema/wire regen; gate dormant);
|
||||
web/VK/TG behaviour unchanged.
|
||||
Reference in New Issue
Block a user