Generate the Android launcher/adaptive icons + splashes with capacitor-assets from ui/assets/icon.png (the existing maskable brand mark upscaled 512→1024) as a placeholder until the mandatory single-vector icon rebrand recorded in ANDROID_PLAN.md. The adaptive icon insets the foreground 16.7% into the safe zone; the AndroidManifest is untouched (its icon refs already point at @mipmap). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
36 KiB
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.mdat 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:
-
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. -
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 shouldBootOfflinerequireshasSession && hasProfile; dicts come fromgateway.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) |
| Toolchain (locked) | Capacitor 8 (@capacitor/* ^8); compileSdk/targetSdk 36, minSdk 24; JDK 21 (required — @capacitor/android compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK platforms;android-36 + build-tools;36.x |
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).
Progress (as-built)
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
- A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12). Capacitor 8 native project generated
under
ui/android/(tracked),@capacitor/*deps +capacitor.config.ts, hardware Back inui/src/lib/native.tswired fromApp.svelte.pnpm build→cap sync→./gradlew assembleDebugbuilds a debug APK that installs and cold-launches to the Login screen on the Pixel_10 emulator.svelte-check0 errors,vitest584 passed. Build recipe + toolchain gotchas live in.claude/CLAUDE.md→ "Native Android build (Capacitor)". Launcher icon: a temporary placeholder is in place —ui/assets/icon.png(the maskable «Э» brand mark upscaled 512→1024) →pnpm android:assetsregenerated the Android launcher/adaptive icons + splashes. Superseded by the icon rebrand below. - B–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
the formats drift — favicon.svg is a rounded bordered tile, icon-maskable-512.png a full-bleed
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
(capacitor-assets insets it 16.7% into the adaptive safe zone). ~/.claude/bin/gitea-ci-watch.py is present.
RuStore publication prerequisites gate release only.
Prerequisites to start DEVELOPMENT (on the other device)
Have all of these before writing code:
- JDK 21 (Temurin/OpenJDK) — required by Capacitor 8:
@capacitor/androidsetssourceCompatibility/targetCompatibility = VERSION_21, so a JDK 17 Gradle run fails withinvalid source release: 21. (Homebrew:brew install openjdk@21.) - Android SDK — Android Studio (recommended: bundles the SDK manager + AVD emulator) or
cmdline-tools. Capacitor 8 targets compileSdk/targetSdk 36, minSdk 24, so install:
platform-tools,platforms;android-36,build-tools;36.0.0(a newer build-tools such as36.1.0is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the wrappercap add androidgenerates. - Node 20+ and pnpm (via corepack), matching the repo's
uitoolchain. - Git, plus this repo's Gitea access. If this device will push/PR, set up the
teaCLI login and the CI watcher exactly as the owner's global setup (~/.claudetooling:python3 ~/.claude/bin/gitea-ci-watch.py,teaagainstgitea.lan). - Both repositories side by side per
go.work: clonedeveloper/scrabble-gameand the siblingscrabble-solvernext to it — the solver library the backend/CI consume via thego.workreplace. The bundled dictionaries do NOT come from the solver. The versioned, production dictionary set is published bydeveloper/scrabble-dictionaryas a release artifactscrabble-dawg-<DICT_VERSION>.tar.gz(one semver label per set);backend/Dockerfileand every CI job alreadycurlexactly that tarball, keyed on the shared Gitea variableDICT_VERSION. Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needsDICT_VERSION+ network access to the release, notscrabble-solver. (scrabble-solver/dawg/*.dawgare the solver's committed test fixtures — byte-identical to a build but pinned to the solver commit, not the versioned production set.) - A test target: a physical Android device with USB debugging or an emulator AVD (e.g. a Pixel AVD; any recent API image).
- 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.guestthe 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 enveloperesult_code = "update_required"(HTTP 200); the client already throwsGatewayError(result_code)for any non-ok.Subscribe→ ConnectCodeFailedPrecondition(a stream has noresult_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 — ✅ DONE
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):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 commitsui/android/(a Gradle project). Neither.gitignorelists it today; keep it tracked so build.gradle + signing edits are reviewable and CI reproducible. Ensureui/android/.gitignorecovers build outputs (/app/build,/build,/.gradle,/local.properties,*.keystore,*.jks).- Android hardware Back — new
ui/src/lib/native.ts, dynamically importing@capacitor/appso the web/mock bundle never pulls it:Call once from bootstrap.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(); }); }atNavigationRoot= current route islobby/login(mirrorrouteDepth(...) === 0fromApp.svelte:50-54, readingrouter.svelte.ts). - Launcher icon (owner asset): owner supplies a 1024×1024 «Э» icon at
ui/assets/icon.png(+ optionalsplash.png);pnpm android:assetsgenerates 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)
- One origin helper — new
ui/src/lib/origin.ts:Fix the two same-origin landmines (they would produce// 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 : ''); }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:32already computes the equivalent inline — leave it.)
- Skip the service worker on native —
ui/src/lib/pwa.svelte.ts registerServiceWorker()(line 85): add, next toif (inMiniApp()) return;:import { clientChannel } from './channel'; const ch = clientChannel(); if (ch === 'android' || ch === 'ios') return; - Hide payments in the MVP build —
ui/src/lib/distribution.ts, add:Switch the wallet/purchase consumers from// 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'; }isGooglePlayBuild()topurchasesHidden()where they hide the buy actions (grepisGooglePlayBuildunderui/src). Keep the "go to RuStore" stubisGooglePlayBuild()-only. AddVITE_PAYMENTS_DISABLED?: string,VITE_STORE_URL?: string,VITE_DICT_VERSION?: stringtoui/src/vite-env.d.ts. - 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 leadingv?MAJOR.MINOR.PATCH(ignore any-N-gSHA/+metasuffix) + compare:Tests: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 }"v1.16.0","1.16.0","v1.16.0-3-gabc","dev"/""→!ok, ordering incl. equal. - Config
gateway/internal/config/config.go: addMinClientVersion 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. Extendconfig_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: addminClient clientver.Version+gateOn bool.Deps: addMinClientVersion string.NewServer: parsed.MinClientVersiononce (setgateOnon success;log.Warn+ off if unparseable).- helper
clientTooOld(header string) bool:if !s.gateOn { return false }; parse header, on!okreturn false (fail-open); returnclientver.Less(v, s.minClient). Execute: insert after thedefermetrics line (afterserver.go:296, beforeregistry.Lookup), somsgTypeis set and the payload is untouched: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 (beforeresolve):if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }.main.go: addMinClientVersion: cfg.MinClientVersion,to theconnectsrv.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.
- consts
C2. Client (ui)
ui/src/lib/update.svelte.ts(new, terminal — no poll):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: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 thesubscribe()catch: if the code isUPDATE_REQUIRED, callreportUpdateRequired()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 onexec/a dedicatedauthGuestSilentpath guarded by a flag; the executing session picks the seam. Foregroundauth.*keep the overlay behaviour.
ui/src/lib/retry.tstoGatewayError(): addcase Code.FailedPrecondition: return new GatewayError('update_required', e.message);.ui/src/components/UpdateOverlay.svelte(new, mirrorMaintenanceOverlay.svelte): non-dismissable; shown whenupdateRequired.active; one button — native (clientChannel()android/ios) →window.open(import.meta.env.VITE_STORE_URL, '_system'); web →location.reload(). i18n keysupdate.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.tsmock branch, next to__maint:__update = { on: reportUpdateRequired }. - Tests:
update.svelte.test.ts; extendretry.test.ts(FailedPrecondition → 'update_required'); Playwrightupdate.spec.tsdriving__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.
- Bundle dictionaries in the APK.
- New build step
ui/scripts/bundle-dicts.mjs: copy the three DAWGs from the unpackedscrabble-dictionaryrelease (scrabble-dawg-<DICT_VERSION>.tar.gz→ a dict dir passed viaDICT_DIR; NOT../scrabble-solver/dawg) →ui/dist/dict/<dictKey>.dawg(thedictKey(variant, version)naming the loader's bundled tier fetches). Run only in the native pipeline (afterpnpm build, beforecap sync). Web builds stay slim.VITE_DICT_VERSIONmust equal the release'sDICT_VERSIONso the bundled(variant, version)matches what the loader requests. - Vite
define: add__DICT_VERSION__fromVITE_DICT_VERSION(mirrors__APP_VERSION__); declare it invite-env.d.ts. - Loader tier —
ui/src/lib/dict/loader.tsload(): between the IndexedDB tier (line 73-85) and the network tier (line 87), add a bundled tier thatfetch('./dict/'+key+'.dawg')(relative — served from app assets on native, 404 on web → falls through). On a hit, build theDawg, 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.svelteoffline-create andlocalgameresolve the dict version, use__DICT_VERSION__so the requested(variant, version)matches the bundled file (grep the offlinelocalSource.createcalls inNewGame.svelte:83-96,289-296and the dict-version resolution ingamesource.ts/localgame).
- New build step
- Local-guest identity — new
ui/src/lib/localguest.ts: a device-local id + default display name (e.g. localized "Гость"), persisted (IndexedDBscrabbleDB / 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 — seelocalgame/source.list.test.ts:64). Hotseat seats stay independent local identities (already so). - 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), setsetOfflineMode(true, …), establish the local guest, preload the bundled dicts, and land in the lobby (notLogin). Preserve the existing online path (guest/email) when reachable. offlinePreloadEligible(offline.ts:69): treat a native channel as eligible (drop thestandalone && hasEmailweb gate for native), or bypass preload entirely on native since the dicts are bundled.
- Extend
- 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. Onupdate_required, stay offline silently (no overlay). - Ensure both offline modes render for the local guest.
NewGame.sveltegates the offline flows onguest || offlineMode.active; the local guest makesofflineMode.activetrue, so both the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the local-guest id. - 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:
- Checkout this repo; copy the Node/pnpm setup block from
.gitea/workflows/ci.yaml(theuijob). Add the "Fetch dictionary DAWGs" step verbatim fromci.yaml(thecurl … scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz+ untar into${GITHUB_WORKSPACE}/dawg), keyed on theDICT_VERSIONGitea variable — this, not thescrabble-solversibling, is the source of the bundled dicts. (scrabble-solveris not needed by the Android build:uiis a Node project outsidego.work.) pnpm install --frozen-lockfile.pnpm buildwith the native env (below),VITE_APP_VERSION=git describe --tags,VITE_DICT_VERSION= the release dict version.DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs(copy the release.dawgfiles intoui/dist/dict/).- Setup JDK 21 (
actions/setup-java@v4, temurin 21). - Install Android SDK via
sdkmanager(pinplatform-tools,platforms;android-36,build-tools;36.0.0); cache the SDK. No emulator — assemble only; on-device smoke is manual. npx cap sync android.- Decode the keystore from
ANDROID_KEYSTORE_BASE64; computeversionCode/versionNamefrom the tag (scheme below). cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAMEwith the signing config reading the keystore +ANDROID_KEYSTORE_PASSWORD/ANDROID_KEY_ALIAS/ANDROID_KEY_PASSWORDfrom env.- Upload
ui/android/app/build/outputs/apk/release/*.apkas 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,versionCodescheme, RuStore,file://-origin implications).docs/FUNCTIONAL.md(+docs/FUNCTIONAL_ru.mdmirror): 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 whenGATEWAY_MIN_CLIENT_VERSIONis bumped).- Config/README docs: new vars
GATEWAY_MIN_CLIENT_VERSION,VITE_STORE_URL,VITE_PAYMENTS_DISABLED,VITE_DICT_VERSION, and the nativeVITE_GATEWAY_URLvalue. deploy/README.md: the Android build/release runbook (keystore + secrets, dispatch, RuStore upload) and the discipline rule — bumpGATEWAY_MIN_CLIENT_VERSIONin the same prod deploy that ships an incompatible wire change.
G. Release + owner handoff
- Merge
feature/android-native→development; verify on the contour (contour-safe; gate dormant). - Promote
development→master; tagvX.Y.0. - Dispatch
android-build; watch green; retrieve the signed APK. - Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
- 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_BUILDunset. 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.
Google Play compliance — decided: no RuStore steering. Google forbids not just external billing
but steering users to other payment methods or app stores. So the Google Play build hides purchases
with no pointer to RuStore: the existing isGooglePlayBuild() RuStore-pointer stub is dropped
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
other store). This does not affect the RuStore MVP (there isGooglePlayBuild() is false, so the stub
never shows). Google Play also requires an AAB (not APK) and Play App Signing — a separate CI
target, built later.
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__= theX-Client-Versionheader.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_BUILDunset — 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-buildartifact. - 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.gotests + the Playwright__updatee2e 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-Versionstripped 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-nativePR deploys cleanly (no schema/wire regen; gate dormant); web/VK/TG behaviour unchanged.