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:
@@ -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
|
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
|
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
|
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
|
APK's own version name — the same source the updater uses to know which channel
|
||||||
for future updates.
|
it is on.
|
||||||
|
|
||||||
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
Adding a game means writing a `GameRules` implementation, a `GameAi` and a
|
||||||
screen, then flipping `available` — the menu, lobby, networking, settings and OTA
|
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.
|
- **Sound** on/off.
|
||||||
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
|
- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long
|
||||||
the computer appears to think.
|
the computer appears to think.
|
||||||
- **Updates** — check on launch, or check on demand, from either of two
|
- **Updates** — check on launch, or check on demand. **There is no channel to
|
||||||
channels:
|
choose**: a build updates from its own channel and no other. A production
|
||||||
- **Production** — stable builds, published from `main`.
|
install is offered production builds from `main`; a dev install is offered dev
|
||||||
- **Dev** — preview builds, published from `dev` on every green CI run.
|
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
|
The check on launch **only ever speaks when there is a build to install**. It
|
||||||
[Both channels on one device](#both-channels-on-one-device).
|
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
|
## 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
|
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
|
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
|
being handed a dev build. A dev build is reached by the in-app updater of a dev
|
||||||
the channel set to dev, or from the releases page.
|
install — which looks at the prerelease list rather than that endpoint — or from
|
||||||
|
the releases page.
|
||||||
|
|
||||||
### The version series
|
### 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`
|
**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
|
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
|
separately. Dev is on 69 while production is on 40, so the numbers say nothing
|
||||||
reads the `-dev` suffix in its own `versionName` to know which channel it is on,
|
across the divide.
|
||||||
and treats a change of channel as an explicit switch rather than an upgrade.
|
|
||||||
|
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
|
### 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
|
vectors serves both colourways. `BuildConfig.DEV_BUILD` carries the same flag
|
||||||
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
into Kotlin, where `AppSett` picks the matching Compose sett for the splash.
|
||||||
|
|
||||||
Switching channels in Settings therefore installs the other channel's app
|
Because they are separate apps, having both is a matter of installing both, and
|
||||||
alongside this one and leaves this one in place; the settings screen says so
|
each then updates itself down its own line. Nothing in the app moves a phone
|
||||||
when the selected channel is not the installed one.
|
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
|
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
|
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
|
net/ wire protocol, LAN transport, lobby, match driver
|
||||||
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
score/ scorekeeper sheet — pure Kotlin, unit-tested — and its store
|
||||||
settings/ DataStore-backed preferences
|
settings/ DataStore-backed preferences
|
||||||
update/ GitHub Releases OTA updater
|
update/ GitHub Releases OTA updater, and the channel rule — pure, tested
|
||||||
ui/ Compose screens
|
ui/ Compose screens
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
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.
|
* snapped back before a real name could be typed.
|
||||||
*/
|
*/
|
||||||
val playerName: String = "",
|
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
|
* 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 SPEED = floatPreferencesKey("animation_speed")
|
||||||
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch")
|
||||||
val NAME = stringPreferencesKey("player_name")
|
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 ->
|
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,
|
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
|
||||||
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
|
||||||
playerName = prefs[Keys.NAME] ?: "",
|
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) {
|
suspend fun setSoundEnabled(enabled: Boolean) {
|
||||||
context.dataStore.edit { it[Keys.SOUND] = enabled }
|
context.dataStore.edit { it[Keys.SOUND] = enabled }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ fun AppRoot(env: AppEnv) {
|
|||||||
if (!splashDone) {
|
if (!splashDone) {
|
||||||
SplashScreen(onFinished = { splashDone = true })
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
@@ -26,8 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
|
import org.prolibertate.games.update.Release
|
||||||
import org.prolibertate.games.update.UpdateChannel
|
import org.prolibertate.games.update.UpdateChannel
|
||||||
import org.prolibertate.games.update.Updater
|
import org.prolibertate.games.update.UpdateVerdict
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -42,7 +42,7 @@ fun SettingsScreen(
|
|||||||
// caret jumped and a cleared field refilled itself.
|
// caret jumped and a cleared field refilled itself.
|
||||||
var editedName by remember { mutableStateOf<String?>(null) }
|
var editedName by remember { mutableStateOf<String?>(null) }
|
||||||
var updateStatus 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 ->
|
ScreenScaffold(title = "Settings", onBack = onBack) { modifier ->
|
||||||
Column(
|
Column(
|
||||||
@@ -111,45 +111,32 @@ fun SettingsScreen(
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
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 {
|
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(
|
||||||
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,
|
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(
|
SettingRow(
|
||||||
@@ -166,26 +153,20 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
PrimaryAction(text = "Check for updates now") {
|
PrimaryAction(text = "Check for updates now") {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
updateStatus = "Checking the ${settings.updateChannel.label} channel…"
|
updateStatus = "Checking the ${channel.label} channel…"
|
||||||
when (val result = env.updater.check(settings.updateChannel)) {
|
when (val verdict = env.updater.check()) {
|
||||||
is Updater.Result.UpToDate -> {
|
is UpdateVerdict.UpToDate -> {
|
||||||
updateStatus =
|
updateStatus = "You're on the latest ${channel.label} build."
|
||||||
"You're on the latest ${settings.updateChannel.label} build."
|
|
||||||
pendingRelease = null
|
pendingRelease = null
|
||||||
}
|
}
|
||||||
|
|
||||||
is Updater.Result.Available -> {
|
is UpdateVerdict.Install -> {
|
||||||
pendingRelease = result.release
|
pendingRelease = verdict.release
|
||||||
updateStatus = if (result.isChannelSwitch) {
|
updateStatus = "Version ${verdict.release.tag} is available."
|
||||||
"${result.release.tag} is the current " +
|
|
||||||
"${result.release.channel.label} build."
|
|
||||||
} else {
|
|
||||||
"Version ${result.release.tag} is available."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is Updater.Result.Failed -> {
|
is UpdateVerdict.Refused -> {
|
||||||
updateStatus = result.reason
|
updateStatus = verdict.reason
|
||||||
pendingRelease = null
|
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.HttpURLConnection
|
||||||
import java.net.URL
|
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.
|
* 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.
|
* 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
|
* The repository's releases must be publicly downloadable — no token is
|
||||||
* embedded in the app, and GitHub blocks anonymous access to private assets.
|
* 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) {
|
class Updater(private val activity: Activity) {
|
||||||
|
|
||||||
data class Release(
|
/**
|
||||||
val tag: String,
|
* Looks for a newer build of whatever is installed.
|
||||||
val versionCode: Int,
|
*
|
||||||
val apkUrl: String,
|
* Takes no channel: asking for one would be asking which line of builds
|
||||||
val apkName: String,
|
* this phone is on, and the phone already knows.
|
||||||
val channel: UpdateChannel,
|
*/
|
||||||
)
|
suspend fun check(): UpdateVerdict = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
val latest = fetchLatestRelease(channel)
|
val channel = installedChannel()
|
||||||
?: return@withContext Result.Failed(
|
verdictFor(
|
||||||
"No ${channel.label.lowercase()} build has been published yet."
|
latest = fetchLatestRelease(channel),
|
||||||
)
|
installedChannel = channel,
|
||||||
|
installedVersionCode = installedVersionCode(),
|
||||||
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
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} 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? {
|
private fun toRelease(json: JSONObject): Release? {
|
||||||
val tag = json.optString("tag_name").ifBlank { return null }
|
val tag = json.optString("tag_name").ifBlank { return null }
|
||||||
// Tags are v<series>.<n>, with -dev on a prerelease. Only the trailing
|
val versionCode = versionCodeOfTag(tag) ?: return null
|
||||||
// run number is read: the series is a name for people and changing it
|
val channel = channelOfTag(tag, json.optBoolean("prerelease"))
|
||||||
// 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 assets = json.optJSONArray("assets") ?: return null
|
val assets = json.optJSONArray("assets") ?: return null
|
||||||
val apks = (0 until assets.length())
|
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. */
|
/** Read back from the installed APK's own version name. */
|
||||||
fun installedChannel(): UpdateChannel =
|
fun installedChannel(): UpdateChannel = channelOfVersionName(installedVersionName())
|
||||||
if (installedVersionName().endsWith(DEV_SUFFIX)) UpdateChannel.DEV
|
|
||||||
else UpdateChannel.PRODUCTION
|
|
||||||
|
|
||||||
/** Downloads the APK and hands it to the system installer. */
|
/** Downloads the APK and hands it to the system installer. */
|
||||||
suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user