android: find updates without being asked, fetch them, then nag
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Android / Kotlin + Rust (APK) (push) Failing after 4m29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s

`check()` had exactly one caller: a button on the sync screen. So a new build was
found only by someone who went looking for one — and having to remember to go
looking is the same as not being told. The operator has been doing that by hand
every time.

Three parts.

FIND. The app checks when it comes forward, which is the moment the person is
present. Rate-limited to six hours in the view model, so flicking between two apps
is not a re-check, and skipped entirely on an unlinked device — updates come from a
linked server and there is nothing to ask. Same ForegroundTransitions shape as
AutomaticSync, for the same reason.

FETCH. Finding one downloads it, 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, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE
permission — normal, no prompt). On a metered link the update is still found and
still nags; Install downloads it then, which is a choice rather than a surprise.

NAG. A banner on the board, under the error banners — an update is worth saying and
never worth saying before a note failed to save. "Later" clears it for this sitting
only: the next time the app comes forward the check finds the same build and says so
again. That is the difference between a reminder and a notice you can lose.

downloadAndInstall now skips the download when the background fetch already did it,
so the sync screen's button and the banner's are the same action with the same
name — whether the bytes are already there is this class's problem, not the
person's.
This commit is contained in:
2026-08-26 08:34:28 -04:00
parent 68f851110f
commit ee47a61270
7 changed files with 253 additions and 8 deletions
+3
View File
@@ -7,6 +7,9 @@
this permission never exercised.
-->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Only to answer "is this connection metered?" before the app downloads its own
update in the background. Normal permission, no prompt, no location. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--
Four more permissions are NOT declared here and still reach the merged
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
@@ -63,6 +64,22 @@ object AppUpdate {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
/** Where a download goes: app-private, so no storage permission is involved. */
/**
* Whether this is a network to spend fifty-odd megabytes on without being asked.
*
* 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.
*
* A missing ConnectivityManager reads as metered: the cautious answer is the one
* that costs nothing.
*/
fun onUnmeteredNetwork(context: Context): Boolean {
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
return !manager.isActiveNetworkMetered
}
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
/**
@@ -25,6 +25,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardSync
import com.fabledsword.thoughtsync.ui.BoardUpdate
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
@@ -154,6 +155,7 @@ private fun App(
var automatic by remember { mutableStateOf(settings.automatic) }
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
val editing = board.state.editing
val screen =
@@ -219,6 +221,20 @@ private fun App(
onSearch = board::search,
onCompose = board::compose,
onToggleItem = board::toggleItem,
// Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret.
update =
update.state.available
?.takeIf { update.state.nagging }
?.let {
BoardUpdate(
version = it.version,
ready = update.state.ready,
busy = update.state.busy,
onInstall = update::downloadAndInstall,
onDismiss = update::dismissNag,
)
},
onDismissError = board::dismissError,
)
}
@@ -271,6 +287,36 @@ private fun ReminderAlarms(core: ThoughtSync) {
}
}
/**
* Looking for an app update without being asked.
*
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
* So a new build was found only by someone who went looking for one, and the operator
* had to remember to go looking — which is the same as not being told.
*
* On coming forward rather than on a timer: it is the moment the person is present,
* and the view model rate-limits so flicking between two apps is not a re-check.
* Unlinked devices are skipped entirely — updates come from a linked server, and
* there is nothing to ask.
*/
@Composable
private fun AutomaticUpdate(
linked: Boolean,
onCheck: () -> Unit,
) {
var wanted by remember { mutableStateOf(false) }
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
LaunchedEffect(wanted, linked) {
if (!wanted || !linked) return@LaunchedEffect
// Consumed here, so this fires once per trip to the foreground however many
// times the effect restarts. There is no suspension point before the call, so
// the block completes before the recomposition that would cancel it.
wanted = false
onCheck()
}
}
/**
* Syncing without being asked.
*
@@ -65,6 +65,7 @@ fun BoardScreen(
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
update: BoardUpdate?,
onDismissError: () -> Unit,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -118,6 +119,18 @@ fun BoardScreen(
ErrorBanner(message = message, onDismiss = sync.onDismissError)
}
// Below the failures and above the notes: an update is worth saying,
// and never worth saying before a note failed to save.
update?.let {
UpdateBanner(
version = it.version,
ready = it.ready,
busy = it.busy,
onInstall = it.onInstall,
onDismiss = it.onDismiss,
)
}
// Only where someone is already thinking about reminders. On the
// main board it would nag people who have never set one.
if (state.destination == Destination.Reminders) ReminderNotice()
@@ -187,6 +200,26 @@ data class BoardSync(
val onDismissError: () -> Unit,
)
/**
* The waiting app update, or null when there is nothing to say.
*
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
* `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.
*/
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. */
val busy: Boolean,
val onInstall: () -> Unit,
val onDismiss: () -> Unit,
)
/**
* A search field IS the top bar, following the phone convention rather than the
* desktop's title-plus-sidebar.
@@ -1,18 +1,27 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -113,6 +122,62 @@ fun UpdateCard(
}
}
/**
* The nag: an update is waiting, said where someone will actually see it.
*
* Until this existed the only way to learn about a new build was to open the sync
* screen and press Check — so the updates that got installed were the ones somebody
* went looking for, and the rest were simply never found.
*
* 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.
*/
@Composable
fun UpdateBanner(
version: String,
ready: Boolean,
busy: Boolean,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
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,
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (busy) {
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
Spacer(Modifier.size(12.dp))
} else {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
}
}
}
private val BANNER_RADIUS = 12.dp
private val BANNER_SPINNER = 18.dp
/**
* The one line an unlinked device gets.
*
@@ -24,10 +24,23 @@ data class UpdateState(
val available: ClientUpdate? = null,
/** A check completed and found nothing. Distinct from "not checked yet". */
val upToDate: Boolean = false,
/** The available build has been fetched and is sitting in the cache. */
val ready: Boolean = false,
val downloading: Boolean = false,
val working: Boolean = false,
val error: String? = null,
/** The banner has been waved away — until the app next comes forward. */
val nagDismissed: Boolean = false,
) {
val busy: Boolean get() = checking || working
val busy: Boolean get() = checking || downloading || working
/**
* 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.
*/
val nagging: Boolean get() = available != null && !nagDismissed && !working
}
/**
@@ -53,28 +66,82 @@ class UpdateViewModel(
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
private set
/** Ask the linked server what it has. */
fun check() {
/** When the last check ran, so coming back to the app twice in a minute is one. */
private var lastCheckAt = 0L
/** Ask the linked server what it has. The Check button on the sync screen. */
fun check() = runCheck(fetch = false)
/**
* The automatic path: look, fetch, then nag.
*
* Called when the app comes forward. Until this existed an update was only ever
* found by someone opening the sync screen and pressing a button — so the ones
* that mattered were the ones nobody went looking for.
*
* Skipped when a check is already in flight, when a build is already waiting, and
* when one ran recently: flicking between two apps is not a request to re-check.
*/
fun checkInBackground() {
val now = System.currentTimeMillis()
if (state.busy || state.ready || now - lastCheckAt < CHECK_INTERVAL_MS) return
lastCheckAt = now
runCheck(fetch = true)
}
private fun runCheck(fetch: Boolean) {
viewModelScope.launch {
state = state.copy(checking = true, error = null, upToDate = false)
state =
try {
val found = core.clientUpdate(state.installedVersion)
state.copy(checking = false, available = found, upToDate = found == null)
state.copy(
checking = false,
available = found,
upToDate = found == null,
// A build that is still there is worth mentioning again. The
// dismissal was for that sitting, not for this version.
nagDismissed = if (found == null) state.nagDismissed else false,
)
} catch (e: Exception) {
// Broad by intent, as everywhere the core is called: it reports
// every failure as one error type carrying a message written to
// 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)) {
download()
}
}
}
/** Fetch the waiting build into the cache, leaving it for [install]. */
private suspend fun download() {
state = state.copy(downloading = true, error = null)
state =
try {
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
state.copy(downloading = false, ready = true)
} catch (e: Exception) {
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
}
}
/** Stop nagging until the app next comes forward and finds it again. */
fun dismissNag() {
state = state.copy(nagDismissed = true)
}
/**
* Download the update and hand it to the system installer.
* Hand the update to the system installer, downloading first if it is not already
* in the cache.
*
* One action rather than two buttons: nobody wants a downloaded APK sitting
* around as an intermediate state they have to think about.
* Still one action from the outside. A downloaded APK is not a state anyone wants
* to think about, so whether the fetch already happened in the background is this
* class's problem rather than the person's.
*/
fun downloadAndInstall() {
viewModelScope.launch {
@@ -83,7 +150,7 @@ class UpdateViewModel(
val failure =
try {
val target = AppUpdate.downloadTarget(context)
core.downloadClientUpdate(target.absolutePath)
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
// Off the main thread: this streams ~55 MiB into the session.
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
} catch (e: Exception) {
@@ -126,3 +193,14 @@ class UpdateViewModel(
}
private const val FALLBACK = "The update couldn't be checked."
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
/**
* How long a background check stays good for.
*
* Long enough that switching to another app and back is not a re-check; short enough
* that a build published this morning is offered today. The same reasoning as sync's
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
* something that must not get lost.
*/
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
@@ -120,6 +120,9 @@
<string name="update_current">You\'re on the newest build this server has.</string>
<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>
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>