android: update the app from the server it syncs with (2727, M12 step 7)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m36s

Closes M12. The phone can now notice that its server has a newer build and
install it, instead of the operator copying an APK to a device by hand.

**A PackageInstaller session, not an install intent.** The obvious route —
ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned
against, and it is what produced the "bypassing Android security" warning 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 someone dismissing the dialog.

The session says who is doing what, and on Android 12+ declares no user action
required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the
confirmation entirely on the UPDATE path. Only there: Android will not let an app
quietly put a NEW package on a device, which is right. It also only applies when
the new build carries the same signing key as the installed one, which is why
signing had to land first.

Two things from that research deliberately NOT done: `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 and the result lands
at `UpdateReceiver`, so a failure can be shown rather than guessed at, and
STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31
and still possible above it, since the OS is entitled to ask anyway. Someone
declining is reported as no error at all: calling a deliberate choice a failure
is how an app sounds broken when it is not.

**The network work stays in Rust.** Two FFI additions — `clientUpdate` and
`downloadClientUpdate` — because the device token lives in the core, and pulling
it into Kotlin to make an HTTP call would spread the one secret this app holds
across two languages for nothing. The core also owns the comparison, so the rule
"version CODE decides, never the name" lives in the layer that has to get it
right for every surface.

The download is streamed to disk, not buffered: 55 MiB in memory on a phone is
how an update gets killed halfway through. It lands in `update.apk.part` and is
renamed only once size and sha256 both match, so an interrupted download can
never be mistaken for a finished one. The digest is not a trust anchor — the
signature is, and Android checks it — but it catches a truncated transfer before
the installer is bothered with it. The advertised path is joined to the base URL
this device is LINKED to rather than followed as given, so a server cannot point
the download at a host nobody agreed to.

**Updates are linked-only, and it says so.** An unlinked install has no update
path, so it gets one sentence explaining where updates come from rather than a
Check button that silently finds nothing — the same lesson as the desktop's
unlink copy (issue 2110). And the "install unknown apps" grant is asked for
BEFORE downloading, so nobody spends 55 MiB to be told no.

Every Android API here was read out of `android-36/android.jar` with javap
first, and the two new FFI methods out of freshly generated bindings, rather
than recalled: `suspend fun clientUpdate(installedVersionCode: Long):
ClientUpdate?` and `downloadClientUpdate(destPath: String)`.

Also fixes `check-symbols.py`, which reported four false positives on
`UpdateOutcome.Result` — its object-member index collected functions and
properties but not nested TYPES, and a data class inside an object is an
ordinary member.
This commit is contained in:
2026-08-21 08:44:08 -04:00
parent 0cf77336d4
commit 81695fa0c8
15 changed files with 838 additions and 5 deletions
+29
View File
@@ -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.
-->
<!--
Updating this app from the server it syncs with (M12 step 7).
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
all. It is NOT what makes an install look suspicious to on-device heuristics
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
2437. The person must additionally grant "install unknown apps" in system
settings; the update card asks before downloading anything.
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
confirmation on the UPDATE path, and only there — Android will not let an app
silently put a NEW package on a device, which is correct. It also only applies
when the new build is signed with the same key as the installed one, which is
why signing had to land before any of this could work.
-->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -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.
-->
<!--
Where the system reports what happened to an install we committed. Not
exported: the only sender is the PendingIntent this app handed to
PackageInstaller. Without it a failed install would be indistinguishable
from someone declining the dialog (Scribe #2438).
-->
<receiver
android:name=".UpdateReceiver"
android:exported="false" />
<receiver
android:name=".ReminderReceiver"
android:exported="false">
@@ -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"
}
@@ -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 ->
@@ -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
}
}
@@ -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>(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"
}
}
@@ -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()
}
}
@@ -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,
@@ -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
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T =
UpdateViewModel(core, context.applicationContext) as T
}
}
}
private const val FALLBACK = "The update couldn't be checked."
@@ -123,6 +123,16 @@
<string name="sync_automatic">Sync automatically</string>
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
<string name="update_installed_version">This app is build %1$d.</string>
<string name="update_available">Build %1$s is available (%2$d MB).</string>
<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_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>
<string name="update_permission_action">Allow installing</string>
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
<string name="sync_failed_title">Sync failed</string>
<string name="sync_rejected_title">The server wouldn\'t accept some changes</string>
+6
View File
@@ -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"
+50 -2
View File
@@ -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<Option<ClientUpdate>, CoreError> {
let (base_url, token) = self.credentials()?;
let release = client::fetch_client_release(&base_url, &token)
.await
.map_err(CoreError::network)?;
Ok(release
.filter(|r| r.version_code > installed_version_code)
.map(ClientUpdate::from))
}
/// Download that client to `dest_path`, verified.
///
/// Takes the destination rather than choosing one: only Android knows a
/// directory its own package installer can read from, and the core has no
/// business guessing at platform paths — the same reason `ThoughtSync::new`
/// takes a data dir.
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
let (base_url, token) = self.credentials()?;
let release = client::fetch_client_release(&base_url, &token)
.await
.map_err(CoreError::network)?
// Re-read rather than trusting what the caller was shown: the server
// may have published a new build between the check and the tap, and
// downloading against a stale digest would fail verification on bytes
// that are perfectly good.
.ok_or_else(|| {
CoreError::network("This server no longer has an Android client.".to_string())
})?;
client::download_client(
&base_url,
&token,
&release,
std::path::Path::new(&dest_path),
)
.await
.map_err(CoreError::network)
}
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
let (base_url, token) = self.credentials()?;
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
+36
View File
@@ -51,6 +51,42 @@ pub struct Note {
pub updated_at: Option<String>,
}
/// An Android build the linked server is offering, already judged to be newer.
///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
/// reason every other record here is one: the core's shapes are contracted with
/// other consumers, and `url` in particular is an implementation detail of how
/// the download is fetched — the app never needs it, because it asks the core to
/// do the downloading.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ClientUpdate {
/// For people to read.
pub version: String,
/// For machines to compare.
pub version_code: i64,
pub size: i64,
}
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
// Destructured exhaustively, like every other conversion in this file: a
// field added upstream stops this compiling until Android is told what to
// do with it, which turns silent drift into a build error.
let thoughtsync_core::sync::client::ClientRelease {
version,
version_code,
size,
sha256: _,
url: _,
} = r;
ClientUpdate {
version,
version_code,
size,
}
}
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteLabel {
pub id: String,
+6 -1
View File
@@ -104,9 +104,14 @@ def object_members(src: str) -> dict:
depth = 0
for line in body.splitlines():
if depth == 0:
# Nested TYPES count as members too: `Foo.Bar` where Bar is a
# data class inside object Foo is an ordinary reference, and
# leaving them out made the checker report four false positives
# the first time an object held one.
decl = re.match(
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
r"(?:const |lateinit |inline |suspend )*(?:fun|val|var)\s+"
r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*"
r"(?:fun|val|var|class|object|interface)\s+"
r"(?:<[^>]*>\s*)?(\w+)",
line,
)
+136
View File
@@ -8,6 +8,7 @@
//! Nothing here runs unless the user has linked a server; the app is local-first and
//! fully usable with no network at all.
use std::path::Path;
use std::time::Duration;
use reqwest::{RequestBuilder, StatusCode};
@@ -431,3 +432,138 @@ mod tests {
);
}
}
/// The Android client a linked server can hand out.
///
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
/// means the server has no client to offer, which is an ordinary state and not an
/// error — a self-hoster who never touches Android has one.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientRelease {
pub version: String,
/// What decides "is this newer". The name is for people and sorts like a string.
pub version_code: i64,
pub size: i64,
pub sha256: String,
/// Path on the same server, not an absolute URL — the client joins it to the
/// base it is already linked to, so a compromised or misconfigured server
/// cannot redirect the download somewhere else.
pub url: String,
}
/// What Android client the linked server has, if any.
///
/// `Ok(None)` for a server that simply has none — that is the answer to the
/// question, not a failure to answer it.
pub async fn fetch_client_release(
base_url: &str,
token: &str,
) -> Result<Option<ClientRelease>, String> {
let url = format!("{base_url}/api/client/android");
let response = prepare(http()?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json::<ClientRelease>()
.await
.map(Some)
.map_err(|e| {
format!("{base_url} described its Android client in a way this app could not read: {e}")
})
}
/// Download the client to `dest`, verifying it on the way in.
///
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
/// a phone, on top of whatever the app is already using, is how an update gets
/// killed by the low-memory killer half way through.
///
/// Written to `dest.part` and renamed only once the digest matches, so an
/// interrupted download can never be mistaken for a finished one. The digest is
/// not a trust anchor — the APK signature is, and Android checks that at install —
/// but it catches a truncated or corrupted transfer before the installer is
/// bothered with it.
pub async fn download_client(
base_url: &str,
token: &str,
release: &ClientRelease,
dest: &Path,
) -> Result<(), String> {
use sha2::{Digest, Sha256};
use std::io::Write;
// The advertised path is joined to the base we are LINKED to. Taking an
// absolute URL from the response would let a server point the download at a
// host the user never agreed to.
let path = release.url.trim_start_matches('/');
let url = format!("{base_url}/{path}");
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let partial = dest.with_extension("part");
if let Some(parent) = partial.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
}
let mut file = std::fs::File::create(&partial)
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
let mut hasher = Sha256::new();
let mut written: i64 = 0;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let Some(chunk) = chunk else { break };
hasher.update(&chunk);
written += chunk.len() as i64;
file.write_all(&chunk)
.map_err(|e| format!("Couldn't write the download: {e}"))?;
}
file.flush()
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
drop(file);
let digest = format!("{:x}", hasher.finalize());
let mismatch = if written != release.size {
Some(format!("expected {} bytes, got {written}", release.size))
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
Some("the contents did not match the checksum the server published".to_string())
} else {
None
};
if let Some(why) = mismatch {
// The half-file is removed rather than left: a later run finding it would
// have no way to tell it from a good one.
let _ = std::fs::remove_file(&partial);
return Err(format!("The download from {base_url} was damaged — {why}."));
}
std::fs::rename(&partial, dest)
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
}