M12 — the Android client, end to end #2
@@ -64,6 +64,25 @@
|
|||||||
declared here too because ReminderReceiver now depends on it directly. A
|
declared here too because ReminderReceiver now depends on it directly. A
|
||||||
permission this file relies on should be visible in this file.
|
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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<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
|
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.
|
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
|
<receiver
|
||||||
android:name=".ReminderReceiver"
|
android:name=".ReminderReceiver"
|
||||||
android:exported="false">
|
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.SyncState
|
||||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||||
|
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.olderThan
|
import com.fabledsword.thoughtsync.ui.olderThan
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -150,6 +151,7 @@ private fun App(
|
|||||||
var composing by rememberSaveable { mutableStateOf(false) }
|
var composing by rememberSaveable { mutableStateOf(false) }
|
||||||
var showingSync 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) }
|
val settings = remember(context) { SyncSettings(context) }
|
||||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||||
|
|
||||||
@@ -179,6 +181,11 @@ private fun App(
|
|||||||
automatic = it
|
automatic = it
|
||||||
settings.automatic = it
|
settings.automatic = it
|
||||||
},
|
},
|
||||||
|
update = update.state,
|
||||||
|
onCheckUpdate = update::check,
|
||||||
|
onInstallUpdate = update::downloadAndInstall,
|
||||||
|
onDismissUpdateError = update::dismissError,
|
||||||
|
onInstallOutcome = update::consumeInstallOutcome,
|
||||||
)
|
)
|
||||||
|
|
||||||
Screen.EDITOR ->
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -120,10 +119,14 @@ fun UnlinkedPanel(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { credentials?.let { onLink(url, it) } },
|
onClick = { credentials?.let { onLink(url, it) } },
|
||||||
enabled = credentials != null && !state.busy,
|
enabled = credentials != null && !state.busy,
|
||||||
modifier = Modifier.padding(bottom = 24.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.sync_connect))
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
|
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-in server pairing.
|
* Opt-in server pairing.
|
||||||
@@ -64,6 +65,11 @@ fun SyncScreen(
|
|||||||
onDismissRevokeNotice: () -> Unit,
|
onDismissRevokeNotice: () -> Unit,
|
||||||
automatic: Boolean,
|
automatic: Boolean,
|
||||||
onAutomaticChange: (Boolean) -> Unit,
|
onAutomaticChange: (Boolean) -> Unit,
|
||||||
|
update: UpdateState,
|
||||||
|
onCheckUpdate: () -> Unit,
|
||||||
|
onInstallUpdate: () -> Unit,
|
||||||
|
onDismissUpdateError: () -> Unit,
|
||||||
|
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -99,6 +105,11 @@ fun SyncScreen(
|
|||||||
onUnlink = onUnlink,
|
onUnlink = onUnlink,
|
||||||
automatic = automatic,
|
automatic = automatic,
|
||||||
onAutomaticChange = onAutomaticChange,
|
onAutomaticChange = onAutomaticChange,
|
||||||
|
update = update,
|
||||||
|
onCheckUpdate = onCheckUpdate,
|
||||||
|
onInstallUpdate = onInstallUpdate,
|
||||||
|
onDismissUpdateError = onDismissUpdateError,
|
||||||
|
onInstallOutcome = onInstallOutcome,
|
||||||
)
|
)
|
||||||
else ->
|
else ->
|
||||||
UnlinkedPanel(
|
UnlinkedPanel(
|
||||||
@@ -122,6 +133,11 @@ private fun LinkedPanel(
|
|||||||
onUnlink: () -> Unit,
|
onUnlink: () -> Unit,
|
||||||
automatic: Boolean,
|
automatic: Boolean,
|
||||||
onAutomaticChange: (Boolean) -> Unit,
|
onAutomaticChange: (Boolean) -> Unit,
|
||||||
|
update: UpdateState,
|
||||||
|
onCheckUpdate: () -> Unit,
|
||||||
|
onInstallUpdate: () -> Unit,
|
||||||
|
onDismissUpdateError: () -> Unit,
|
||||||
|
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
|
||||||
) {
|
) {
|
||||||
var confirmingUnlink by remember { mutableStateOf(false) }
|
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)
|
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(
|
||||||
text = stringResource(R.string.sync_footer),
|
text = stringResource(R.string.sync_footer),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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">Sync automatically</string>
|
||||||
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</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="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_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_failed_title">Sync failed</string>
|
||||||
<string name="sync_rejected_title">The server wouldn\'t accept some changes</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
|
# * 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
|
# 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.
|
# 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
|
# Scoped to those paths rather than disabled globally: elsewhere the rule is
|
||||||
# right and still applies.
|
# right and still applies.
|
||||||
@@ -72,3 +77,4 @@ exceptions:
|
|||||||
- "**/ThoughtSyncApplication.kt"
|
- "**/ThoughtSyncApplication.kt"
|
||||||
- "**/SyncWorker.kt"
|
- "**/SyncWorker.kt"
|
||||||
- "**/ReminderReceiver.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 thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
patch_from, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome,
|
patch_from, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult,
|
||||||
SyncOutcome, SyncStatus,
|
RevokeOutcome, SyncOutcome, SyncStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@@ -442,6 +442,54 @@ impl ThoughtSync {
|
|||||||
/// The only sync entry point, on purpose. Push and pull exist separately inside
|
/// 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
|
/// 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.
|
/// 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> {
|
pub async fn sync_now(&self) -> Result<SyncOutcome, CoreError> {
|
||||||
let (base_url, token) = self.credentials()?;
|
let (base_url, token) = self.credentials()?;
|
||||||
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
|
engine::run_cycle(&self.db, &self.blobs, &base_url, &token)
|
||||||
|
|||||||
@@ -51,6 +51,42 @@ pub struct Note {
|
|||||||
pub updated_at: Option<String>,
|
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)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct NoteLabel {
|
pub struct NoteLabel {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -104,9 +104,14 @@ def object_members(src: str) -> dict:
|
|||||||
depth = 0
|
depth = 0
|
||||||
for line in body.splitlines():
|
for line in body.splitlines():
|
||||||
if depth == 0:
|
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(
|
decl = re.match(
|
||||||
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
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+)",
|
r"(?:<[^>]*>\s*)?(\w+)",
|
||||||
line,
|
line,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
||||||
//! fully usable with no network at all.
|
//! fully usable with no network at all.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use reqwest::{RequestBuilder, StatusCode};
|
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