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 automatically Checks 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 failed The 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, 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 { let (base_url, token) = self.credentials()?; engine::run_cycle(&self.db, &self.blobs, &base_url, &token) diff --git a/android/ffi/src/models.rs b/android/ffi/src/models.rs index 700d198..de42191 100644 --- a/android/ffi/src/models.rs +++ b/android/ffi/src/models.rs @@ -51,6 +51,42 @@ pub struct Note { pub updated_at: Option, } +/// 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 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, diff --git a/android/tools/check-symbols.py b/android/tools/check-symbols.py index 861fea6..dc9ad66 100755 --- a/android/tools/check-symbols.py +++ b/android/tools/check-symbols.py @@ -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, ) diff --git a/core/src/sync/client.rs b/core/src/sync/client.rs index 846df3d..eef837a 100644 --- a/core/src/sync/client.rs +++ b/core/src/sync/client.rs @@ -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, 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::() + .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}")) +}