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