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

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

**A PackageInstaller session, not an install intent.** The obvious route —
ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned
against, and it is what produced the "bypassing Android security" warning on
Minstrel (Scribe note 2437). It also never tells the OS that this app is the
legitimate updater of its own package, and it returns nothing: a failed install
is indistinguishable from someone dismissing the dialog.

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

Two things from that research deliberately NOT done: `setRequestUpdateOwnership`
was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is
not the differentiator either — Mihon declares it too. The mechanism was the
whole difference.

**The outcome comes back.** `commit` takes an IntentSender and the result lands
at `UpdateReceiver`, so a failure can be shown rather than guessed at, and
STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31
and still possible above it, since the OS is entitled to ask anyway. Someone
declining is reported as no error at all: calling a deliberate choice a failure
is how an app sounds broken when it is not.

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

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

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

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

Also fixes `check-symbols.py`, which reported four false positives on
`UpdateOutcome.Result` — its object-member index collected functions and
properties but not nested TYPES, and a data class inside an object is an
ordinary member.
This commit is contained in:
2026-08-21 08:44:08 -04:00
parent 0cf77336d4
commit 81695fa0c8
15 changed files with 838 additions and 5 deletions
+136
View File
@@ -8,6 +8,7 @@
//! Nothing here runs unless the user has linked a server; the app is local-first and
//! fully usable with no network at all.
use std::path::Path;
use std::time::Duration;
use reqwest::{RequestBuilder, StatusCode};
@@ -431,3 +432,138 @@ mod tests {
);
}
}
/// The Android client a linked server can hand out.
///
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
/// means the server has no client to offer, which is an ordinary state and not an
/// error — a self-hoster who never touches Android has one.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientRelease {
pub version: String,
/// What decides "is this newer". The name is for people and sorts like a string.
pub version_code: i64,
pub size: i64,
pub sha256: String,
/// Path on the same server, not an absolute URL — the client joins it to the
/// base it is already linked to, so a compromised or misconfigured server
/// cannot redirect the download somewhere else.
pub url: String,
}
/// What Android client the linked server has, if any.
///
/// `Ok(None)` for a server that simply has none — that is the answer to the
/// question, not a failure to answer it.
pub async fn fetch_client_release(
base_url: &str,
token: &str,
) -> Result<Option<ClientRelease>, String> {
let url = format!("{base_url}/api/client/android");
let response = prepare(http()?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json::<ClientRelease>()
.await
.map(Some)
.map_err(|e| {
format!("{base_url} described its Android client in a way this app could not read: {e}")
})
}
/// Download the client to `dest`, verifying it on the way in.
///
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
/// a phone, on top of whatever the app is already using, is how an update gets
/// killed by the low-memory killer half way through.
///
/// Written to `dest.part` and renamed only once the digest matches, so an
/// interrupted download can never be mistaken for a finished one. The digest is
/// not a trust anchor — the APK signature is, and Android checks that at install —
/// but it catches a truncated or corrupted transfer before the installer is
/// bothered with it.
pub async fn download_client(
base_url: &str,
token: &str,
release: &ClientRelease,
dest: &Path,
) -> Result<(), String> {
use sha2::{Digest, Sha256};
use std::io::Write;
// The advertised path is joined to the base we are LINKED to. Taking an
// absolute URL from the response would let a server point the download at a
// host the user never agreed to.
let path = release.url.trim_start_matches('/');
let url = format!("{base_url}/{path}");
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token))
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let partial = dest.with_extension("part");
if let Some(parent) = partial.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
}
let mut file = std::fs::File::create(&partial)
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
let mut hasher = Sha256::new();
let mut written: i64 = 0;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let Some(chunk) = chunk else { break };
hasher.update(&chunk);
written += chunk.len() as i64;
file.write_all(&chunk)
.map_err(|e| format!("Couldn't write the download: {e}"))?;
}
file.flush()
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
drop(file);
let digest = format!("{:x}", hasher.finalize());
let mismatch = if written != release.size {
Some(format!("expected {} bytes, got {written}", release.size))
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
Some("the contents did not match the checksum the server published".to_string())
} else {
None
};
if let Some(why) = mismatch {
// The half-file is removed rather than left: a later run finding it would
// have no way to tell it from a good one.
let _ = std::fs::remove_file(&partial);
return Err(format!("The download from {base_url} was damaged — {why}."));
}
std::fs::rename(&partial, dest)
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
}