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
@@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.prolibertate.games.update.UpdateChannel
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -32,8 +31,6 @@ data class Settings(
* snapped back before a real name could be typed.
*/
val playerName: String = "",
/** Which builds the updater offers. Production unless deliberately changed. */
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
) {
/**
* The name other players actually see. This is where the default lives, so
@@ -60,7 +57,11 @@ class SettingsRepository(private val context: Context) {
val SPEED = floatPreferencesKey("animation_speed")
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
val NAME = stringPreferencesKey("player_name")
val CHANNEL = stringPreferencesKey("update_channel")
// There is deliberately no update-channel key. The channel is a
// property of the installed APK, not a preference — an install that
// could be pointed at the other channel's builds is exactly the mix-up
// that separate applicationIds exist to prevent. Anything an older
// version left under "update_channel" is simply never read again.
}
val settings: Flow<Settings> = context.dataStore.data.map { prefs ->
@@ -69,16 +70,9 @@ class SettingsRepository(private val context: Context) {
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "",
updateChannel = prefs[Keys.CHANNEL]
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
?: UpdateChannel.PRODUCTION,
)
}
suspend fun setUpdateChannel(channel: UpdateChannel) {
context.dataStore.edit { it[Keys.CHANNEL] = channel.name }
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { it[Keys.SOUND] = enabled }
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
@@ -26,8 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
import org.prolibertate.games.update.UpdateVerdict
@Composable
fun SettingsScreen(
@@ -42,7 +42,7 @@ fun SettingsScreen(
// caret jumped and a cleared field refilled itself.
var editedName by remember { mutableStateOf<String?>(null) }
var updateStatus by remember { mutableStateOf<String?>(null) }
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
var pendingRelease by remember { mutableStateOf<Release?>(null) }
ScreenScaffold(title = "Settings", onBack = onBack) { modifier ->
Column(
@@ -111,45 +111,32 @@ fun SettingsScreen(
Divider()
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text(
text = "Installed: v${env.updater.installedVersionName()} " +
"(${env.updater.installedChannel().label} channel).",
style = MaterialTheme.typography.bodySmall,
)
// The channel is not a setting. It is read off the running APK, and
// this build updates down its own line and no other — so it is
// stated here rather than offered.
val channel = env.updater.installedChannel()
Column {
Text("Update channel", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
UpdateChannel.entries.forEach { channel ->
FilterChip(
selected = settings.updateChannel == channel,
onClick = {
scope.launch {
env.settingsRepository.setUpdateChannel(channel)
}
updateStatus = null
pendingRelease = null
},
label = { Text(channel.label) },
)
}
}
Text(
text = settings.updateChannel.blurb + ".",
text = "Installed: v${env.updater.installedVersionName()} — " +
"${channel.label} channel.",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "${channel.blurb}. This build updates from the " +
"${channel.label} channel only.",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = if (channel == UpdateChannel.DEV) {
"The production app is a separate install with its own updates. " +
"Install it from the releases page if you want both."
} else {
"Dev builds are a separate install with their own updates, from " +
"the releases page."
},
style = MaterialTheme.typography.bodySmall,
)
if (settings.updateChannel != env.updater.installedChannel()) {
// Each channel installs under its own package, so the other
// one arrives as a second app rather than replacing this
// one — no downgrade for Android to refuse.
Text(
text = "You're on a ${env.updater.installedChannel().label} build. " +
"The two channels install as separate apps, so this adds the " +
"${settings.updateChannel.label} build alongside this one and " +
"leaves it in place.",
style = MaterialTheme.typography.bodySmall,
)
}
}
SettingRow(
@@ -166,26 +153,20 @@ fun SettingsScreen(
PrimaryAction(text = "Check for updates now") {
scope.launch {
updateStatus = "Checking the ${settings.updateChannel.label} channel…"
when (val result = env.updater.check(settings.updateChannel)) {
is Updater.Result.UpToDate -> {
updateStatus =
"You're on the latest ${settings.updateChannel.label} build."
updateStatus = "Checking the ${channel.label} channel…"
when (val verdict = env.updater.check()) {
is UpdateVerdict.UpToDate -> {
updateStatus = "You're on the latest ${channel.label} build."
pendingRelease = null
}
is Updater.Result.Available -> {
pendingRelease = result.release
updateStatus = if (result.isChannelSwitch) {
"${result.release.tag} is the current " +
"${result.release.channel.label} build."
} else {
"Version ${result.release.tag} is available."
}
is UpdateVerdict.Install -> {
pendingRelease = verdict.release
updateStatus = "Version ${verdict.release.tag} is available."
}
is Updater.Result.Failed -> {
updateStatus = result.reason
is UpdateVerdict.Refused -> {
updateStatus = verdict.reason
pendingRelease = null
}
}
@@ -0,0 +1,99 @@
package org.prolibertate.games.update
/**
* Which line of builds an APK belongs to.
*
* This is a property of the build, never a preference. The two channels are
* published by different workflows and have entirely separate version
* sequences — a dev build's versionCode is its CI run number, a production
* build's is its release run number, and the two are unrelated. There is no
* meaning to be had from comparing across them, so nothing does.
*
* They also carry different applicationIds — a dev build installs as
* `…games.dev` — so both can sit on one device, each updating itself down its
* own line and leaving the other alone.
*/
enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"),
DEV("Dev", "Preview builds from the dev branch — newer, less tested"),
}
/** Marks a tag or a version name as belonging to the dev channel. */
const val DEV_SUFFIX = "-dev"
/** One published build, as read off a GitHub release. */
data class Release(
val tag: String,
val versionCode: Int,
val apkUrl: String,
val apkName: String,
val channel: UpdateChannel,
)
sealed interface UpdateVerdict {
data class Install(val release: Release) : UpdateVerdict
data object UpToDate : UpdateVerdict
data class Refused(val reason: String) : UpdateVerdict
}
/**
* The channel an installed build belongs to, read from its own version name.
*
* The APK says what it is, so this cannot disagree with what is actually
* running — which is the whole point of not storing it anywhere.
*/
fun channelOfVersionName(versionName: String): UpdateChannel =
if (versionName.endsWith(DEV_SUFFIX)) UpdateChannel.DEV else UpdateChannel.PRODUCTION
/**
* The channel a release belongs to.
*
* Two independent marks, and either is enough: the `-dev` suffix the workflow
* puts on the tag, and GitHub's own prerelease flag. A release would have to
* lose both to be mistaken for the other channel.
*/
fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
if (tag.endsWith(DEV_SUFFIX) || prerelease) UpdateChannel.DEV else UpdateChannel.PRODUCTION
/**
* The run number out of a `v<series>.<n>` tag, which is what an APK's
* versionCode is.
*
* Only the trailing number is read. The series is a name for people, and
* bumping it must never make an older build look newer.
*/
fun versionCodeOfTag(tag: String): Int? =
tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull()
/**
* Whether [latest] should be offered to a build of [installedChannel] at
* [installedVersionCode].
*
* **A build only ever updates within its own channel.** A dev install is
* offered dev builds and a production install production ones; there is no
* setting for this and no way to cross over, because crossing over is not an
* upgrade — it is a different app with an unrelated version sequence, and
* offering it as though it were newer is how a phone ends up being handed a
* build nobody meant it to have.
*
* The channel is checked here rather than trusted from the fetch, so a release
* that does not belong to this build is refused whatever the endpoint returned.
*/
fun verdictFor(
latest: Release?,
installedChannel: UpdateChannel,
installedVersionCode: Int,
): UpdateVerdict = when {
latest == null ->
UpdateVerdict.Refused("No ${installedChannel.label.lowercase()} build has been published yet.")
latest.channel != installedChannel ->
UpdateVerdict.Refused(
"${latest.tag} is a ${latest.channel.label.lowercase()} build, and this is a " +
"${installedChannel.label.lowercase()} install. The two update separately."
)
latest.versionCode > installedVersionCode -> UpdateVerdict.Install(latest)
else -> UpdateVerdict.UpToDate
}
@@ -14,27 +14,6 @@ import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Which builds the updater offers.
*
* The two channels are published by different workflows and so have entirely
* separate version sequences — a dev build's versionCode is its CI run number,
* a production build's is its release run number, and the two are unrelated.
* Versions are therefore only ever compared *within* a channel; moving between
* them is treated as an explicit switch rather than an upgrade.
*
* The two also carry different applicationIds — a dev build installs as
* `…games.dev` — so a switch installs the other channel beside this one instead
* of over it, and both can be kept on the same device.
*/
enum class UpdateChannel(val label: String, val blurb: String) {
PRODUCTION("Production", "Stable builds released from main"),
DEV("Dev", "Preview builds from the dev branch — newer, less tested"),
}
/** Marks a version name as belonging to the dev channel. */
private const val DEV_SUFFIX = "-dev"
/**
* Over-the-air updates from the repository's GitHub Releases.
*
@@ -42,50 +21,29 @@ private const val DEV_SUFFIX = "-dev"
* number, so comparing the two is enough to tell whether a newer build exists.
* The repository's releases must be publicly downloadable — no token is
* embedded in the app, and GitHub blocks anonymous access to private assets.
*
* There is nothing to choose here. The channel comes from the running APK, so
* a dev install looks only at dev builds and a production install only at
* production ones — see [verdictFor], where that rule lives and is tested.
*/
class Updater(private val activity: Activity) {
data class Release(
val tag: String,
val versionCode: Int,
val apkUrl: String,
val apkName: String,
val channel: UpdateChannel,
)
sealed interface Result {
/**
* [isChannelSwitch] means this build is from a different channel than
* the one installed, so it is an explicit move rather than a newer
* version of the same line — and, because the channels have separate
* applicationIds, it lands as a second app rather than an upgrade.
*/
data class Available(val release: Release, val isChannelSwitch: Boolean) : Result
data object UpToDate : Result
data class Failed(val reason: String) : Result
}
suspend fun check(channel: UpdateChannel): Result = withContext(Dispatchers.IO) {
/**
* Looks for a newer build of whatever is installed.
*
* Takes no channel: asking for one would be asking which line of builds
* this phone is on, and the phone already knows.
*/
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
try {
val latest = fetchLatestRelease(channel)
?: return@withContext Result.Failed(
"No ${channel.label.lowercase()} build has been published yet."
)
when {
// Different channel: version numbers are not comparable, so
// always offer it and let the user decide.
latest.channel != installedChannel() ->
Result.Available(latest, isChannelSwitch = true)
latest.versionCode > installedVersionCode() ->
Result.Available(latest, isChannelSwitch = false)
else -> Result.UpToDate
}
val channel = installedChannel()
verdictFor(
latest = fetchLatestRelease(channel),
installedChannel = channel,
installedVersionCode = installedVersionCode(),
)
} catch (e: Exception) {
Result.Failed(e.message ?: "Update check failed.")
UpdateVerdict.Refused(e.message ?: "Update check failed.")
}
}
@@ -112,19 +70,8 @@ class Updater(private val activity: Activity) {
private fun toRelease(json: JSONObject): Release? {
val tag = json.optString("tag_name").ifBlank { return null }
// Tags are v<series>.<n>, with -dev on a prerelease. Only the trailing
// run number is read: the series is a name for people and changing it
// must not make an older build look newer.
val versionCode = tag.substringAfterLast('.')
.removeSuffix(DEV_SUFFIX)
.toIntOrNull()
?: return null
val channel =
if (tag.endsWith(DEV_SUFFIX) || json.optBoolean("prerelease")) {
UpdateChannel.DEV
} else {
UpdateChannel.PRODUCTION
}
val versionCode = versionCodeOfTag(tag) ?: return null
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
val assets = json.optJSONArray("assets") ?: return null
val apks = (0 until assets.length())
@@ -178,9 +125,7 @@ class Updater(private val activity: Activity) {
}
/** Read back from the installed APK's own version name. */
fun installedChannel(): UpdateChannel =
if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV
else UpdateChannel.PRODUCTION
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
/** Downloads the APK and hands it to the system installer. */
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {