mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Merge fix/ota-redownload-loop (#140): stop OTA re-download loop on devices that can't silently install (#139)
This commit is contained in:
commit
a6fe849c67
|
|
@ -240,6 +240,12 @@ 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) }
|
||||
// #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()
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
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()
|
||||
|
||||
/**
|
||||
* #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"
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,25 @@ 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) {}
|
||||
}
|
||||
|
||||
// #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
|
||||
|
|
@ -59,6 +78,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 +137,17 @@ 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)
|
||||
announceOtaStatus() // transition -> emits 'none' so the badge clears promptly
|
||||
}
|
||||
} else if (downloadUrl.isNotEmpty()) {
|
||||
maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Update check error: ${e.message}")
|
||||
|
|
@ -126,20 +155,89 @@ 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)")
|
||||
announceOtaStatus() // transition -> emits 'manual_update_required'
|
||||
}
|
||||
}
|
||||
|
||||
// #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 +256,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 +264,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,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
|
||||
|
|
@ -256,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
|
||||
|
|
@ -273,8 +388,13 @@ class UpdateChecker(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun signingCertHashes(info: PackageInfo): Set<String> {
|
||||
val sigs: Array<Signature>? = 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<String> {
|
||||
val sigs: Array<Signature>? = if (useSigningInfo) {
|
||||
info.signingInfo?.apkContentsSigners
|
||||
} else {
|
||||
@Suppress("DEPRECATION") info.signatures
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
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
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ function renderDeviceCard(device) {
|
|||
<div class="device-card-name">${esc(device.name)}${device.orphan_count > 0 ? `
|
||||
<span class="device-orphan-badge" title="${tn('dashboard.device_orphan_tip', device.orphan_count)}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--danger);vertical-align:middle">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>${device.orphan_count}
|
||||
</span>` : ''}${device.ota_status === 'manual_update_required' ? `
|
||||
<span class="device-ota-badge" title="${esc(t('dashboard.device_ota_stuck', { version: device.ota_target_version || '?', n: device.ota_attempts || 0 }))}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--warning);vertical-align:middle">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>update
|
||||
</span>` : ''}</div>
|
||||
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue