Merge pull request #14: each channel updates only from itself

The update channel is now a property of the installed APK rather than a
setting that could point a phone at the other channel's builds, and the
"check on launch" switch is wired to a check that speaks only when there
is something to install.
This commit is contained in:
adrianoftyriel
2026-08-07 21:37:07 -06:00
committed by GitHub
8 changed files with 467 additions and 156 deletions
+33 -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,20 @@ 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).
The check on launch **only ever speaks when there is a build to install**. It
runs behind the splash and waits for it to clear, and an app that is already
current — or a phone that cannot reach GitHub — says nothing, because opening
the app was not a question. The button in Settings is a question, so it
answers every time, including "you're on the latest" and whatever went wrong.
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 +191,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 +247,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 +287,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 +347,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
```
@@ -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 }
}
@@ -129,6 +129,11 @@ fun AppRoot(env: AppEnv) {
if (!splashDone) {
SplashScreen(onFinished = { splashDone = true })
}
// Looks while the splash is up and waits until it has gone before
// saying anything, so the check costs nothing in time and cannot
// interrupt the opening of the app.
LaunchUpdateCheck(env = env, visible = splashDone)
}
}
@@ -0,0 +1,97 @@
package org.prolibertate.games.ui
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.prolibertate.games.update.Release
import org.prolibertate.games.update.launchOffer
/**
* The check on launch, and the only thing it is allowed to say.
*
* Runs once per start of the app, if the setting is on, and speaks only when
* there is a build to install — see [launchOffer]. Everything else is silence:
* an up-to-date app has nothing to report, and a phone with no network has
* nothing to apologise for.
*
* It reads the setting straight out of the store rather than from the state the
* screens are drawn with. That state starts life as the defaults and is
* replaced a moment later by what is actually stored, so a check driven off it
* would fire on the default — on — before ever learning the setting had been
* turned off. This waits for the real value instead.
*/
@Composable
fun LaunchUpdateCheck(env: AppEnv, visible: Boolean) {
val scope = rememberCoroutineScope()
var release by remember { mutableStateOf<Release?>(null) }
var dismissed by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// Kept apart from [status] on purpose. Inferring "still working" from there
// being a message meant a failed download — which leaves its reason on
// screen — disabled both buttons for good, and the dialog could not be shut.
var working by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val stored = env.settingsRepository.settings.first()
if (!stored.checkForUpdatesOnLaunch) return@LaunchedEffect
release = launchOffer(env.updater.check())
}
// Held back behind the splash, so an update prompt cannot appear over the
// tartan before the app itself has.
val pending = release
if (pending == null || dismissed || !visible) return
AlertDialog(
onDismissRequest = { if (!working) dismissed = true },
title = { Text("Update available") },
text = {
Text(
text = status ?: "${pending.tag} is out on the " +
"${pending.channel.label.lowercase()} channel. You're on " +
"v${env.updater.installedVersionName()}.",
style = MaterialTheme.typography.bodyMedium,
)
},
confirmButton = {
TextButton(
enabled = !working,
onClick = {
scope.launch {
working = true
if (env.updater.needsInstallPermission()) {
// Android has to be told this app may install one
// first. Say so and stand down: they will be looking
// at a system screen, not at this.
status = "Allow installs from this app, then check " +
"again in Settings."
env.updater.requestInstallPermission()
} else {
status = "Downloading ${pending.tag}…"
val error = env.updater.downloadAndInstall(pending)
status = error ?: "Handing over to the installer…"
}
working = false
}
},
) { Text("Install") }
},
dismissButton = {
TextButton(
enabled = !working,
onClick = { dismissed = true },
) { Text("Not now") }
},
)
}
@@ -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,112 @@
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()
/**
* The build a check on launch should interrupt somebody for, or null to say
* nothing at all.
*
* Only an actual update is worth a word. Opening the app is not asking a
* question, so a check that finds nothing — or that cannot reach GitHub at all
* — stays quiet; a phone on a train would otherwise be told off for it every
* single launch. The button in Settings *is* asking a question, and reports
* every answer including those two.
*/
fun launchOffer(verdict: UpdateVerdict): Release? =
(verdict as? UpdateVerdict.Install)?.release
/**
* 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) {
@@ -0,0 +1,161 @@
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"),
)
}
// -- What a check on launch is allowed to say ---------------------------
@Test
fun `a check on launch speaks only when there is something to install`() {
assertEquals(devBuild, launchOffer(UpdateVerdict.Install(devBuild)))
}
@Test
fun `a check on launch says nothing when there is nothing to say`() {
assertNull("an up-to-date app has no business interrupting", launchOffer(UpdateVerdict.UpToDate))
assertNull(
"a phone with no network has nothing to apologise for",
launchOffer(UpdateVerdict.Refused("Unable to resolve host api.github.com")),
)
// Including the refusal that this whole change exists to produce: being
// shown the other channel's build is not a thing to be told about.
assertNull(
launchOffer(verdictFor(productionBuild, UpdateChannel.DEV, installedVersionCode = 1)),
)
}
/**
* 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,
)
}
}
}