diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt index 88043da..1c61ae6 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index b587073..45c061a 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index 91e1cd5..1efc831 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateCard.kt index de895cf..b28a82d 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateCard.kt @@ -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), ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateViewModel.kt index 7525fc2..7e21e12 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateViewModel.kt @@ -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) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8d4ac34..5f40083 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -121,7 +121,6 @@ Check for an update Update Build %1$s is downloaded and ready. - Build %1$s is available. Later The update didn\'t install Android needs your permission