mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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:
parent
a9cf8747cb
commit
aa23cf02dd
|
|
@ -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()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue