Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user