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.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.Settings
@@ -64,19 +65,25 @@ object AppUpdate {
.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
* over mobile data is a bill nobody agreed to. On a metered link the update is
* still FOUND and still nags — pressing Install downloads it then, which is a
* choice rather than a surprise.
* The app fetches its own update in the background, and fifty-odd megabytes over
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
* update is found, nothing is downloaded, and nothing is said until it can be.
*
* A missing ConnectivityManager reads as metered: the cautious answer is the one
* that costs nothing.
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
* 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
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. */
@@ -229,7 +229,6 @@ private fun App(
?.let {
BoardUpdate(
version = it.version,
ready = update.state.ready,
busy = update.state.busy,
onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag,
@@ -124,7 +124,6 @@ fun BoardScreen(
update?.let {
UpdateBanner(
version = it.version,
ready = it.ready,
busy = it.busy,
onInstall = it.onInstall,
onDismiss = it.onDismiss,
@@ -207,14 +206,13 @@ data class BoardSync(
* `version` and a pair of booleans as positional arguments could be swapped with
* nothing to catch it.
*
* Null covers every reason there is nothing to show — unlinked, up to date, already
* dismissed for this sitting, mid-install — so the board never has to know which.
* Null covers every reason there is nothing to show — unlinked, up to date, found but
* not yet downloaded, dismissed for this sitting — so the board never has to know
* which.
*/
data class BoardUpdate(
val version: String,
/** Already fetched, so Install is one tap rather than a wait. */
val ready: Boolean,
/** A check, fetch or install is in flight. */
/** An install is in flight — the banner stays and reports it. */
val busy: Boolean,
val onInstall: () -> Unit,
val onDismiss: () -> Unit,
@@ -129,14 +129,17 @@ fun UpdateCard(
* screen and press Check — so the updates that got installed were the ones somebody
* 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
* the app comes forward the background check finds the same build and says so again.
* That is the difference between a reminder and a notice you can lose.
* the app comes forward it says so again. That is the difference between a reminder
* and a notice you can lose.
*/
@Composable
fun UpdateBanner(
version: String,
ready: Boolean,
busy: Boolean,
onInstall: () -> Unit,
onDismiss: () -> Unit,
@@ -155,13 +158,7 @@ fun UpdateBanner(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// Two sentences for two states: fetched already, or waiting to be. The
// 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,
),
text = stringResource(R.string.update_banner_ready, version),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
@@ -37,10 +37,15 @@ data class UpdateState(
/**
* Worth interrupting the board for.
*
* Not gated on [ready]: on a metered connection nothing is downloaded in advance,
* and an update nobody is told about is worse than one that costs a tap to fetch.
* Gated on [ready], so the banner never appears until the bytes are on disk. An
* 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.
*/
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()
if (state.busy || state.ready || now - lastCheckAt < CHECK_INTERVAL_MS) return
if (now - lastCheckAt < CHECK_INTERVAL_MS) return
lastCheckAt = now
runCheck(fetch = true)
}
@@ -109,10 +134,10 @@ class UpdateViewModel(
// be read, and a failed check must not take the screen down.
state.copy(checking = false, error = e.message ?: FALLBACK)
}
// Fetched in advance so the nag is a one-tap install rather than the start
// of a wait. Not over mobile data: fifty-odd megabytes is a bill nobody
// agreed to, and on a metered link the Install button downloads instead.
if (fetch && state.available != null && AppUpdate.onUnmeteredNetwork(context)) {
// Fetched before anything is said, so the banner is a one-tap install
// rather than the start of a wait. Off wifi this simply does not happen
// and the app stays quiet — the next foreground on wifi picks it up.
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
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() {
state = state.copy(nagDismissed = true)
}
@@ -121,7 +121,6 @@
<string name="update_check">Check for an 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_available">Build %1$s is available.</string>
<string name="update_later">Later</string>
<string name="update_failed_title">The update didn\'t install</string>
<string name="update_permission_title">Android needs your permission</string>