Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user