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');