Let each channel update only from itself, with nothing to choose

The updater took the channel from a setting, so a production install
could be pointed at dev builds and vice versa. That setting was the only
thing standing between a phone and the wrong line of builds, and it was
a chip you could tap by accident.

The channel is now a property of the running APK and nothing else. The
app reads the -dev suffix in its own versionName, asks that channel and
no other, and then checks again at the point of decision — a release
belonging to the other channel is refused whatever came back from the
fetch. There is no preference, no key in the DataStore, and no way to
cross over.

Crossing over was never an upgrade in the first place. The two channels
are counted by different workflows and their numbers are unrelated: dev
is on 69 while production is on 40, so "newer" means nothing across the
divide. They are separate apps under separate applicationIds, so having
both is a matter of installing both, and each then keeps itself current.

The rule is now a pure function with no Android in it —
update/UpdatePolicy.kt — so it is unit-tested on the JVM like the rules
engines are, rather than being buried in a class that needs an Activity.
UpdatePolicyTest holds the wall: a dev install is refused a production
build and a production install a dev one, at every version number
including the ones where the two sequences overlap.

The settings screen states which build is running and where its updates
come from. It no longer offers a switch, because there is no longer one
to offer.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
This commit is contained in:
Claude
2026-08-08 03:24:48 +00:00
parent 71e000a428
commit 3372adf87a
6 changed files with 325 additions and 156 deletions
+27 -17
View File
@@ -17,8 +17,8 @@ machinery for it is still there and still tested: a game listed in `GameCatalog`
with `available = false` appears in the menu marked *Coming soon* **on dev builds
only**, so a production release lists just what can actually be played and nobody
installs it and taps into a dead end. The menu decides this from the installed
APK's own version name, so it follows the build rather than the channel selected
for future updates.
APK's own version name — the same source the updater uses to know which channel
it is on.
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
@@ -91,13 +91,14 @@ it: every rule above is a pure function and unit-tested on the JVM.
- **Sound** on/off.
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
the computer appears to think.
- **Updates** — check on launch, or check on demand, from either of two
channels:
- **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run.
- **Updates** — check on launch, or check on demand. **There is no channel to
choose**: a build updates from its own channel and no other. A production
install is offered production builds from `main`; a dev install is offered dev
builds from `dev`. The settings screen states which one is running rather than
offering a switch.
The two install as separate apps, so both can be kept on one device — see
[Both channels on one device](#both-channels-on-one-device).
Wanting both means installing both — they are separate apps and each updates
itself, see [Both channels on one device](#both-channels-on-one-device).
## Branding
@@ -184,8 +185,9 @@ the versioned copy, so an installed build can always be traced back to its tag.
There is no equivalent link for the dev channel: `/releases/latest` skips
prereleases by design, and that is exactly what keeps a production install from
being handed a dev build. Dev builds are reached through the in-app updater with
the channel set to dev, or from the releases page.
being handed a dev build. A dev build is reached by the in-app updater of a dev
install — which looks at the prerelease list rather than that endpoint — or from
the releases page.
### The version series
@@ -239,9 +241,16 @@ aimed elsewhere does nothing rather than shipping unreviewed code.
**The two channels have independent version sequences** — a build's `versionCode`
is the run number of the workflow that produced it, and the two workflows count
separately. Versions are therefore only ever compared *within* a channel; the app
reads the `-dev` suffix in its own `versionName` to know which channel it is on,
and treats a change of channel as an explicit switch rather than an upgrade.
separately. Dev is on 69 while production is on 40, so the numbers say nothing
across the divide.
That is why **an install only ever updates within its own channel**, and why
there is no setting for it. The app reads the `-dev` suffix in its own
`versionName` to know which line it is on, asks only that channel, and refuses
anything that comes back belonging to the other one — checked again at the point
of decision rather than trusted from the fetch. The rule is a pure function,
`verdictFor` in `update/UpdatePolicy.kt`, with no Android in it, so it is
unit-tested on the JVM like the rules engines are.
### Both channels on one device
@@ -272,9 +281,10 @@ reference `@color/tartan_field` rather than a literal, which is how one set of
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
Switching channels in Settings therefore installs the other channel's app
alongside this one and leaves this one in place; the settings screen says so
when the selected channel is not the installed one.
Because they are separate apps, having both is a matter of installing both, and
each then updates itself down its own line. Nothing in the app moves a phone
from one channel to the other: the settings screen states which build is
running and where its updates come from, and that is all it does.
The debug keystore is committed on purpose: every build is signed with the same
key, so an OTA update installs over the previous one instead of being rejected
@@ -331,7 +341,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
net/ wire protocol, LAN transport, lobby, match driver
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater
update/ GitHub Releases OTA updater, and the channel rule — pure, tested
ui/ Compose screens
```