Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
6 changed files with 61 additions and 36 deletions
Showing only changes of commit 1e2b42af25 - Show all commits
@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.IntentSender import android.content.IntentSender
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
@@ -64,19 +65,25 @@ object AppUpdate {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** /**
* Whether this is a network to spend fifty-odd megabytes on without being asked. * Whether this is wifi somebody is not paying by the megabyte for.
* *
* The update fetches itself in the background once one is found, and doing that * The app fetches its own update in the background, and fifty-odd megabytes over
* over mobile data is a bill nobody agreed to. On a metered link the update is * mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
* still FOUND and still nags — pressing Install downloads it then, which is a * update is found, nothing is downloaded, and nothing is said until it can be.
* choice rather than a surprise.
* *
* A missing ConnectivityManager reads as metered: the cautious answer is the one * BOTH conditions, deliberately. Wifi alone would still download over a tethered
* that costs nothing. * hotspot, which is mobile data wearing a different hat and the exact bill this
* avoids. Unmetered alone would download over an unmetered cellular plan, which
* is not what "on wifi" means to the person who asked for it.
*
* Every uncertain answer is `false`: the cautious one costs nothing.
*/ */
fun onUnmeteredNetwork(context: Context): Boolean { fun onWifi(context: Context): Boolean {
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
return !manager.isActiveNetworkMetered val active = manager.activeNetwork ?: return false
val caps = manager.getNetworkCapabilities(active) ?: return false
return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
} }
/** Where a download goes: app-private, so no storage permission is involved. */ /** Where a download goes: app-private, so no storage permission is involved. */
@@ -229,7 +229,6 @@ private fun App(
?.let { ?.let {
BoardUpdate( BoardUpdate(
version = it.version, version = it.version,
ready = update.state.ready,
busy = update.state.busy, busy = update.state.busy,
onInstall = update::downloadAndInstall, onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag, onDismiss = update::dismissNag,
@@ -124,7 +124,6 @@ fun BoardScreen(
update?.let { update?.let {
UpdateBanner( UpdateBanner(
version = it.version, version = it.version,
ready = it.ready,
busy = it.busy, busy = it.busy,
onInstall = it.onInstall, onInstall = it.onInstall,
onDismiss = it.onDismiss, onDismiss = it.onDismiss,
@@ -207,14 +206,13 @@ data class BoardSync(
* `version` and a pair of booleans as positional arguments could be swapped with * `version` and a pair of booleans as positional arguments could be swapped with
* nothing to catch it. * nothing to catch it.
* *
* Null covers every reason there is nothing to show — unlinked, up to date, already * Null covers every reason there is nothing to show — unlinked, up to date, found but
* dismissed for this sitting, mid-install — so the board never has to know which. * not yet downloaded, dismissed for this sitting — so the board never has to know
* which.
*/ */
data class BoardUpdate( data class BoardUpdate(
val version: String, val version: String,
/** Already fetched, so Install is one tap rather than a wait. */ /** An install is in flight — the banner stays and reports it. */
val ready: Boolean,
/** A check, fetch or install is in flight. */
val busy: Boolean, val busy: Boolean,
val onInstall: () -> Unit, val onInstall: () -> Unit,
val onDismiss: () -> Unit, val onDismiss: () -> Unit,
@@ -129,14 +129,17 @@ fun UpdateCard(
* screen and press Check — so the updates that got installed were the ones somebody * screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found. * went looking for, and the rest were simply never found.
* *
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
* than the start of a wait — and so nothing is said at all until the app has been on
* wifi, which is where the fetch happens.
*
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time * Dismissible, but not permanently. "Later" clears it for this sitting; the next time
* the app comes forward the background check finds the same build and says so again. * the app comes forward it says so again. That is the difference between a reminder
* That is the difference between a reminder and a notice you can lose. * and a notice you can lose.
*/ */
@Composable @Composable
fun UpdateBanner( fun UpdateBanner(
version: String, version: String,
ready: Boolean,
busy: Boolean, busy: Boolean,
onInstall: () -> Unit, onInstall: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -155,13 +158,7 @@ fun UpdateBanner(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
// Two sentences for two states: fetched already, or waiting to be. The text = stringResource(R.string.update_banner_ready, version),
// button is the same either way — the difference is how long it takes.
text =
stringResource(
if (ready) R.string.update_banner_ready else R.string.update_banner_available,
version,
),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -37,10 +37,15 @@ data class UpdateState(
/** /**
* Worth interrupting the board for. * Worth interrupting the board for.
* *
* Not gated on [ready]: on a metered connection nothing is downloaded in advance, * Gated on [ready], so the banner never appears until the bytes are on disk. An
* and an update nobody is told about is worse than one that costs a tap to fetch. * update that has been FOUND is not news anyone can act on quickly — offering it
* off wifi would turn one tap into a download somebody did not plan.
*
* Deliberately still true while [working]: the install is the one moment the
* banner has something to report, and hiding it there would look like the tap
* did nothing.
*/ */
val nagging: Boolean get() = available != null && !nagDismissed && !working val nagging: Boolean get() = ready && available != null && !nagDismissed
} }
/** /**
@@ -83,8 +88,28 @@ class UpdateViewModel(
* when one ran recently: flicking between two apps is not a request to re-check. * when one ran recently: flicking between two apps is not a request to re-check.
*/ */
fun checkInBackground() { fun checkInBackground() {
if (state.busy) return
// Already fetched and waved away — say so again. "Later" is for that sitting,
// not forever, and without this the early return below would mean a single
// dismissal silenced the update permanently. Which is precisely the "lost"
// this whole path exists to prevent.
if (state.ready) {
if (state.nagDismissed) state = state.copy(nagDismissed = false)
return
}
// Already found one and never fetched it — almost always because the last look
// happened on mobile data. Retry the FETCH rather than the check, and ignore
// the interval: this is what makes an update found on the train arrive when
// the person gets home, instead of waiting out six hours first.
if (state.available != null) {
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
return
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (state.busy || state.ready || now - lastCheckAt < CHECK_INTERVAL_MS) return if (now - lastCheckAt < CHECK_INTERVAL_MS) return
lastCheckAt = now lastCheckAt = now
runCheck(fetch = true) runCheck(fetch = true)
} }
@@ -109,10 +134,10 @@ class UpdateViewModel(
// be read, and a failed check must not take the screen down. // be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK) state.copy(checking = false, error = e.message ?: FALLBACK)
} }
// Fetched in advance so the nag is a one-tap install rather than the start // Fetched before anything is said, so the banner is a one-tap install
// of a wait. Not over mobile data: fifty-odd megabytes is a bill nobody // rather than the start of a wait. Off wifi this simply does not happen
// agreed to, and on a metered link the Install button downloads instead. // and the app stays quiet — the next foreground on wifi picks it up.
if (fetch && state.available != null && AppUpdate.onUnmeteredNetwork(context)) { if (fetch && state.available != null && AppUpdate.onWifi(context)) {
download() download()
} }
} }
@@ -130,7 +155,7 @@ class UpdateViewModel(
} }
} }
/** Stop nagging until the app next comes forward and finds it again. */ /** Stop nagging for this sitting. The next trip to the foreground says it again. */
fun dismissNag() { fun dismissNag() {
state = state.copy(nagDismissed = true) state = state.copy(nagDismissed = true)
} }
@@ -121,7 +121,6 @@
<string name="update_check">Check for an update</string> <string name="update_check">Check for an update</string>
<string name="update_install">Update</string> <string name="update_install">Update</string>
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string> <string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
<string name="update_banner_available">Build %1$s is available.</string>
<string name="update_later">Later</string> <string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string> <string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string> <string name="update_permission_title">Android needs your permission</string>