diff --git a/README.md b/README.md index 377d574..c70579e 100644 --- a/README.md +++ b/README.md @@ -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 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 itself, see [Both channels on one device](#both-channels-on-one-device). diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index c82fa4e..e7ae875 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -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) } } diff --git a/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt b/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt new file mode 100644 index 0000000..5a395ad --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/LaunchUpdateCheck.kt @@ -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(null) } + var dismissed by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(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") } + }, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt index b1eac5c..f57aff7 100644 --- a/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt +++ b/app/src/main/java/org/prolibertate/games/update/UpdatePolicy.kt @@ -65,6 +65,19 @@ fun channelOfTag(tag: String, prerelease: Boolean): UpdateChannel = 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]. diff --git a/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt index 8113c2e..9796266 100644 --- a/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt +++ b/app/src/test/java/org/prolibertate/games/update/UpdatePolicyTest.kt @@ -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 * 69 — so a build's own number is meaningless on the other channel. This is