diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7f12efb..d80d757 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -64,6 +64,25 @@
declared here too because ReminderReceiver now depends on it directly. A
permission this file relies on should be visible in this file.
-->
+
+
+
+
@@ -98,6 +117,16 @@
do not survive either a reboot or an app update, so without this a phone
that restarts overnight would quietly stop reminding anyone of anything.
-->
+
+
+
diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt
new file mode 100644
index 0000000..3a64305
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt
@@ -0,0 +1,151 @@
+package com.fabledsword.thoughtsync
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageInstaller
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import android.util.Log
+import java.io.File
+
+/**
+ * Replacing this app with a newer build of itself.
+ *
+ * ## A PackageInstaller session, not an install intent
+ *
+ * The obvious route — `ACTION_VIEW` on the APK with
+ * `application/vnd.android.package-archive` — is the one on-device install
+ * heuristics are tuned against, and it is what produces the "bypassing Android
+ * security" warning the operator saw on Minstrel (Scribe note 2437). It also never
+ * tells the OS that this app is the legitimate updater of its own package, and it
+ * returns nothing: a failed install is indistinguishable from a person dismissing
+ * the dialog.
+ *
+ * A session says who is doing what. On Android 12+ it can also declare that no user
+ * action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
+ * removes the confirmation dialog entirely on the UPDATE path. Not on a first
+ * install: the OS will not let an app quietly put a NEW package on a device, which
+ * is right.
+ *
+ * Two things from that same research that are NOT done here, deliberately:
+ * `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
+ * `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
+ * too. The mechanism was the whole difference.
+ *
+ * ## The outcome comes back
+ *
+ * `commit` takes an `IntentSender`; the system reports the result to
+ * [UpdateReceiver], which is why a failure can be shown rather than guessed at.
+ */
+object AppUpdate {
+ private const val TAG = "ThoughtSyncUpdate"
+
+ /** This build's versionCode — what the server's is compared against. */
+ fun installedVersionCode(context: Context): Long =
+ runCatching {
+ context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
+ }.getOrDefault(0L)
+
+ /**
+ * Whether this app may install packages at all.
+ *
+ * A separate grant from anything in the manifest, and one only the person can
+ * give. Checked before offering an update rather than after downloading 55 MiB.
+ */
+ fun canInstall(context: Context): Boolean = context.packageManager.canRequestPackageInstalls()
+
+ /** The settings page where that grant lives, scoped to this app. */
+ fun installPermissionSettings(context: Context): Intent =
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
+ .setData(Uri.fromParts("package", context.packageName, null))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ /** Where a download goes: app-private, so no storage permission is involved. */
+ fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
+
+ /**
+ * Hand the APK to the system installer.
+ *
+ * Streamed into the session rather than passed as a path or a content URI —
+ * the session takes bytes, which is also why no FileProvider is needed here.
+ *
+ * Returns the failure to show, or null when the install was handed over
+ * successfully. "Handed over" is the honest word: the real outcome arrives
+ * later at [UpdateReceiver], because a commit that the system accepts can still
+ * fail afterwards.
+ */
+ fun install(
+ context: Context,
+ apk: File,
+ ): String? {
+ val installer = context.packageManager.packageInstaller
+ var sessionId = -1
+ return try {
+ val params =
+ PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // Only honoured on an UPDATE of an app signed with the same key —
+ // exactly our case, and the reason the signing work had to land
+ // first. Android ignores it for anything else rather than failing,
+ // so there is no need to guard on which it is.
+ params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+
+ sessionId = installer.createSession(params)
+ installer.openSession(sessionId).use { session ->
+ writeApk(session, apk)
+ session.commit(statusSender(context))
+ }
+ null
+ } catch (e: Exception) {
+ // Broad on purpose: createSession throws IOException, openWrite throws,
+ // and the framework raises SecurityException for a revoked grant. All of
+ // them mean one thing to the person — it did not install — and none of
+ // them should take the app down.
+ Log.w(TAG, "could not start the install session", e)
+ if (sessionId != -1) runCatching { installer.abandonSession(sessionId) }
+ e.message ?: "The update could not be installed."
+ }
+ }
+
+ /**
+ * Stream the APK into the session.
+ *
+ * Its own function only because the two nested `use` blocks read badly inline —
+ * and detekt agreed, which is fair: a stream inside a session inside a try is
+ * three things to hold at once.
+ */
+ private fun writeApk(
+ session: PackageInstaller.Session,
+ apk: File,
+ ) {
+ session.openWrite(WRITE_NAME, 0, apk.length()).use { out ->
+ apk.inputStream().use { it.copyTo(out) }
+ // Before close: the session must have the bytes on disk, not sitting in
+ // a buffer, or commit can be handed a short file.
+ session.fsync(out)
+ }
+ }
+
+ private fun statusSender(context: Context): IntentSender {
+ val intent =
+ Intent(context, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALLED)
+ // MUTABLE, and this is the one place it is correct: the system fills the
+ // result extras in before delivering it. An immutable one would arrive with
+ // no status at all.
+ val flags =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or
+ android.app.PendingIntent.FLAG_MUTABLE
+ } else {
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ return android.app.PendingIntent
+ .getBroadcast(context, 0, intent, flags)
+ .intentSender
+ }
+
+ private const val WRITE_NAME = "thoughtsync-update"
+}
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 33a45cd..f88d0f8 100644
--- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt
@@ -34,6 +34,7 @@ import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
+import com.fabledsword.thoughtsync.ui.UpdateViewModel
import com.fabledsword.thoughtsync.ui.olderThan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -150,6 +151,7 @@ private fun App(
var composing by rememberSaveable { mutableStateOf(false) }
var showingSync by rememberSaveable { mutableStateOf(false) }
+ val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
@@ -179,6 +181,11 @@ private fun App(
automatic = it
settings.automatic = it
},
+ update = update.state,
+ onCheckUpdate = update::check,
+ onInstallUpdate = update::downloadAndInstall,
+ onDismissUpdateError = update::dismissError,
+ onInstallOutcome = update::consumeInstallOutcome,
)
Screen.EDITOR ->
diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt
new file mode 100644
index 0000000..b027884
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt
@@ -0,0 +1,37 @@
+package com.fabledsword.thoughtsync
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+/**
+ * The last thing the system said about an install, waiting to be shown.
+ *
+ * A process-wide holder because the two ends cannot reach each other any other
+ * way: [UpdateReceiver] is constructed by the system, and the view model that
+ * wants the answer is owned by the composition. The alternative — a bound service
+ * or a broadcast the UI also listens for — is more machinery for one nullable
+ * string.
+ *
+ * Safe as snapshot state: `onReceive` runs on the main thread, which is where
+ * Compose expects its state to be written.
+ */
+object UpdateOutcome {
+ /** `error == null` means it went through, or the person declined. */
+ data class Result(
+ val error: String?,
+ )
+
+ /** Null until the system has said something about an install we committed. */
+ var latest: Result? by mutableStateOf(null)
+ private set
+
+ fun report(error: String?) {
+ latest = Result(error)
+ }
+
+ /** Called once the UI has shown it, so a later install starts from silence. */
+ fun clear() {
+ latest = null
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt
new file mode 100644
index 0000000..63e2c1c
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt
@@ -0,0 +1,77 @@
+package com.fabledsword.thoughtsync
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.util.Log
+
+/**
+ * What the system says about an install we committed.
+ *
+ * Without this the app would `commit` and learn nothing — a failed install would
+ * look exactly like a person deciding not to go ahead, and the update card would
+ * sit there claiming an update is available with no explanation of why nothing
+ * happened. That was the specific complaint recorded against Minstrel's first
+ * attempt (Scribe #2438).
+ *
+ * The result is written to [UpdateOutcome] rather than notified: the app is on
+ * screen when this fires — someone just tapped Update — so the place to say it is
+ * the card they are looking at.
+ */
+class UpdateReceiver : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ if (intent.action != ACTION_INSTALLED) return
+
+ val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)
+ val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+
+ when (status) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ // Android wants a confirmation. This is the ORDINARY path below API
+ // 31, and the path on 31+ whenever the OS declines to skip the
+ // dialog — which it may, and is entitled to.
+ val confirm =
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ if (confirm == null) {
+ UpdateOutcome.report("Android asked for confirmation but sent no way to give it.")
+ return
+ }
+ // NEW_TASK because a receiver has no activity of its own to start
+ // from. The app is in the foreground, so this surfaces immediately.
+ confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ runCatching { context.startActivity(confirm) }
+ .onFailure {
+ Log.w(TAG, "could not show the install confirmation", it)
+ UpdateOutcome.report("Android's install confirmation could not be shown.")
+ }
+ }
+
+ PackageInstaller.STATUS_SUCCESS -> {
+ // Rarely seen: a successful self-update replaces this process, so
+ // the app is usually gone before it can act on this.
+ Log.i(TAG, "update installed")
+ UpdateOutcome.report(null)
+ }
+
+ PackageInstaller.STATUS_FAILURE_ABORTED ->
+ // Someone declined. Not an error, and saying "install failed" for a
+ // deliberate choice is how an app sounds broken when it is not.
+ UpdateOutcome.report(null)
+
+ else -> {
+ Log.w(TAG, "install failed: status=$status message=$message")
+ UpdateOutcome.report(message ?: "The update did not install.")
+ }
+ }
+ }
+
+ companion object {
+ const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
+ private const val TAG = "ThoughtSyncUpdate"
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncPairing.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncPairing.kt
index 651ecdb..ff10b83 100644
--- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncPairing.kt
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncPairing.kt
@@ -4,7 +4,6 @@ import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
@@ -120,10 +119,14 @@ fun UnlinkedPanel(
Button(
onClick = { credentials?.let { onLink(url, it) } },
enabled = credentials != null && !state.busy,
- modifier = Modifier.padding(bottom = 24.dp),
) {
Text(stringResource(R.string.sync_connect))
}
+
+ // Where app updates come from, said here rather than left as a gap. This
+ // device has no update path at all until it is linked, and a Check button
+ // that always found nothing would be worse than the sentence.
+ UnlinkedUpdateNote()
}
}
diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncScreen.kt
index 56a682d..461d700 100644
--- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncScreen.kt
@@ -36,6 +36,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
+import com.fabledsword.thoughtsync.UpdateOutcome
/**
* Opt-in server pairing.
@@ -64,6 +65,11 @@ fun SyncScreen(
onDismissRevokeNotice: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
+ update: UpdateState,
+ onCheckUpdate: () -> Unit,
+ onInstallUpdate: () -> Unit,
+ onDismissUpdateError: () -> Unit,
+ onInstallOutcome: (UpdateOutcome.Result) -> Unit,
) {
Scaffold(
topBar = {
@@ -99,6 +105,11 @@ fun SyncScreen(
onUnlink = onUnlink,
automatic = automatic,
onAutomaticChange = onAutomaticChange,
+ update = update,
+ onCheckUpdate = onCheckUpdate,
+ onInstallUpdate = onInstallUpdate,
+ onDismissUpdateError = onDismissUpdateError,
+ onInstallOutcome = onInstallOutcome,
)
else ->
UnlinkedPanel(
@@ -122,6 +133,11 @@ private fun LinkedPanel(
onUnlink: () -> Unit,
automatic: Boolean,
onAutomaticChange: (Boolean) -> Unit,
+ update: UpdateState,
+ onCheckUpdate: () -> Unit,
+ onInstallUpdate: () -> Unit,
+ onDismissUpdateError: () -> Unit,
+ onInstallOutcome: (UpdateOutcome.Result) -> Unit,
) {
var confirmingUnlink by remember { mutableStateOf(false) }
@@ -214,6 +230,15 @@ private fun LinkedPanel(
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_failed_title), body = it)
}
+ // The app itself comes from this server too, not just the notes.
+ UpdateCard(
+ state = update,
+ onCheck = onCheckUpdate,
+ onInstall = onInstallUpdate,
+ onDismissError = onDismissUpdateError,
+ onOutcome = onInstallOutcome,
+ )
+
Text(
text = stringResource(R.string.sync_footer),
style = MaterialTheme.typography.bodySmall,
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
new file mode 100644
index 0000000..5f4eb6d
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateCard.kt
@@ -0,0 +1,135 @@
+package com.fabledsword.thoughtsync.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+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.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.fabledsword.thoughtsync.AppUpdate
+import com.fabledsword.thoughtsync.R
+import com.fabledsword.thoughtsync.UpdateOutcome
+
+/**
+ * Updating the app from the server it is linked to.
+ *
+ * Lives on the sync screen because that is what it IS — the server hands out the
+ * client as well as the notes. Putting it in a settings screen of its own would
+ * separate two halves of one relationship.
+ *
+ * Nothing here appears on an unlinked device; [UnlinkedUpdateNote] says why in one
+ * line instead, so the absence reads as a consequence of not being linked rather
+ * than as a missing feature.
+ */
+@Composable
+fun UpdateCard(
+ state: UpdateState,
+ onCheck: () -> Unit,
+ onInstall: () -> Unit,
+ onDismissError: () -> Unit,
+ onOutcome: (UpdateOutcome.Result) -> Unit,
+) {
+ val context = LocalContext.current
+
+ // The system answers an install through a BroadcastReceiver, which has no way
+ // back into a view model. This is the seam.
+ UpdateOutcome.latest?.let { result ->
+ LaunchedEffect(result) { onOutcome(result) }
+ }
+
+ Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
+ Text(
+ text = stringResource(R.string.update_installed_version, state.installedVersion),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ val available = state.available
+ if (available != null) {
+ Text(
+ text =
+ stringResource(
+ R.string.update_available,
+ available.version,
+ available.size / BYTES_PER_MB,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ } else if (state.upToDate) {
+ Text(
+ text = stringResource(R.string.update_current),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ if (state.working) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (available == null) {
+ TextButton(onClick = onCheck, enabled = !state.busy) {
+ Text(stringResource(R.string.update_check))
+ }
+ } else {
+ Button(onClick = onInstall, enabled = !state.busy) {
+ Text(stringResource(R.string.update_install))
+ }
+ }
+ }
+
+ // Android's "install unknown apps" grant is separate from anything in the
+ // manifest and only the person can give it. Said BEFORE a download rather
+ // than after, so nobody spends 55 MiB to be told no.
+ if (available != null && !AppUpdate.canInstall(context)) {
+ Notice(
+ tone = Tone.WARN,
+ title = stringResource(R.string.update_permission_title),
+ body = stringResource(R.string.update_permission_body),
+ actionLabel = stringResource(R.string.update_permission_action),
+ onAction = { context.startActivity(AppUpdate.installPermissionSettings(context)) },
+ )
+ }
+
+ state.error?.let {
+ Notice(
+ tone = Tone.ERROR,
+ title = stringResource(R.string.update_failed_title),
+ body = it,
+ onDismiss = onDismissError,
+ )
+ }
+ }
+}
+
+/**
+ * The one line an unlinked device gets.
+ *
+ * Updates arrive from a linked server, so there is genuinely nothing to offer
+ * here — and a Check button that always found nothing would be worse than saying
+ * so.
+ */
+@Composable
+fun UnlinkedUpdateNote() {
+ Text(
+ text = stringResource(R.string.update_needs_server),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ // Carries the bottom breathing room the connect button used to provide,
+ // now that it is the last thing on the unlinked screen.
+ modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
+ )
+}
+
+private const val BYTES_PER_MB = 1024 * 1024
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
new file mode 100644
index 0000000..34b45a8
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/UpdateViewModel.kt
@@ -0,0 +1,128 @@
+package com.fabledsword.thoughtsync.ui
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.fabledsword.thoughtsync.AppUpdate
+import com.fabledsword.thoughtsync.UpdateOutcome
+import com.fabledsword.thoughtsync.core.ClientUpdate
+import com.fabledsword.thoughtsync.core.ThoughtSync
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** Everything the update card renders from. */
+data class UpdateState(
+ /** What is running now. Shown even when there is nothing to update to. */
+ val installedVersion: Long = 0,
+ val checking: Boolean = false,
+ /** Only ever set to something NEWER — the core does that comparison. */
+ val available: ClientUpdate? = null,
+ /** A check completed and found nothing. Distinct from "not checked yet". */
+ val upToDate: Boolean = false,
+ val working: Boolean = false,
+ val error: String? = null,
+) {
+ val busy: Boolean get() = checking || working
+}
+
+/**
+ * Updating the app from the server it syncs with.
+ *
+ * **Linked-only, and said out loud.** The app is local-first and completely usable
+ * having never touched a server, so an unlinked install has no update path at all.
+ * The card says that rather than offering a Check button that silently finds
+ * nothing — the same lesson as the desktop's unlink copy (issue 2110).
+ *
+ * The core does the network work, not this class: the device token lives in the
+ * Rust store and pulling it into Kotlin to make an HTTP call would spread the one
+ * secret this app holds across two languages for no gain.
+ */
+class UpdateViewModel(
+ private val core: ThoughtSync,
+ /**
+ * MUST be the application context — it outlives this view model, and holding an
+ * Activity here is the textbook way to leak a window.
+ */
+ private val context: Context,
+) : ViewModel() {
+ var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
+ private set
+
+ /** Ask the linked server what it has. */
+ fun check() {
+ 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)
+ } 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)
+ }
+ }
+ }
+
+ /**
+ * Download the update and hand it to the system installer.
+ *
+ * One action rather than two buttons: nobody wants a downloaded APK sitting
+ * around as an intermediate state they have to think about.
+ */
+ fun downloadAndInstall() {
+ viewModelScope.launch {
+ state = state.copy(working = true, error = null)
+ UpdateOutcome.clear()
+ val failure =
+ try {
+ val target = AppUpdate.downloadTarget(context)
+ 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) {
+ e.message ?: FALLBACK
+ }
+ // `working` stays TRUE on success: the install is still in flight, and
+ // on a silent update this process is about to be replaced. Clearing it
+ // here would flash "ready" a moment before the app disappears.
+ state =
+ if (failure == null) state else state.copy(working = false, error = failure)
+ }
+ }
+
+ /**
+ * Take whatever the system finally said about the install.
+ *
+ * Called from the composition, because the answer arrives at a BroadcastReceiver
+ * the system owns and there is no other way back into this class.
+ */
+ fun consumeInstallOutcome(result: UpdateOutcome.Result) {
+ UpdateOutcome.clear()
+ state = state.copy(working = false, error = result.error)
+ }
+
+ fun dismissError() {
+ state = state.copy(error = null)
+ }
+
+ companion object {
+ fun factory(
+ core: ThoughtSync,
+ context: Context,
+ ): ViewModelProvider.Factory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ UpdateViewModel(core, context.applicationContext) as T
+ }
+ }
+}
+
+private const val FALLBACK = "The update couldn't be checked."
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 15c5fc3..04e5f28 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -123,6 +123,16 @@
Sync automaticallyChecks about every 15 minutes, and whenever you open the app.Only when you pull the board down or tap Sync now.
+ This app is build %1$d.
+ Build %1$s is available (%2$d MB).
+ You\'re on the newest build this server has.
+ Check for an update
+ Update
+ The update didn\'t install
+ Android needs your permission
+ ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.
+ Allow installing
+ App updates come from a server you connect. Until then, install new builds yourself.Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.Sync failedThe server wouldn\'t accept some changes
diff --git a/android/config/detekt.yml b/android/config/detekt.yml
index 0d14ffc..8c27368 100644
--- a/android/config/detekt.yml
+++ b/android/config/detekt.yml
@@ -64,6 +64,11 @@ exceptions:
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
# has already logged its own failure by the time this catches anything.
+ # * the self-updater — the install path throws IOException from three
+ # different calls and SecurityException when the "install unknown apps"
+ # grant has been revoked since it was checked. All of them mean one thing
+ # to the person ("it did not install"), and none should take the app down
+ # while it is holding their notes.
#
# Scoped to those paths rather than disabled globally: elsewhere the rule is
# right and still applies.
@@ -72,3 +77,4 @@ exceptions:
- "**/ThoughtSyncApplication.kt"
- "**/SyncWorker.kt"
- "**/ReminderReceiver.kt"
+ - "**/AppUpdate.kt"
diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs
index 6a35f25..190642b 100644
--- a/android/ffi/src/lib.rs
+++ b/android/ffi/src/lib.rs
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{
- patch_from, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome,
- SyncOutcome, SyncStatus,
+ patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
+ RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
@@ -442,6 +442,54 @@ impl ThoughtSync {
/// The only sync entry point, on purpose. Push and pull exist separately inside
/// the core, but offering a bare "pull" would let the UI overwrite unsent local
/// edits — the ordering isn't a suggestion, it's what keeps them.
+ /// The Android client the linked server is offering, if any.
+ ///
+ /// `None` covers two different-looking situations that are one answer to the
+ /// app: this server has no client, or it has one and it is not newer than what
+ /// is already installed. Comparing here rather than in Kotlin keeps the rule —
+ /// version CODE decides, never the name — in the layer that also has to get it
+ /// right for the desktop.
+ pub async fn client_update(
+ &self,
+ installed_version_code: i64,
+ ) -> Result