From edaf2dfd9ea558a66011d56e5529b55c84f5a047 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 22:42:41 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20add=20ANDROID=5FPLAN.md=20=E2=80=94=20n?= =?UTF-8?q?ative=20Android=20(Capacitor)=20RuStore=20MVP=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ANDROID_PLAN.md | 532 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 ANDROID_PLAN.md diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md new file mode 100644 index 0000000..3b49d49 --- /dev/null +++ b/ANDROID_PLAN.md @@ -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 { + 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).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 => { + const h: Record = { '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 `` right after `` (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/@.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=` — 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.