android: say nothing until the update is downloaded, and only fetch on wifi
Both corrections to what I built, and the second changes the first. NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it appearing as soon as a build was FOUND, with Install downloading on demand — which turns one tap into an unplanned download, and is exactly the surprise the wifi gate was meant to avoid. Off wifi the app now stays quiet and picks it up later. ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI alone would download over a tethered hotspot, which is mobile data wearing a different hat and the precise bill this avoids. It now requires both. Found while making the first change: gating the nag on `ready` broke the nag. The background path returns early once a build is fetched, so `nagDismissed` would never be cleared again and a single "Later" would have silenced the update permanently — the exact "lost" this whole path exists to prevent. Coming forward with a fetched build now clears the dismissal instead of returning. Also: a build found off wifi retries its FETCH on the next foreground rather than waiting out the six-hour check interval. Found on the train, downloaded at home. The banner loses its two-state text with the change, and BoardUpdate loses `ready` — it is implied now. It stays visible while installing, deliberately: that is the one moment it has something to report, and hiding it would look like the tap did nothing.
This commit is contained in:
@@ -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