Make "check on launch" actually check on launch
The switch has been in Settings since the updater landed and has never been read by anything: there was no launch-time check to turn on or off. It is now wired to one. It speaks only when there is a build to install. An app that is already current says nothing, and neither does one that could not reach GitHub — opening the app is not asking a question, and a phone with no signal should not be told off for it once a launch. The button in Settings *is* a question and still answers every time, up to date and failures alike. That rule is `launchOffer`, next to the channel rule and tested with it. Two things it is careful about: The setting is read straight from the store rather than from the state the screens are drawn with. That state begins as the defaults — where checking is on — and is replaced a moment later by what is stored, so a check driven off it would have fired on the default before ever learning it had been switched off. The prompt waits for the splash to clear before appearing, while the check itself runs behind it. The look costs no time and cannot land on top of the tartan. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
This commit is contained in:
@@ -97,6 +97,12 @@ it: every rule above is a pure function and unit-tested on the JVM.
|
|||||||
builds from `dev`. The settings screen states which one is running rather than
|
builds from `dev`. The settings screen states which one is running rather than
|
||||||
offering a switch.
|
offering a switch.
|
||||||
|
|
||||||
|
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
|
Wanting both means installing both — they are separate apps and each updates
|
||||||
itself, see [Both channels on one device](#both-channels-on-one-device).
|
itself, see [Both channels on one device](#both-channels-on-one-device).
|
||||||
|
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -65,6 +65,19 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel =
|
|||||||
fun versionCodeOfTag(tag: String): Int? =
|
fun versionCodeOfTag(tag: String): Int? =
|
||||||
tag.substringAfterLast('.').removeSuffix(DEV_SUFFIX).toIntOrNull()
|
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
|
* Whether [latest] should be offered to a build of [installedChannel] at
|
||||||
* [installedVersionCode].
|
* [installedVersionCode].
|
||||||
|
|||||||
@@ -117,6 +117,27 @@ class UpdatePolicyTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- 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
|
* 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
|
* 69 — so a build's own number is meaningless on the other channel. This is
|
||||||
|
|||||||
Reference in New Issue
Block a user