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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-23 19:53:55 -05:00
parent a9cf8747cb
commit aa23cf02dd
6 changed files with 289 additions and 12 deletions

View file

@ -240,6 +240,9 @@ class MainActivity : AppCompatActivity() {
// Start auto-update checker // Start auto-update checker
updateChecker = UpdateChecker(this) 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() updateChecker.startPeriodicCheck()
} }

View file

@ -71,4 +71,37 @@ class ServerConfig(context: Context) {
fun clearPlaylistCache() { fun clearPlaylistCache() {
prefs.edit().remove("cached_playlist").apply() 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()
}
} }

View file

@ -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<State, Action> {
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<State, Boolean> {
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()
}

View file

@ -39,6 +39,17 @@ class UpdateChecker(private val context: Context) {
private var installReceiverRegistered = false 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, // The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION,
// which Android 13+ returns for non-device-owner installers) via this broadcast. // which Android 13+ returns for non-device-owner installers) via this broadcast.
// Without handling it the committed session just stalls and the update never // 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}") } 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") 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)}") 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") Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable")
if (updateAvailable && downloadUrl.isNotEmpty()) { if (!updateAvailable) {
Log.i(TAG, "Update available! Downloading...") // #139: on the latest version now. If OTA state was pending, the install
downloadAndInstall("${config.serverUrl}$downloadUrl", latestVersion) // 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) { } catch (e: Exception) {
Log.e(TAG, "Update check error: ${e.message}") Log.e(TAG, "Update check error: ${e.message}")
@ -126,20 +146,88 @@ class UpdateChecker(private val context: Context) {
}.start() }.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 { 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 // Download to a temp file
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
Log.e(TAG, "Download failed: ${response.code}") 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 -> response.body?.byteStream()?.use { input ->
apkFile.outputStream().use { output -> apkFile.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
@ -158,7 +246,7 @@ class UpdateChecker(private val context: Context) {
if (!verifyApkSignature(apkFile)) { if (!verifyApkSignature(apkFile)) {
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)") Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete() apkFile.delete()
return return false
} }
Log.i(TAG, "APK signature verified against installed app - proceeding to install") Log.i(TAG, "APK signature verified against installed app - proceeding to install")
@ -166,8 +254,10 @@ class UpdateChecker(private val context: Context) {
handler.post { handler.post {
installApk(apkFile) installApk(apkFile)
} }
return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Download/install error: ${e.message}") Log.e(TAG, "Download/install error: ${e.message}")
return false
} }
} }

View file

@ -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
}
}

View file

@ -710,13 +710,22 @@ function resolveApkPath() {
return null; 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 // Serve APK download
app.get('/download/apk', (req, res) => { app.get('/download/apk', (req, res) => {
const apkPath = resolveApkPath(); const apkPath = resolveApkPath();
if (apkPath) { if (apkPath) {
// #96: an APK download means a device is actually applying an OTA - log it so the const ip = getClientIp(req);
// update is observable end to end (check -> download -> [relaunch]). const now = Date.now();
console.log(`[ota] APK download by ${getClientIp(req)} (${fs.statSync(apkPath).size} bytes) - OTA update in progress`); 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-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"'); res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');