Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
7 changed files with 253 additions and 8 deletions
Showing only changes of commit ee47a61270 - Show all commits
+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>