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') ? `