M12 — the Android client, end to end #2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user