From aa23cf02ddc8fed46e5f485760cd629069013d94 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 23 Jun 2026 19:53:55 -0500 Subject: [PATCH 1/2] fix(ota): stop OTA re-download loop on devices that cannot silently install (#139) Devices that download an OTA APK but cannot silently install it (Fire TV: no device-owner path) re-downloaded the full APK every check cycle indefinitely - install never completes, version never advances, next check re-triggers. Client (UpdateChecker.kt, ServerConfig.kt, OtaThrottle.kt): - Reuse a cached, signature-verified APK instead of re-downloading every cycle; delete leftover invalid files; keep the verified APK on disk as the manual-install artifact. - Persisted per-version attempt budget (EncryptedSharedPreferences) so it survives the Fire OS app restarts that drive the loop. An attempt is counted only when an install is launched - a download/verify failure does not consume the budget, so a transient network problem cannot park a healthy device in backoff. After 3 failed installs, back off to one retry per 24h. - Clear OTA state and caches when a check returns update_available=false while state is pending (app relaunched as the new version). - Report OTA status to the dashboard via device:log (tag ota) on state transitions only (enter-backoff, clear) to avoid flooding the channel. - Extract throttle decision logic into a pure OtaThrottle object (no Android deps) with JUnit coverage (OtaThrottleTest) for the state transitions. Server (server.js): - Reword /download/apk log from "OTA update in progress" to "APK served" and rate-limit to once per IP / 10 min so a looping device cannot flood the log. Note: client-cooperative fix - prevents the loop in cohorts running this APK. Currently-stuck beta4 devices still require a one-time manual update. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/remotedisplay/player/MainActivity.kt | 3 + .../remotedisplay/player/data/ServerConfig.kt | 33 ++++++ .../player/service/OtaThrottle.kt | 60 ++++++++++ .../player/service/UpdateChecker.kt | 108 ++++++++++++++++-- .../player/service/OtaThrottleTest.kt | 82 +++++++++++++ server/server.js | 15 ++- 6 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt create mode 100644 android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt 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 43893f3..f4cf854 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -240,6 +240,9 @@ class MainActivity : AppCompatActivity() { // Start auto-update checker updateChecker = UpdateChecker(this) + // #139: surface OTA status (applying / backing off / manual-update-required) to the + // dashboard. wsService is read lazily — it binds after this runs. + updateChecker.otaLogReporter = { level, msg -> wsService?.sendLog("ota", level, msg) } updateChecker.startPeriodicCheck() } diff --git a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt index 3fc34a5..58fb9c0 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/ServerConfig.kt @@ -71,4 +71,37 @@ class ServerConfig(context: Context) { fun clearPlaylistCache() { prefs.edit().remove("cached_playlist").apply() } + + // #139 OTA attempt state. Persisted (not in-memory) on purpose: the OTA loop is driven + // by Fire OS restarting the app, which re-fires the update check; an in-memory counter + // would reset on every restart and never back off. `otaTargetVersion` is the version we + // are currently trying to install; `otaAttempts` counts install attempts for it; + // `otaLastAttemptAt` gates the post-cap retry backoff. + var otaTargetVersion: String + get() = prefs.getString("ota_target_version", "") ?: "" + set(value) = prefs.edit().putString("ota_target_version", value).apply() + + var otaAttempts: Int + get() = prefs.getInt("ota_attempts", 0) + set(value) = prefs.edit().putInt("ota_attempts", value).apply() + + var otaLastAttemptAt: Long + get() = prefs.getLong("ota_last_attempt_at", 0L) + set(value) = prefs.edit().putLong("ota_last_attempt_at", value).apply() + + // #139: true once the "entering backoff" status has been reported for the current target, + // so the dashboard line fires on the transition only — not on every backed-off poll (Fire OS + // restarts re-fire the check constantly). Reset on a new target / on clear. + var otaBackoffReported: Boolean + get() = prefs.getBoolean("ota_backoff_reported", false) + set(value) = prefs.edit().putBoolean("ota_backoff_reported", value).apply() + + fun clearOtaState() { + prefs.edit() + .remove("ota_target_version") + .remove("ota_attempts") + .remove("ota_last_attempt_at") + .remove("ota_backoff_reported") + .apply() + } } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt new file mode 100644 index 0000000..7db5bf7 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt @@ -0,0 +1,60 @@ +package com.remotedisplay.player.service + +/** + * #139: pure OTA throttle decision logic — no Android dependencies, so it's unit-testable + * (see OtaThrottleTest). UpdateChecker is the imperative shell: it reads/writes the persisted + * fields (ServerConfig / EncryptedSharedPreferences) and performs the actual download + install; + * this object owns the stateful RULES so they have coverage beyond a compile: + * + * - a new target version resets the attempt budget, + * - a check NEVER consumes the budget — only a launched install does (so a transient + * download/network failure can't park a healthy device in backoff), + * - after MAX_INSTALL_ATTEMPTS failed installs, back off to one retry per BACKOFF_MS, + * - the "entering backoff" signal fires on the crossing only (report-on-transition). + */ +object OtaThrottle { + const val MAX_INSTALL_ATTEMPTS = 3 + const val BACKOFF_MS = 24L * 60 * 60 * 1000 + + /** Persisted OTA state for the version we are currently trying to install. */ + data class State( + val targetVersion: String = "", + val attempts: Int = 0, + val lastAttemptAt: Long = 0L, + val backoffReported: Boolean = false + ) + + enum class Action { ATTEMPT, BACKOFF } + + /** True when [latestVersion] differs from the persisted target — caller drops stale APKs. */ + fun isNewTarget(state: State, latestVersion: String): Boolean = state.targetVersion != latestVersion + + /** + * A check found [latestVersion] available. Returns the state to persist (reset on a new + * target) and whether to attempt now. Does NOT count an attempt: the budget is consumed + * only once an install is actually launched (see [onInstallLaunched]). + */ + fun onUpdateAvailable(state: State, latestVersion: String, now: Long): Pair { + val s = if (isNewTarget(state, latestVersion)) State(targetVersion = latestVersion) else state + if (s.attempts >= MAX_INSTALL_ATTEMPTS && now - s.lastAttemptAt < BACKOFF_MS) { + return s to Action.BACKOFF + } + return s to Action.ATTEMPT + } + + /** + * An install was actually launched (a verified APK was in hand). Consumes one attempt and + * returns the new state plus whether this attempt is the FIRST to cross the cap into backoff + * (true => caller reports "manual update required" once; false on all later polls). + */ + fun onInstallLaunched(state: State, now: Long): Pair { + val attempts = state.attempts + 1 + var s = state.copy(attempts = attempts, lastAttemptAt = now) + val enteredBackoff = attempts >= MAX_INSTALL_ATTEMPTS && !s.backoffReported + if (enteredBackoff) s = s.copy(backoffReported = true) + return s to enteredBackoff + } + + /** A check found us already on the latest. True if there was pending OTA state to clear. */ + fun shouldClearOnUpToDate(state: State): Boolean = state.targetVersion.isNotEmpty() +} 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 c9e29d3..77372ce 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 @@ -39,6 +39,17 @@ class UpdateChecker(private val context: Context) { private var installReceiverRegistered = false + // #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity + // to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter. + // The throttle thresholds + decision rules live in OtaThrottle (pure, unit-tested); this + // class is the imperative shell that persists state and does the download/install. + var otaLogReporter: ((level: String, message: String) -> Unit)? = null + + private fun report(level: String, message: String) { + when (level) { "error" -> Log.e(TAG, message); "warn" -> Log.w(TAG, message); else -> Log.i(TAG, message) } + try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {} + } + // The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION, // which Android 13+ returns for non-device-owner installers) via this broadcast. // Without handling it the committed session just stalls and the update never @@ -59,6 +70,8 @@ class UpdateChecker(private val context: Context) { catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") } } } + // Logcat only — NOT report(): these fire per attempt, and #139 keeps the + // device:log/dashboard channel to state transitions (enter-backoff, clear). android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully") else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}") } @@ -116,9 +129,16 @@ class UpdateChecker(private val context: Context) { Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable") - if (updateAvailable && downloadUrl.isNotEmpty()) { - Log.i(TAG, "Update available! Downloading...") - downloadAndInstall("${config.serverUrl}$downloadUrl", latestVersion) + if (!updateAvailable) { + // #139: on the latest version now. If OTA state was pending, the install + // landed (the app relaunched as the new version) — clear state + caches once. + if (OtaThrottle.shouldClearOnUpToDate(otaState())) { + report("info", "OTA complete: now on $currentVersion — clearing update state") + config.clearOtaState() + cleanupApks(null) + } + } else if (downloadUrl.isNotEmpty()) { + maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") } } catch (e: Exception) { Log.e(TAG, "Update check error: ${e.message}") @@ -126,20 +146,88 @@ class UpdateChecker(private val context: Context) { }.start() } - private fun downloadAndInstall(url: String, version: String) { + private fun otaState() = OtaThrottle.State( + config.otaTargetVersion, config.otaAttempts, config.otaLastAttemptAt, config.otaBackoffReported) + + private fun persistOta(s: OtaThrottle.State) { + config.otaTargetVersion = s.targetVersion + config.otaAttempts = s.attempts + config.otaLastAttemptAt = s.lastAttemptAt + config.otaBackoffReported = s.backoffReported + } + + // #139 imperative shell over OtaThrottle (the pure, unit-tested decision logic). A device + // that can't silently install (Fire TV: no device-owner) stops re-pulling the full APK every + // cycle. Only a COMMITTED install consumes the attempt budget — a transient download/verify + // failure on a HEALTHY device must never park it in backoff. + private fun maybeUpdate(latestVersion: String, downloadUrl: String) { + val now = System.currentTimeMillis() + val cur = otaState() + if (OtaThrottle.isNewTarget(cur, latestVersion)) cleanupApks(latestVersion) + + val (afterCheck, action) = OtaThrottle.onUpdateAvailable(cur, latestVersion, now) + persistOta(afterCheck) + // Capped + still inside the window: do nothing AND stay silent. Fire OS restarts re-fire + // this check constantly; reporting here would just move the flood onto the WS channel. + // The enter-backoff line was already sent once on the crossing (below). + if (action == OtaThrottle.Action.BACKOFF) return + + // download/verify failure → retry on the normal cadence; do NOT count it as an attempt. + if (!downloadAndInstall(downloadUrl, latestVersion)) { + Log.w(TAG, "Update $latestVersion: download/verify failed — retry next check (no attempt consumed)") + return + } + + val (afterLaunch, enteredBackoff) = OtaThrottle.onInstallLaunched(afterCheck, now) + persistOta(afterLaunch) + Log.i(TAG, "Install launched for $latestVersion (attempt ${afterLaunch.attempts}/${OtaThrottle.MAX_INSTALL_ATTEMPTS})") + if (enteredBackoff) { + report("warn", "Update $latestVersion available but not installing after ${afterLaunch.attempts} attempts — manual update required (backing off to one retry per ${OtaThrottle.BACKOFF_MS / 3_600_000L}h)") + } + } + + // #139: remove cached OTA APKs other than `keep` (null = remove all). Keeps the external + // files dir from accumulating one stale APK per superseded version. + private fun cleanupApks(keep: String?) { try { + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return + val keepName = keep?.let { "ScreenTinker-$it.apk" } + dir.listFiles { f -> + f.name.startsWith("ScreenTinker-") && f.name.endsWith(".apk") && f.name != keepName + }?.forEach { it.delete() } + } catch (e: Exception) { + Log.w(TAG, "APK cleanup failed: ${e.message}") + } + } + + // Returns TRUE only when a verified APK is in hand and an install has been launched (the + // caller may then count an attempt); FALSE on any download/verify failure — the caller must + // NOT count those, so a transient network problem can't burn a healthy device's budget. #139 + private fun downloadAndInstall(url: String, version: String): Boolean { + try { + val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), + "ScreenTinker-$version.apk") + + // #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)) { + Log.i(TAG, "Reusing cached verified APK: ${apkFile.absolutePath} (${apkFile.length()} bytes)") + handler.post { installApk(apkFile) } + return true + } + // A leftover but invalid file (partial/corrupt/tampered) must never be reused. + if (apkFile.exists()) apkFile.delete() + // Download to a temp file val request = Request.Builder().url(url).build() val response = client.newCall(request).execute() if (!response.isSuccessful) { Log.e(TAG, "Download failed: ${response.code}") - return + return false } - val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - "ScreenTinker-$version.apk") - response.body?.byteStream()?.use { input -> apkFile.outputStream().use { output -> input.copyTo(output) @@ -158,7 +246,7 @@ class UpdateChecker(private val context: Context) { if (!verifyApkSignature(apkFile)) { Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)") apkFile.delete() - return + return false } Log.i(TAG, "APK signature verified against installed app - proceeding to install") @@ -166,8 +254,10 @@ class UpdateChecker(private val context: Context) { handler.post { installApk(apkFile) } + return true } catch (e: Exception) { Log.e(TAG, "Download/install error: ${e.message}") + return false } } diff --git a/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt new file mode 100644 index 0000000..6dae03d --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt @@ -0,0 +1,82 @@ +package com.remotedisplay.player.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #139: coverage for the OTA throttle state machine (the stateful core that the OTA + * re-download-loop fix depends on), independent of Android. UpdateChecker is just the shell. + */ +class OtaThrottleTest { + + private val V = "1.9.1-beta6" + private val MAX = OtaThrottle.MAX_INSTALL_ATTEMPTS + private val WINDOW = OtaThrottle.BACKOFF_MS + + // Launch `n` installs from `start`, returning the resulting state. + private fun launch(start: OtaThrottle.State, n: Int, now: Long = 1000L): OtaThrottle.State { + var s = start + repeat(n) { s = OtaThrottle.onInstallLaunched(s, now + it).first } + return s + } + + @Test fun newTargetResetsBudget() { + val stale = OtaThrottle.State(targetVersion = "1.9.1-beta5", attempts = 2, lastAttemptAt = 1000, backoffReported = true) + assertTrue(OtaThrottle.isNewTarget(stale, V)) + val (s, action) = OtaThrottle.onUpdateAvailable(stale, V, now = 5000) + assertEquals(V, s.targetVersion) + assertEquals(0, s.attempts) + assertEquals(0L, s.lastAttemptAt) + assertFalse(s.backoffReported) + assertEquals(OtaThrottle.Action.ATTEMPT, action) + } + + @Test fun aCheckNeverConsumesBudget_onlyInstallLaunchedDoes() { + var s = OtaThrottle.State(targetVersion = V, attempts = 0) + // Repeated checks (e.g. each followed by a failed download) must not advance the counter. + repeat(5) { + val (ns, action) = OtaThrottle.onUpdateAvailable(s, V, now = 100) + assertEquals(OtaThrottle.Action.ATTEMPT, action) + assertEquals(0, ns.attempts) + s = ns + } + // Only a launched install increments. + assertEquals(1, OtaThrottle.onInstallLaunched(s, now = 200).first.attempts) + } + + @Test fun capThenBackoffWithinWindow() { + val s = launch(OtaThrottle.State(targetVersion = V), MAX, now = 1000L) + assertEquals(MAX, s.attempts) + assertTrue(s.backoffReported) + // A check inside the window → BACKOFF, no further attempt, state unchanged. + val (ns, action) = OtaThrottle.onUpdateAvailable(s, V, now = 1000L + WINDOW - 1) + assertEquals(OtaThrottle.Action.BACKOFF, action) + assertEquals(MAX, ns.attempts) + } + + @Test fun enterBackoffSignalsExactlyOnce() { + var s = OtaThrottle.State(targetVersion = V) + var crossings = 0 + repeat(MAX + 3) { i -> + val (ns, entered) = OtaThrottle.onInstallLaunched(s, now = i.toLong()) + if (entered) crossings++ + s = ns + } + assertEquals("enter-backoff fires only on the crossing", 1, crossings) + } + + @Test fun retryAfterWindowElapsedDoesNotReReport() { + val capped = OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = 0L, backoffReported = true) + val (afterCheck, action) = OtaThrottle.onUpdateAvailable(capped, V, now = WINDOW + 1) + assertEquals(OtaThrottle.Action.ATTEMPT, action) // window elapsed → one retry allowed + val (_, entered) = OtaThrottle.onInstallLaunched(afterCheck, now = WINDOW + 2) + assertFalse("already reported entering backoff — must not report again", entered) + } + + @Test fun clearsOnSuccessOnlyWhenPending() { + assertTrue(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State(targetVersion = V, attempts = 2))) + assertFalse(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State())) // nothing pending + } +} diff --git a/server/server.js b/server/server.js index fc30068..687cd54 100644 --- a/server/server.js +++ b/server/server.js @@ -710,13 +710,22 @@ function resolveApkPath() { return null; } +// #139: a device that can't silently install re-downloads the APK every check cycle. Don't +// word a download as "in progress" (it may be a stuck loop, not progress), and rate-limit the +// line to once per IP per window so a looping device can't flood the log. +const otaDownloadLoggedAt = new Map(); // ip -> last-logged ms +const OTA_DOWNLOAD_LOG_WINDOW_MS = 10 * 60 * 1000; + // Serve APK download app.get('/download/apk', (req, res) => { const apkPath = resolveApkPath(); if (apkPath) { - // #96: an APK download means a device is actually applying an OTA - log it so the - // update is observable end to end (check -> download -> [relaunch]). - console.log(`[ota] APK download by ${getClientIp(req)} (${fs.statSync(apkPath).size} bytes) - OTA update in progress`); + const ip = getClientIp(req); + const now = Date.now(); + if (now - (otaDownloadLoggedAt.get(ip) || 0) > OTA_DOWNLOAD_LOG_WINDOW_MS) { + otaDownloadLoggedAt.set(ip, now); + console.log(`[ota] APK served to ${ip} (${fs.statSync(apkPath).size} bytes)`); + } res.setHeader('Content-Type', 'application/vnd.android.package-archive'); res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"'); res.setHeader('Cache-Control', 'no-cache'); From 0c0a8dd68ac99181d3353fd7d5b2e79ea4ea1d59 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 23 Jun 2026 22:49:01 -0500 Subject: [PATCH 2/2] fix(ota): surface stuck OTA on dashboard + read APK signer correctly on API 28/29 (#139) Follow-up to the cache/backoff loop fix (aa23cf0): make a device that can't self-install visible to operators, and fix the signature-verify bug that kept the whole #139 fix from engaging on the actual Fire OS target. Dashboard surface (Phase 2): - devices gains ota_status / ota_target_version / ota_attempts / ota_updated_at via the idempotent ALTER TABLE ADD COLUMN migration (non-destructive, default-backfilled, idempotent on re-run). - The device reports ota_status (OtaThrottle.statusFor -> none | pending | manual_update_required) in device_info; the server persists it on register (the reconnect backstop). devices d.* already surfaces it to the dashboard. - Dashboard shows a non-blocking amber badge when manual_update_required ("Update available (vX) - install failed N times, manual update required"); i18n key in en.js (non-en inherits via the en fallback). Server suite +1 test. Event-driven status (Option B): - New device:ota-status WS message, emitted on STATE TRANSITIONS only (enter-backoff -> manual_update_required, clear -> none), so the badge updates promptly without waiting for a reconnect and without per-poll/heartbeat chatter. Server handler persists the same fields; an unknown/forged device_id is a safe no-op. The register-path persist stays as the reconnect backstop. Signature-verify fix (the critical piece): verifyApkSignature read the downloaded APK's signer via getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo, but that field is null for ARCHIVE files on API 28/29 (populated only from API 30). On Fire OS 8 (Android 9 / API 28) - the actual deployment target - this returned 0 certs from a correctly-signed APK, so every OTA was refused as "tampered," the cache was deleted, and the full APK re-downloaded every check cycle. This was the real cause of the #139 re-download loop, NOT a silent-install failure: the cache and backoff added in this branch sit behind this verify gate and never engaged on the target. Fix: below API 30, read the archive's signer via the legacy GET_SIGNATURES + .signatures (its v1/JAR cert, which IS populated on 28/29). Keep GET_SIGNING_CERTIFICATES + signingInfo for API >= 30 and for the installed-app read (which works on 28+). The archive's signer is still extracted and compared to the installed app's signer; a mismatch or zero-cert APK is still rejected. This reads the cert correctly on old APIs - it does not weaken verification. Verified on emulators: - API 28: verify now passes for a legit APK (was: 0 certs, refused). Full backoff then engages - 8.5MB pulled once, cache-hit on retries, backoff after 3, manual_update_required emitted once; clears on successful update. - API 28 negative: a re-signed (different-key) APK is still refused on cert MISMATCH - no hole opened. - API 30: unchanged path still passes (no regression). - server suite 173/173, OtaThrottleTest 7/7. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/remotedisplay/player/MainActivity.kt | 3 ++ .../player/service/OtaThrottle.kt | 14 ++++++ .../player/service/UpdateChecker.kt | 46 +++++++++++++++---- .../player/service/WebSocketService.kt | 16 +++++++ .../player/telemetry/DeviceInfo.kt | 9 ++++ .../player/service/OtaThrottleTest.kt | 15 ++++++ frontend/js/i18n/en.js | 2 + frontend/js/views/dashboard.js | 3 ++ server/db/database.js | 9 ++++ server/test/api.test.js | 26 +++++++++++ server/ws/deviceSocket.js | 22 ++++++++- 11 files changed, 155 insertions(+), 10 deletions(-) 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 f4cf854..8922f12 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -243,6 +243,9 @@ class MainActivity : AppCompatActivity() { // #139: surface OTA status (applying / backing off / manual-update-required) to the // dashboard. wsService is read lazily — it binds after this runs. updateChecker.otaLogReporter = { level, msg -> wsService?.sendLog("ota", level, msg) } + // #139 Phase 2 (Option B): announce OTA status transitions (clear / enter-backoff) so the + // dashboard badge clears/lights up promptly without waiting for a reconnect. + updateChecker.otaStatusReporter = { wsService?.sendOtaStatus() } updateChecker.startPeriodicCheck() } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt index 7db5bf7..bfa0e0e 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt @@ -57,4 +57,18 @@ object OtaThrottle { /** A check found us already on the latest. True if there was pending OTA state to clear. */ fun shouldClearOnUpToDate(state: State): Boolean = state.targetVersion.isNotEmpty() + + /** + * #139 Phase 2: operator-facing status for the dashboard. + * - "none" : no update pending. + * - "manual_update_required" : capped AND still inside the backoff window — this device + * can't self-install; a human needs to update it. + * - "pending" : an update is in progress / will retry (under the cap, or the + * window has elapsed so a retry is due). + */ + fun statusFor(state: State, now: Long): String = when { + state.targetVersion.isEmpty() -> "none" + state.attempts >= MAX_INSTALL_ATTEMPTS && now - state.lastAttemptAt < BACKOFF_MS -> "manual_update_required" + else -> "pending" + } } 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 77372ce..8efe400 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 @@ -50,6 +50,14 @@ class UpdateChecker(private val context: Context) { try { otaLogReporter?.invoke(level, message) } catch (_: Throwable) {} } + // #139 Phase 2 (Option B): announce an OTA status TRANSITION to the server (wired by + // MainActivity to WebSocketService.sendOtaStatus, which reads the just-persisted state). + // Fired ONLY at the two transitions — clear and enter-backoff — so the dashboard badge + // updates promptly without waiting for a reconnect, with no per-poll/heartbeat chatter. + // Lazy/null-safe so binding order doesn't matter, same as otaLogReporter. + var otaStatusReporter: (() -> Unit)? = null + private fun announceOtaStatus() { try { otaStatusReporter?.invoke() } catch (_: Throwable) {} } + // The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION, // which Android 13+ returns for non-device-owner installers) via this broadcast. // Without handling it the committed session just stalls and the update never @@ -136,6 +144,7 @@ class UpdateChecker(private val context: Context) { report("info", "OTA complete: now on $currentVersion — clearing update state") config.clearOtaState() cleanupApks(null) + announceOtaStatus() // transition -> emits 'none' so the badge clears promptly } } else if (downloadUrl.isNotEmpty()) { maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") @@ -183,6 +192,7 @@ class UpdateChecker(private val context: Context) { Log.i(TAG, "Install launched for $latestVersion (attempt ${afterLaunch.attempts}/${OtaThrottle.MAX_INSTALL_ATTEMPTS})") if (enteredBackoff) { report("warn", "Update $latestVersion available but not installing after ${afterLaunch.attempts} attempts — manual update required (backing off to one retry per ${OtaThrottle.BACKOFF_MS / 3_600_000L}h)") + announceOtaStatus() // transition -> emits 'manual_update_required' } } @@ -335,9 +345,18 @@ class UpdateChecker(private val context: Context) { private fun verifyApkSignature(apkFile: File): Boolean { return try { val pm = context.packageManager - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + // #139: getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo is NULL for + // ARCHIVE files on API 28/29 (it's only populated from API 30) — so the modern flag + // reads 0 certs from a downloaded APK and we'd wrongly REFUSE a legitimate update, + // which is the real Fire OS 8 / Android 9 OTA-loop cause. Below API 30, read the + // archive's signer via the legacy GET_SIGNATURES + .signatures (its v1/JAR cert, + // which IS populated on 28/29). This reads the cert CORRECTLY — it does not weaken + // verification: the archive's signer is still extracted and compared to the installed + // app's signer below, and a mismatch / zero-cert APK is still rejected. + val archiveUsesSigningInfo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // API 30 + val archiveFlags = if (archiveUsesSigningInfo) PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES - val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags) + val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, archiveFlags) if (downloaded == null) { Log.e(TAG, "Could not parse downloaded APK") return false @@ -346,14 +365,20 @@ class UpdateChecker(private val context: Context) { Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}") return false } - val installed = pm.getPackageInfo(context.packageName, flags) - val downloadedSigs = signingCertHashes(downloaded) - val installedSigs = signingCertHashes(installed) + // INSTALLED-app read: signingInfo IS populated for installed packages on API 28+, + // so keep the modern flag there (this side already worked). + val installedUsesSigningInfo = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P // API 28 + val installedFlags = if (installedUsesSigningInfo) + PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES + val installed = pm.getPackageInfo(context.packageName, installedFlags) + val downloadedSigs = signingCertHashes(downloaded, archiveUsesSigningInfo) + val installedSigs = signingCertHashes(installed, installedUsesSigningInfo) if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) { Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})") return false } - // Share at least one current signing certificate. + // Require a non-empty overlap of signer certs (handles multi-signer / cert-rotation + // the same way the API>=30 path does: compare the full current signer sets). val match = downloadedSigs.any { it in installedSigs } if (!match) Log.e(TAG, "APK signing certificate does not match installed app") match @@ -363,8 +388,13 @@ class UpdateChecker(private val context: Context) { } } - private fun signingCertHashes(info: PackageInfo): Set { - val sigs: Array? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Read the signer-cert SHA-256 set from a PackageInfo. `useSigningInfo` must match the flag + // it was fetched with: GET_SIGNING_CERTIFICATES -> signingInfo.apkContentsSigners (modern; + // multi-signer + rotation aware), GET_SIGNATURES -> legacy .signatures (the only field + // populated for ARCHIVE reads on API 28/29). Both yield the same cert for a normally-signed + // APK; the caller compares as sets so an overlapping signer still verifies. + private fun signingCertHashes(info: PackageInfo, useSigningInfo: Boolean): Set { + val sigs: Array? = if (useSigningInfo) { info.signingInfo?.apkContentsSigners } else { @Suppress("DEPRECATION") info.signatures diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 59a047c..68b6e23 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -560,6 +560,22 @@ class WebSocketService : Service() { } catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") } } + // #139 Phase 2 (Option B): announce an OTA status transition to the server so the dashboard + // badge updates promptly (not only on reconnect). Reads the just-persisted throttle state — + // the emit always reflects the stored truth. Called by UpdateChecker at clear / enter-backoff. + fun sendOtaStatus() { + if (socket?.connected() != true) return + try { + val s = OtaThrottle.State(config.otaTargetVersion, config.otaAttempts, config.otaLastAttemptAt, config.otaBackoffReported) + socket?.emit("device:ota-status", JSONObject().apply { + put("device_id", config.deviceId) + put("ota_status", OtaThrottle.statusFor(s, System.currentTimeMillis())) + put("ota_target_version", config.otaTargetVersion) + put("ota_attempts", config.otaAttempts) + }) + } catch (e: Throwable) { Log.w("WebSocketService", "sendOtaStatus: ${e.message}") } + } + fun sendPlaybackState(contentId: String, positionSec: Float) { if (socket?.connected() != true) return try { diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index 7b93e22..6f0ac58 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -13,6 +13,8 @@ import android.os.SystemClock import android.provider.Settings import android.util.DisplayMetrics import android.view.WindowManager +import com.remotedisplay.player.data.ServerConfig +import com.remotedisplay.player.service.OtaThrottle import java.security.MessageDigest import org.json.JSONObject @@ -49,6 +51,13 @@ class DeviceInfo(private val context: Context) { put("screen_height", outH) put("render_width", renW) put("render_height", renH) + // #139 Phase 2: report OTA backoff state (alongside app_version) so the dashboard can + // flag screens stuck in manual-update-required. Read from the persisted throttle state. + val cfg = ServerConfig(context) + val ota = OtaThrottle.State(cfg.otaTargetVersion, cfg.otaAttempts, cfg.otaLastAttemptAt, cfg.otaBackoffReported) + put("ota_status", OtaThrottle.statusFor(ota, System.currentTimeMillis())) + put("ota_target_version", cfg.otaTargetVersion) + put("ota_attempts", cfg.otaAttempts) } } diff --git a/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt index 6dae03d..e853bf1 100644 --- a/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt @@ -79,4 +79,19 @@ class OtaThrottleTest { assertTrue(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State(targetVersion = V, attempts = 2))) assertFalse(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State())) // nothing pending } + + @Test fun statusForReflectsBackoffWindow() { + val now = 10_000L + // no target → none + assertEquals("none", OtaThrottle.statusFor(OtaThrottle.State(), now)) + // under the cap → pending + assertEquals("pending", OtaThrottle.statusFor( + OtaThrottle.State(targetVersion = V, attempts = 1, lastAttemptAt = now), now)) + // capped AND inside the window → manual update required + assertEquals("manual_update_required", OtaThrottle.statusFor( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW - 1)) + // capped but window elapsed (a retry is due) → pending, not stuck + assertEquals("pending", OtaThrottle.statusFor( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW + 1)) + } } diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ab0a6b2..91eeff6 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -6,6 +6,8 @@ export default { 'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.", 'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign", 'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign", + // #139: device stuck in OTA backoff (can't self-install — e.g. Fire TV) — needs a manual update. + 'dashboard.device_ota_stuck': 'Update available (v{version}) — install failed {n}×, manual update required', // Nav (sidebar) 'nav.displays': 'Displays', 'nav.content': 'Content', diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 06a65b1..4725611 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -117,6 +117,9 @@ function renderDeviceCard(device) {
${esc(device.name)}${device.orphan_count > 0 ? ` ${device.orphan_count} + ` : ''}${device.ota_status === 'manual_update_required' ? ` + + update ` : ''}
${device.owner_name || device.owner_email ? `
diff --git a/server/db/database.js b/server/db/database.js index e7d9f07..79b9941 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -216,6 +216,15 @@ const migrations = [ // signal, so the two differ — surfacing both explains "reports 720 but monitor sees 1080". "ALTER TABLE devices ADD COLUMN render_width INTEGER", "ALTER TABLE devices ADD COLUMN render_height INTEGER", + // #139 Phase 2: device-reported OTA backoff status, so the dashboard can flag screens that + // can't self-install (Fire TV: no device-owner path) and need a hands-on update. ADD COLUMN + // with defaults is non-destructive in SQLite, and the apply loop below swallows "duplicate + // column" — so this is idempotent and upgrades an existing populated db without data loss. + // ota_updated_at = server receipt time (s), stamped on each register persist. + "ALTER TABLE devices ADD COLUMN ota_status TEXT DEFAULT 'none'", + "ALTER TABLE devices ADD COLUMN ota_target_version TEXT", + "ALTER TABLE devices ADD COLUMN ota_attempts INTEGER DEFAULT 0", + "ALTER TABLE devices ADD COLUMN ota_updated_at INTEGER", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/test/api.test.js b/server/test/api.test.js index 13d3291..e31d460 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -259,6 +259,32 @@ test('device WS: wrong device_token is rejected (auth-error, never registered)', assert.ok(!got.registered, 'wrong token must not register'); }); +// #139 Phase 2 (Option B): event-driven OTA status. Registers (which, with no ota fields in +// device_info, persists ota_status='none' via the backstop), then emits a valid ota-status and +// a foreign-id one in order on the authenticated socket. +function deviceOtaSeq(payload, otaEvents, timeoutMs = 4000) { + return new Promise((resolve) => { + const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true }); + const finish = () => { try { sock.close(); } catch { /* */ } resolve(); }; + sock.on('connect', () => sock.emit('device:register', payload)); + sock.on('device:registered', () => { for (const e of otaEvents) sock.emit('device:ota-status', e); setTimeout(finish, 500); }); + sock.on('device:auth-error', finish); + setTimeout(finish, timeoutMs); + }); +} +test('device WS: device:ota-status persists the fields; a foreign device_id is a safe no-op (#139)', async () => { + await deviceOtaSeq( + { device_id: S.deviceId, device_token: S.deviceToken, device_info: { app_version: 'test' } }, + [ + { device_id: S.deviceId, ota_status: 'manual_update_required', ota_target_version: '1.9.1-beta6', ota_attempts: 3 }, + { device_id: 'nope-not-a-device', ota_status: 'none', ota_target_version: null, ota_attempts: 0 }, // foreign id -> no-op, no throw + ]); + const dev = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt)); + assert.equal(dev.body.ota_status, 'manual_update_required', 'valid ota-status persisted'); + assert.equal(dev.body.ota_target_version, '1.9.1-beta6'); + assert.equal(dev.body.ota_attempts, 3, 'and the foreign-id event did not overwrite it'); +}); + // ───────────────────────── TIER 4: #92 FOLLOW-UP COVERAGE ───────────────────────── // The non-security gaps named in the self-review (issue #92): the gap-fix fields + the // cross-tenant guard (the security-relevant one), docs serving, and the token lifecycle diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index da9dc35..b15c038 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -372,8 +372,12 @@ module.exports = function setupDeviceSocket(io) { } if (device_info) { - db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ? WHERE id = ?') - .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_info.render_width ?? null, device_info.render_height ?? null, device_id); + db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?, + ota_status = ?, ota_target_version = ?, ota_attempts = ?, ota_updated_at = strftime('%s','now') WHERE id = ?`) + .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_info.render_width ?? null, device_info.render_height ?? null, + // #139 Phase 2: older APKs don't send these — default to a clean 'none' state. + device_info.ota_status ?? 'none', device_info.ota_target_version ?? null, device_info.ota_attempts ?? 0, + device_id); } heartbeat.registerConnection(device_id, socket.id); @@ -585,6 +589,20 @@ module.exports = function setupDeviceSocket(io) { }); }); + // #139 Phase 2 (Option B): event-driven OTA status. The device announces a status TRANSITION + // ('manual_update_required' on enter-backoff, 'none' on clear) so the dashboard badge updates + // promptly without waiting for a reconnect. The register path still persists these fields too + // (the reconnect backstop if a transition event is missed). Same columns + ?? defaults. + socket.on('device:ota-status', (data) => { + if (!requireDeviceAuth()) return; + const { device_id, ota_status, ota_target_version, ota_attempts } = data || {}; + // Unknown / forged / mismatched id -> no-op. WHERE id = ? also makes an unregistered id a + // 0-row update (never throws), so a stray event can't error the socket. + if (!device_id || device_id !== currentDeviceId) return; + db.prepare("UPDATE devices SET ota_status = ?, ota_target_version = ?, ota_attempts = ?, ota_updated_at = strftime('%s','now') WHERE id = ?") + .run(ota_status ?? 'none', ota_target_version ?? null, ota_attempts ?? 0, device_id); + }); + // Play event logging (proof-of-play) socket.on('device:play-event', (data) => { if (!requireDeviceAuth()) return;