Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 18s
Android / Core and FFI clippy and tests (push) Successful in 45s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s

Three of the idea's practices this project still owed (Scribe #5118):

Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.

Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
  Retry-After past the limit. The limit is a new Settings → Security
  value, "App downloads per account per hour" (default 30), live like the
  sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
  phone resuming a download across a redeploy is not told its partial
  copy is stale. Quart's own ETag and conditional handling are off, and
  the route runs the conditional pass after setting the ETag, so Range
  and If-Range are judged against the content.

Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
  comes back, as ReminderNotice does. Read once, it stayed up after
  someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
  Android would refuse the release-signed APK over a debug signature
  anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:21:40 -04:00
co-authored by Claude Opus 5.5
parent f0ce5687cc
commit 97b04f9f92
10 changed files with 202 additions and 9 deletions
@@ -3,6 +3,7 @@ package com.fabledsword.inkwell
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInstaller
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
@@ -50,6 +51,18 @@ object AppUpdate {
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
}.getOrDefault(0L)
/**
* Whether this build can update itself at all.
*
* A debuggable build is signed with a debug key (CI's unsigned path, or a local
* build), and Android refuses the release-signed APK over it with
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
* that can only fail, so a debug build says so instead (family idea #5103,
* practice 9).
*/
fun selfUpdates(context: Context): Boolean =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
/**
* Whether this app may install packages at all.
*
@@ -19,6 +19,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -50,6 +54,13 @@ fun UpdateCard(
) {
val context = LocalContext.current
// Re-read on every return to the app, as ReminderNotice does: the grant is given
// in a system screen this app cannot observe. Read once, the warning would still
// be up after someone granted it and came back, and the offer below it would
// look blocked when it is not (family idea #5103, practice 9).
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) }, onBackground = {})
// 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 ->
@@ -63,6 +74,15 @@ fun UpdateCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!state.selfUpdates) {
Text(
text = stringResource(R.string.update_debug_build),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
val available = state.available
if (available != null) {
Text(
@@ -101,7 +121,7 @@ fun UpdateCard(
// 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)) {
if (available != null && !canInstall) {
Notice(
tone = Tone.WARN,
title = stringResource(R.string.update_permission_title),
@@ -31,6 +31,8 @@ data class UpdateState(
val error: String? = null,
/** The banner has been waved away — until the app next comes forward. */
val nagDismissed: Boolean = false,
/** False on a debug build, which can never install a release update over itself. */
val selfUpdates: Boolean = true,
) {
val busy: Boolean get() = checking || downloading || working
@@ -68,7 +70,12 @@ class UpdateViewModel(
*/
private val context: Context,
) : ViewModel() {
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
var state by mutableStateOf(
UpdateState(
installedVersion = AppUpdate.installedVersionCode(context),
selfUpdates = AppUpdate.selfUpdates(context),
),
)
private set
/** When the last check ran, so coming back to the app twice in a minute is one. */
@@ -90,6 +97,9 @@ class UpdateViewModel(
fun checkInBackground() {
val now = System.currentTimeMillis()
when {
// Nothing a debug build could do with what it found.
!state.selfUpdates -> Unit
state.busy -> Unit
// Already fetched and waved away — say so again. "Later" is for that
@@ -228,6 +228,7 @@
<string name="update_permission_title">Android needs your permission</string>
<string name="update_permission_body">Inkwell 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_debug_build">This is a debug build, so it can\'t update itself. Install new builds by hand.</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>