From 04a2ad99d1a04f6d5f754c1fb47802aa315c0226 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 14 Aug 2026 08:37:05 -0500 Subject: [PATCH] Check what is IN a cached update, and add a way to throw it away (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel on prod looped on an update it could never apply, reporting a download failure that was not one. The staged-APK cache is keyed by FILENAME, and the filename is built from the version the SERVER advertised. Prod advertised 1.9.34 while still serving the 1.9.33 file, so the panel saved 1.9.33 as `ScreenTinker-1.9.34.apk`. On every retry it found that file, verified the signature — which passed, same key — reused it, and installed a no-op. The version never changed, so the update was offered again. Fixing the server did not help: the poisoned file is reused before anything is fetched. It took `adb rm` to break the loop. Two changes: CHECK THE VERSION INSIDE. A cached APK is reused only when the versionName in the file matches the version being installed, and a fresh download is checked the same way before install. A server serving stale bytes now fails with what actually happened — "server served 1.9.33 but advertised 1.9.34 — the update on the server is stale" — instead of a download error, and the bad file is deleted rather than kept to poison the next attempt. That makes this class self-healing: the panel recovers on its own once the server is fixed. A WAY TO CLEAR IT. `clear_update_cache` deletes every staged APK across all three staging directories, with a button on the device page next to Force Update. Gated on `system.self_update` — a player that can update itself is one that can hold a bad download. Only caches are deleted; they are re-fetched on demand. The version check should make the button rarely necessary. It exists because it would have turned tonight's hands-on ADB recovery into one click, and because a panel already holding a bad file predates the fix and cannot benefit from it. 1668/1668 pass; Android unit tests and lint clean. --- .../com/remotedisplay/player/MainActivity.kt | 8 +++ .../player/service/UpdateChecker.kt | 59 ++++++++++++++++++- frontend/js/i18n/en.js | 3 + frontend/js/views/device-detail.js | 12 ++++ server/lib/player-capabilities.js | 3 + 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 750f548..40c7843 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -941,6 +941,14 @@ class MainActivity : AppCompatActivity() { if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true) } // #161 device-owner tooling: push + silently install an arbitrary APK from a URL. + // Escape hatch for a panel holding a stale/bad staged APK: drop every cached file + // so the next check downloads afresh. Only ever deletes caches. + "clear_update_cache" -> { + if (::updateChecker.isInitialized) { + val n = updateChecker.clearUpdateCache() + Log.i("MainActivity", "clear_update_cache removed $n file(s)") + } + } "install_apk" -> { val url = payload?.optString("url", "") ?: "" if (url.isNotBlank() && ::updateChecker.isInitialized) { diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index ba01b13..83deea7 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -415,7 +415,7 @@ class UpdateChecker(private val context: Context) { // #139: reuse a previously-downloaded, verified APK for this version instead of // re-pulling ~8.7 MB every cycle. The file also stays on disk as the artifact for a // manual install when silent install isn't possible. - if (apkFile.exists() && verifyApkSignature(apkFile)) { + if (apkFile.exists() && cachedApkIs(apkFile, version) && verifyApkSignature(apkFile)) { Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)") handler.post { installApk(apkFile) } return true @@ -448,6 +448,17 @@ class UpdateChecker(private val context: Context) { // Verify the downloaded APK is our package AND signed by the same key as // the currently-installed app before installing. An attacker can't forge // our signature, so this holds even over an untrusted transport. + // The server advertises a version and separately serves a file; the two can drift. A + // stale APK behind a current version number installs as a NO-OP, so the version never + // changes, the update is attempted again, and the panel loops until its attempts are + // spent — reporting a download failure, which it is not. Say what actually happened. + if (!cachedApkIs(apkFile, version)) { + val got = apkVersionName(apkFile) ?: "unreadable" + lastFailure = "server served $got but advertised $version — the update on the server is stale" + Log.e(TAG, "Version mismatch: advertised $version, downloaded $got") + apkFile.delete() + return false + } if (!verifyApkSignature(apkFile)) { // lastFailure was set precisely inside verifyApkSignature; keep it, and add the // size so a truncated download is distinguishable from a genuine cert mismatch. @@ -585,6 +596,52 @@ class UpdateChecker(private val context: Context) { // True only if the downloaded APK is this same package and shares a signing // certificate with the installed app. Fail-closed on any error. + /* The versionName inside an APK file, or null if it cannot be read. */ + private fun apkVersionName(apkFile: File): String? = try { + context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)?.versionName + } catch (e: Throwable) { + Log.w(TAG, "Could not read version from ${apkFile.name}: ${e.message}") + null + } + + /* + * Is this file actually the version we mean to install? + * + * The cache is keyed by FILENAME, and the filename is built from the version the server + * advertised — so a file called ScreenTinker-1.9.34.apk containing 1.9.33 passes a signature + * check (same key), gets reused on every attempt, and installs as a no-op forever. Fixing the + * server does not clear it; only deleting the file does. Checking the version inside makes that + * self-healing instead of needing a hand on the device. + */ + private fun cachedApkIs(apkFile: File, version: String): Boolean { + val got = apkVersionName(apkFile) ?: return false + if (got == version) return true + Log.w(TAG, "Cached ${apkFile.name} contains $got, expected $version — discarding") + return false + } + + /* + * Delete every staged APK. The escape hatch for a panel holding a bad download: it forces the + * next check to fetch again rather than reuse. Safe at any time — these are only ever caches, + * re-fetched on demand. + */ + fun clearUpdateCache(): Int { + var n = 0 + for (dir in listOfNotNull( + File(context.filesDir, "Download"), + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), + File(context.cacheDir, "Download"), + )) { + val files = try { dir.listFiles() } catch (_: Throwable) { null } ?: continue + for (f in files) { + if (!f.name.endsWith(".apk")) continue + if (f.delete()) n++ + } + } + report("info", "Update cache cleared ($n file(s)) — the next check will download afresh") + return n + } + private fun verifyApkSignature(apkFile: File): Boolean { return try { val pm = context.packageManager diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 375981c..e2d65b5 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -683,6 +683,8 @@ export default { 'device.ctl.screen_on': 'Screen On', 'device.ctl.launch_player': 'Launch Player', 'device.ctl.force_update': 'Force Update', + 'device.ctl.clear_update_cache': 'Clear Update Cache', + 'device.ctl.clear_update_cache_tip': 'Delete any update file this display has already downloaded, so the next check fetches a fresh copy. Use if updates keep failing.', 'device.ctl.shutdown': 'Shutdown', // Remote tab 'device.remote.start_prompt': 'Click "Start Remote" to begin', @@ -773,6 +775,7 @@ export default { 'device.toast.screen_on_sent': 'Screen on command sent', 'device.toast.launch_sent': 'Launch command sent', 'device.toast.update_triggered': 'Update check triggered', + 'device.toast.update_cache_cleared': 'Update cache cleared — the next check will download afresh', 'device.toast.remote_started': 'Remote session started', 'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect', 'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 6c7e4a2..38e582f 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -501,6 +501,12 @@ async function loadDevice(deviceId, activeTab = null) { ${t('device.ctl.force_update')} + + ` : ''} ${can('system.reboot') ? `