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
@@ -0,0 +1,140 @@
package org.prolibertate.games.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The wall between the two channels.
*
* A dev install may only ever be offered dev builds and a production install
* production ones. There is no setting for it, so the only way this can go
* wrong now is here.
*/
class UpdatePolicyTest {
private fun release(
tag: String,
versionCode: Int,
channel: UpdateChannel,
) = Release(
tag = tag,
versionCode = versionCode,
apkUrl = "https://example.invalid/$tag.apk",
apkName = "ProLibertateGames-$tag.apk",
channel = channel,
)
private val devBuild = release("v1.1.69-dev", 69, UpdateChannel.DEV)
private val productionBuild = release("v1.1.40", 40, UpdateChannel.PRODUCTION)
// -- Reading what a build is --------------------------------------------
@Test
fun `a version name says which channel it belongs to`() {
assertEquals(UpdateChannel.DEV, channelOfVersionName("1.1.69-dev"))
assertEquals(UpdateChannel.PRODUCTION, channelOfVersionName("1.1.40"))
}
@Test
fun `either mark is enough to make a release a dev build`() {
assertEquals(UpdateChannel.DEV, channelOfTag("v1.1.69-dev", prerelease = false))
assertEquals(UpdateChannel.DEV, channelOfTag("v1.1.69", prerelease = true))
assertEquals(UpdateChannel.PRODUCTION, channelOfTag("v1.1.40", prerelease = false))
}
@Test
fun `only the run number is read from a tag`() {
assertEquals(69, versionCodeOfTag("v1.1.69-dev"))
assertEquals(40, versionCodeOfTag("v1.1.40"))
// A series bump must never read as a downgrade.
assertTrue(versionCodeOfTag("v2.0.41")!! > versionCodeOfTag("v1.1.40")!!)
assertNull(versionCodeOfTag("nightly"))
}
// -- The wall -----------------------------------------------------------
@Test
fun `a dev install is never offered a production build`() {
val verdict = verdictFor(
latest = productionBuild,
installedChannel = UpdateChannel.DEV,
// Deliberately far behind: even a much "newer" number on the other
// channel must not get through, because the numbers are unrelated.
installedVersionCode = 1,
)
assertTrue("a dev install was offered $productionBuild", verdict is UpdateVerdict.Refused)
}
@Test
fun `a production install is never offered a dev build`() {
val verdict = verdictFor(
latest = devBuild,
installedChannel = UpdateChannel.PRODUCTION,
installedVersionCode = 1,
)
assertTrue("a production install was offered $devBuild", verdict is UpdateVerdict.Refused)
}
// -- Within a channel ---------------------------------------------------
@Test
fun `a newer build on the same channel is offered`() {
assertEquals(
UpdateVerdict.Install(devBuild),
verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 62),
)
assertEquals(
UpdateVerdict.Install(productionBuild),
verdictFor(productionBuild, UpdateChannel.PRODUCTION, installedVersionCode = 39),
)
}
@Test
fun `the same build is not offered to itself`() {
assertEquals(
UpdateVerdict.UpToDate,
verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 69),
)
}
@Test
fun `an older build is not offered`() {
assertEquals(
UpdateVerdict.UpToDate,
verdictFor(devBuild, UpdateChannel.DEV, installedVersionCode = 70),
)
}
@Test
fun `nothing published yet says so rather than failing silently`() {
val verdict = verdictFor(null, UpdateChannel.DEV, installedVersionCode = 69)
assertTrue(verdict is UpdateVerdict.Refused)
assertTrue(
"the reason should name the channel: ${(verdict as UpdateVerdict.Refused).reason}",
verdict.reason.contains("dev"),
)
}
/**
* The two sequences genuinely overlap — production is on 40 while dev is on
* 69 — so a build's own number is meaningless on the other channel. This is
* the case the old channel picker could produce and this one cannot.
*/
@Test
fun `overlapping version numbers never cross the channels`() {
listOf(1, 40, 69, 200).forEach { installed ->
assertTrue(
"production build offered to a dev install on $installed",
verdictFor(productionBuild, UpdateChannel.DEV, installed)
is UpdateVerdict.Refused,
)
assertTrue(
"dev build offered to a production install on $installed",
verdictFor(devBuild, UpdateChannel.PRODUCTION, installed)
is UpdateVerdict.Refused,
)
}
}
}