diff --git a/README.md b/README.md index 2d93841..9d68cd2 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,28 @@ Schema migrations run automatically on first boot — no manual migration comman | `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` | | `MAX_FILE_SIZE` | Largest upload the server will accept. Bytes, or a suffix (`2GB`, `1500MB`). **A reverse proxy caps this independently** — see below. | `500MB` | | `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` | +| `OTA_ALLOW_MANAGED_DEVICES` | Let Android players self-update even when an MDM/DPC owns the device. Off by default — see below before enabling. | `0` | + +#### Android players under an MDM + +By default a player **stands down from self-updating** when it detects that another device owner +(an MDM/DPC such as an EMM agent) manages the panel. The reasoning is that on a managed device the +install confirmation dialog cannot be reliably auto-dismissed, so it ends up sitting over customer +content — and the MDM is normally the thing distributing packages anyway. Such a panel reports +`manual_update_required` rather than going quiet, so it still shows up as needing attention. + +Set `OTA_ALLOW_MANAGED_DEVICES=1` if you run an MDM that does **not** distribute the player and you +want ScreenTinker's OTA to own updates instead. The server then advertises `allow_managed: true` in +`/api/update/check` and players stop standing down. + +Two things to know before enabling it: + +- **It does not grant the ability to install silently.** Unless the player is the device owner, or + the MDM has delegated `DELEGATION_PACKAGE_INSTALLATION` to it, Android still raises a confirm + dialog that somebody (or an accessibility service) has to accept. If installs are failing *with* + an MDM present, delegating that scope is usually the real fix — not this flag. +- **It is read by the player, not the server**, so only players new enough to understand + `allow_managed` honour it. Older players keep standing down regardless. #### Raising the upload limit 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 7c0e714..e4fdb42 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -745,8 +745,12 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } "update" -> { - Log.i("MainActivity", "Force update check triggered") - if (::updateChecker.isInitialized) updateChecker.checkForUpdate() + // FORCED: an operator aimed this at ONE device and is watching the screen. + // Ignores the backoff cap and the MDM stand-down, and reports the outcome back + // — including "nothing to do", which used to return in silence and made a + // working button look broken. + Log.i("MainActivity", "Force update check triggered (operator)") + if (::updateChecker.isInitialized) updateChecker.checkForUpdate(forced = true) } // #161 device-owner tooling: push + silently install an arbitrary APK from a URL. "install_apk" -> { diff --git a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt index d0b3b6a..11aa8e8 100644 --- a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt +++ b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt @@ -221,6 +221,16 @@ object ManagedLogic { // isProfileOwner is carried on Admin but intentionally NOT consulted — see above. return admins.any { it.packageName != ourPackage && it.isDeviceOwner } } + + /** + * Final say on whether self-OTA stands down: a foreign DPC owns installs AND the operator has + * not overridden it (server `allow_managed`, from OTA_ALLOW_MANAGED_DEVICES). + * + * [serverAllowsManaged] must arrive as FALSE when the server said nothing — an older server + * that has never heard of the field would otherwise read as permission. Absence is not consent. + */ + fun standDownFromSelfOta(foreignDpcOwnsInstalls: Boolean, serverAllowsManaged: Boolean): Boolean = + foreignDpcOwnsInstalls && !serverAllowsManaged } /** Pure tier decision, extracted so it's unit-testable without a device / DevicePolicyManager. */ diff --git a/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt index faf4fa2..8bb05c3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt @@ -13,7 +13,19 @@ package com.remotedisplay.player.service * - the "entering backoff" signal fires on the crossing only (report-on-transition). */ object OtaThrottle { - const val MAX_INSTALL_ATTEMPTS = 3 + // Why this is 40 and not 3: an attempt is nearly free. The APK is downloaded and signature- + // verified ONCE and then reused from cache, so attempts 2..N pull no bytes — the ~8.7MB + // re-download this throttle was built to stop is already prevented by the cache, not by the + // cap. What actually blocks these installs is a confirm dialog waiting for a human, and a + // human may walk past at any hour. Three tries inside one hour, then a day of silence, gave up + // long before anyone had a chance; ~40 keeps trying across a working day (30-minute cadence + // ≈ 20 hours) before falling back to the daily retry. + const val MAX_INSTALL_ATTEMPTS = 40 + // Telling the operator is a SEPARATE decision from giving up, and it has to stay early: + // flagging only at the cap would push "this panel needs attention" from ~1 hour out to ~20. + // After this many failed launches a human is demonstrably needed, so say so — and keep + // retrying anyway, because saying it costs nothing and stopping costs the update. + const val ATTEMPTS_BEFORE_FLAGGING = 3 const val BACKOFF_MS = 24L * 60 * 60 * 1000 /** Persisted OTA state for the version we are currently trying to install. */ @@ -50,9 +62,12 @@ object OtaThrottle { 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 + // Flag at ATTEMPTS_BEFORE_FLAGGING, not at the cap — the operator needs to know a human is + // required long before the device stops trying. Reported once (the latch is re-armed by a + // new target version or by a forced check, so a genuinely new situation is announced again). + val shouldFlag = attempts >= ATTEMPTS_BEFORE_FLAGGING && !s.backoffReported + if (shouldFlag) s = s.copy(backoffReported = true) + return s to shouldFlag } /** @@ -75,6 +90,18 @@ object OtaThrottle { ) to report } + /** + * An operator pressed "force update" on THIS device. That is a far stronger signal than the + * 30-minute timer, so it hands the attempt budget back: a device parked in backoff (or one + * that already burned all three attempts on an install nobody accepted) tries again straight + * away instead of waiting out the window. + * + * Target version is kept — this is "try again now", not "forget what you were doing". + * backoffReported resets too, so if it caps out again the operator hears about it again; + * that report is per-decision, not once per lifetime. + */ + fun onForcedCheck(state: State): State = state.copy(attempts = 0, backoffReported = false) + /** 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() @@ -88,7 +115,11 @@ object OtaThrottle { */ 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" + // Keyed on the FLAGGING threshold, not the cap: after this many launched installs failed to + // take, a human really is required, and that stays true while the device keeps retrying. + // Previously this also required being inside the backoff window, so a device that was still + // trying read as plain 'pending' and never surfaced. + state.attempts >= ATTEMPTS_BEFORE_FLAGGING -> "manual_update_required" else -> "pending" } } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index f6c281f..8933a06 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 @@ -113,7 +113,18 @@ class UpdateChecker(private val context: Context) { checkTimer = null } - fun checkForUpdate() { + /** + * [forced] = an operator pressed "force update" on this specific device, rather than the + * 30-minute timer firing. A forced run differs in three ways, all because a human aimed it at + * one panel and is watching: + * - it ignores the backoff cap (the budget is handed back, so a parked device retries NOW), + * - it overrides the MDM stand-down (a targeted human action outranks a blanket default), + * - it REPORTS what happened, including the nothing-to-do cases. + * That last one is the point. The dashboard toast only ever confirmed the command reached the + * socket; every reason the device might then do nothing returned silently, so a capped or + * managed panel looked identical to a working one. + */ + fun checkForUpdate(forced: Boolean = false) { if (config.serverUrl.isEmpty()) return Thread { @@ -138,10 +149,17 @@ class UpdateChecker(private val context: Context) { val updateAvailable = json.optBoolean("update_available", false) val latestVersion = json.optString("latest_version", currentVersion) val downloadUrl = json.optString("download_url", "") + // #166 escape hatch: the operator set OTA_ALLOW_MANAGED_DEVICES, so self-update is + // permitted even under a foreign DPC. Defaults FALSE, which is also what an older + // server (that never sends the field) yields — absence must never read as consent. + val allowManaged = json.optBoolean("allow_managed", false) Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable") if (!updateAvailable) { + // A forced check that finds nothing must SAY nothing-to-do. Silence here is + // what made the button look broken when it was working correctly. + if (forced) report("info", "Force update: already on the latest version ($currentVersion)") // #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())) { @@ -159,7 +177,13 @@ class UpdateChecker(private val context: Context) { // Checked HERE rather than before the request: standing down early meant a // stood-down panel never learned an update existed, so it reported ota_status // 'none' — indistinguishable from up to date — and no dashboard ever flagged it. - if (isManagedByForeignDeviceOwner()) { + // + // A forced run overrides it: the operator is aiming at ONE device and can see + // the screen, which is a stronger and better-targeted signal than the global + // OTA_ALLOW_MANAGED_DEVICES switch. + val managedNow = isManagedByForeignDeviceOwner() + if (com.remotedisplay.player.admin.ManagedLogic.standDownFromSelfOta( + managedNow, allowManaged || forced)) { val (managed, first) = OtaThrottle.onManagedStandDown( otaState(), latestVersion, System.currentTimeMillis()) persistOta(managed) @@ -170,7 +194,19 @@ class UpdateChecker(private val context: Context) { } return@Thread } - maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") + if (managedNow) { + // Loud on purpose: a safety default was overridden, and the confirm dialog + // this may raise over customer content is the cost of that choice. + val why = if (forced) "operator forced it" else "server allows managed self-update" + Log.i(TAG, "Managed by a foreign DPC, but $why — proceeding") + if (forced) report("warn", "Force update: this panel is managed by another device owner — installing anyway at your request; a confirm dialog may appear on screen") + } + if (forced) { + // Hand the attempt budget back so a device parked in backoff acts NOW + // instead of waiting out the window. + persistOta(OtaThrottle.onForcedCheck(otaState())) + } + maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl", forced) } } catch (e: Exception) { Log.e(TAG, "Update check error: ${e.message}") @@ -192,7 +228,7 @@ class UpdateChecker(private val context: Context) { // 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) { + private fun maybeUpdate(latestVersion: String, downloadUrl: String, forced: Boolean = false) { val now = System.currentTimeMillis() val cur = otaState() if (OtaThrottle.isNewTarget(cur, latestVersion)) cleanupApks(latestVersion) @@ -202,19 +238,36 @@ class UpdateChecker(private val context: Context) { // 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 + if (action == OtaThrottle.Action.BACKOFF) { + // Can only be reached unforced: a forced run hands the budget back before calling in. + if (forced) report("warn", "Force update: still backing off on $latestVersion — this should not happen, please report it") + 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)") + // Unforced this is deliberately quiet (transient network blips are not news). Forced, + // somebody is waiting on an answer, and "the APK would not download or did not match + // our signing key" is the single most useful thing we can tell them. + if (forced) report("error", "Force update: $latestVersion failed to download or failed signature verification — not installed") 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 (forced) { + // The APK is verified and the installer is launched — but off device-owner Android + // raises a confirm dialog, and "launched" is NOT "installed". Say which one happened, + // because the gap between them is exactly where force-update appears to do nothing. + report("info", if (canInstallSilently()) + "Force update: installing $latestVersion silently" + else + "Force update: $latestVersion downloaded and verified, install launched — a confirm dialog must be accepted on the device unless an accessibility service does it") + } 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)") + report("warn", "Update $latestVersion downloaded and verified, but ${afterLaunch.attempts} install attempts have not completed — a human needs to accept the install prompt on this device (or the MDM needs to delegate install permission). Still retrying.") announceOtaStatus() // transition -> emits 'manual_update_required' } } @@ -351,6 +404,17 @@ class UpdateChecker(private val context: Context) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val installer = context.packageManager.packageInstaller + // Abandon our own leftover sessions first. Every attempt stages a FULL copy of the + // APK (~8.7MB) via openWrite, and a session whose confirm dialog is never accepted + // just sits there holding it. At three attempts that was a rounding error; at forty + // it would be ~350MB of staged installs on a panel nobody walks up to, on hardware + // that does not have it spare. Also keeps us clear of the per-app session limit, + // which would start throwing once enough accumulated. + try { + for (s in installer.mySessions) { + try { installer.abandonSession(s.sessionId) } catch (_: Throwable) { /* already gone */ } + } + } catch (e: Throwable) { Log.w(TAG, "Session cleanup skipped: ${e.message}") } val params = android.content.pm.PackageInstaller.SessionParams( android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL ) @@ -476,4 +540,8 @@ class UpdateChecker(private val context: Context) { // now lives in STPolicy.hasForeignDeviceOwner() (same public getActiveAdmins() signal, errs safe). private fun isManagedByForeignDeviceOwner(): Boolean = com.remotedisplay.player.admin.STPolicy(context).hasForeignDeviceOwner() + + /** Device owner, or an MDM delegated install scope to us — i.e. no confirm dialog. */ + private fun canInstallSilently(): Boolean = + try { com.remotedisplay.player.admin.STPolicy(context).canInstallSilently() } catch (_: Throwable) { false } } diff --git a/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt index 66dce19..a19c516 100644 --- a/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt @@ -75,4 +75,27 @@ class ManagedLogicTest { val admins = listOf(ManagedLogic.Admin("com.mdm.dpc", isDeviceOwner = true)) assertFalse(ManagedLogic.foreignDpcOwnsInstalls(true, OURS, admins)) } + + // ---- #166 escape hatch: OTA_ALLOW_MANAGED_DEVICES -> server `allow_managed` ---------------- + + @Test fun a_managed_panel_stands_down_when_the_operator_has_not_overridden() { + assertTrue(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = true, serverAllowsManaged = false)) + } + + @Test fun the_override_lets_a_managed_panel_self_update() { + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = true, serverAllowsManaged = true)) + } + + @Test fun an_unmanaged_panel_never_stands_down_either_way() { + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = false, serverAllowsManaged = false)) + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = false, serverAllowsManaged = true)) + } + + @Test fun ABSENCE_IS_NOT_CONSENT_an_old_server_that_omits_the_field_reads_as_false() { + // The caller parses it with optBoolean("allow_managed", false). A server predating the + // flag says nothing, and that silence must mean "stand down", not "go ahead" — otherwise + // upgrading a PLAYER against an older SERVER would silently switch the safe default off. + val serverSaidNothing = false + assertTrue(ManagedLogic.standDownFromSelfOta(true, serverSaidNothing)) + } } diff --git a/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt new file mode 100644 index 0000000..75dce99 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt @@ -0,0 +1,93 @@ +package com.remotedisplay.player.service + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What the retry cadence ACTUALLY is once a device caps out, driven through the real OtaThrottle + * on a simulated timeline rather than read off the source. + * + * The question this settles: after MAX_INSTALL_ATTEMPTS, does a device get a fresh budget of 3 + * attempts every BACKOFF_MS, or a single attempt? It matters for a fleet whose installs fail at + * the confirm dialog — it is the difference between 3 dialogs a day and 1. + */ +class OtaBackoffCadenceTest { + + private val CHECK_INTERVAL = 30 * 60 * 1000L // UpdateChecker.CHECK_INTERVAL + private val TARGET = "1.9.23" + + /** Run `hours` of checks at the real 30-minute cadence; return the ms of each launched install. */ + private fun simulate(hours: Int, installSucceeds: Boolean = false): List { + var state = OtaThrottle.State() + val attemptsAt = mutableListOf() + var now = 0L + val end = hours.toLong() * 60 * 60 * 1000 + while (now <= end) { + val (afterCheck, action) = OtaThrottle.onUpdateAvailable(state, TARGET, now) + state = afterCheck + if (action == OtaThrottle.Action.ATTEMPT) { + // The device downloaded + verified an APK and launched the installer. It failing is + // the case under test: the install never completes, so the target never changes. + if (installSucceeds) break + val (afterLaunch, _) = OtaThrottle.onInstallLaunched(state, now) + state = afterLaunch + attemptsAt.add(now) + } + now += CHECK_INTERVAL + } + return attemptsAt + } + + @Test fun the_opening_burst_runs_at_the_check_cadence_until_the_cap() { + val MAX = OtaThrottle.MAX_INSTALL_ATTEMPTS + // Long enough to burn the whole budget: MAX attempts at one per check interval. + val at = simulate(hours = (MAX * CHECK_INTERVAL / 3_600_000L).toInt() + 2) + assertEquals(MAX, at.size) + // Back to back on consecutive checks — no artificial spacing before the cap. + assertEquals(listOf(0L, CHECK_INTERVAL, CHECK_INTERVAL * 2), at.take(3)) + assertEquals(CHECK_INTERVAL * (MAX - 1), at.last()) + } + + @Test fun the_burst_now_spans_a_working_day_not_an_hour() { + // The reason for raising the cap: a confirm dialog needs a human to walk past, and three + // tries inside one hour gave up long before anyone realistically would. + val spanMs = OtaThrottle.MAX_INSTALL_ATTEMPTS * CHECK_INTERVAL + assert(spanMs >= 12 * 60 * 60 * 1000L) { "burst should cover a working day, was ${spanMs / 3_600_000}h" } + } + + @Test fun THE_QUESTION_after_the_cap_it_is_ONE_attempt_per_24h_not_a_fresh_budget() { + val MAX = OtaThrottle.MAX_INSTALL_ATTEMPTS + val burstHours = (MAX * CHECK_INTERVAL / 3_600_000L).toInt() + val at = simulate(hours = burstHours + 24 * 3 + 2) + val afterBurst = at.drop(MAX) + assertEquals(3, afterBurst.size) // three further days -> three retries, not 3x3 + for (i in 1 until afterBurst.size) { + assertEquals("post-cap retries must be 24h apart", + OtaThrottle.BACKOFF_MS, afterBurst[i] - afterBurst[i - 1]) + } + } + + @Test fun why_it_is_one_and_not_three_attempts_never_reset_with_time() { + // Each post-cap attempt increments attempts AND refreshes lastAttemptAt, so the device is + // immediately back inside a fresh 24h window. Only a NEW target version resets the budget + // (isNewTarget -> State(targetVersion = ...)), never the passage of time. + var s = OtaThrottle.State(targetVersion = TARGET, attempts = OtaThrottle.MAX_INSTALL_ATTEMPTS, + lastAttemptAt = 0L, backoffReported = true) + val justPastWindow = OtaThrottle.BACKOFF_MS + 1 + assertEquals(OtaThrottle.Action.ATTEMPT, OtaThrottle.onUpdateAvailable(s, TARGET, justPastWindow).second) + s = OtaThrottle.onInstallLaunched(s, justPastWindow).first + assertEquals(OtaThrottle.MAX_INSTALL_ATTEMPTS + 1, s.attempts) // grows, never resets + // 30 minutes later it is capped again. + assertEquals(OtaThrottle.Action.BACKOFF, + OtaThrottle.onUpdateAvailable(s, TARGET, justPastWindow + CHECK_INTERVAL).second) + } + + @Test fun a_new_release_DOES_hand_back_a_full_budget_of_three() { + var s = OtaThrottle.State(targetVersion = TARGET, attempts = 9, + lastAttemptAt = 1_000L, backoffReported = true) + val (fresh, action) = OtaThrottle.onUpdateAvailable(s, "1.9.24", 2_000L) + assertEquals(OtaThrottle.Action.ATTEMPT, action) + assertEquals(0, fresh.attempts) + assertEquals("1.9.24", fresh.targetVersion) + } +} diff --git a/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt index 634864d..9b1ca0c 100644 --- a/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaThrottleTest.kt @@ -80,19 +80,29 @@ class OtaThrottleTest { assertFalse(OtaThrottle.shouldClearOnUpToDate(OtaThrottle.State())) // nothing pending } - @Test fun statusForReflectsBackoffWindow() { + @Test fun statusForFlagsEarlyAndStaysFlaggedWhileStillRetrying() { val now = 10_000L + val FLAG = OtaThrottle.ATTEMPTS_BEFORE_FLAGGING // no target → none assertEquals("none", OtaThrottle.statusFor(OtaThrottle.State(), now)) - // under the cap → pending + // below the flagging threshold → pending (it may still just work) assertEquals("pending", OtaThrottle.statusFor( - OtaThrottle.State(targetVersion = V, attempts = 1, lastAttemptAt = now), now)) - // capped AND inside the window → manual update required + OtaThrottle.State(targetVersion = V, attempts = FLAG - 1, lastAttemptAt = now), now)) + // at the threshold → a human is demonstrably needed, say so IMMEDIATELY. This is now far + // below the cap, so the dashboard flags in ~1h instead of waiting ~20h for the give-up. 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)) + OtaThrottle.State(targetVersion = V, attempts = FLAG, lastAttemptAt = now), now)) + // ...and STAYS flagged while the device keeps retrying. It used to drop back to 'pending' + // once the backoff window elapsed, so a panel needing hands looked healthy between retries. + assertEquals("manual_update_required", OtaThrottle.statusFor( + OtaThrottle.State(targetVersion = V, attempts = FLAG, lastAttemptAt = now), now + WINDOW + 1)) + assertEquals("manual_update_required", OtaThrottle.statusFor( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW * 5)) + } + + @Test fun flagging_happens_far_before_giving_up() { + // The whole point of splitting the two thresholds. + assert(OtaThrottle.ATTEMPTS_BEFORE_FLAGGING < OtaThrottle.MAX_INSTALL_ATTEMPTS) } // ---- #166: managed stand-down (a foreign DPC owns installs on this panel) -------------------- @@ -144,4 +154,50 @@ class OtaThrottleTest { assertEquals(MAX, s.attempts) assertTrue(s.backoffReported) } + + // ---- force update: an operator pressed the button on THIS device ---------------------------- + + @Test fun THE_POINT_a_forced_check_un_parks_a_capped_device_immediately() { + val now = 1_000_000L + // Capped and well inside the backoff window: the timer would do nothing for ~24h. + val capped = OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now, backoffReported = true) + assertEquals(OtaThrottle.Action.BACKOFF, OtaThrottle.onUpdateAvailable(capped, V, now + 60_000).second) + + val forced = OtaThrottle.onForcedCheck(capped) + assertEquals(OtaThrottle.Action.ATTEMPT, OtaThrottle.onUpdateAvailable(forced, V, now + 60_000).second) + } + + @Test fun forcing_keeps_the_target_it_is_try_again_not_start_over() { + val s = OtaThrottle.onForcedCheck( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = 5L, backoffReported = true)) + assertEquals(V, s.targetVersion) + assertEquals(0, s.attempts) + } + + @Test fun forcing_re_arms_the_backoff_report_so_a_second_cap_is_announced_again() { + // backoffReported is a once-per-decision latch, not once-per-lifetime: if the operator + // forces and it caps out AGAIN, that is news again. + val s = OtaThrottle.onForcedCheck( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = 5L, backoffReported = true)) + assertFalse(s.backoffReported) + val relaunched = generateSequence(s) { OtaThrottle.onInstallLaunched(it, 10L).first }.elementAt(MAX) + assertEquals(MAX, relaunched.attempts) + assertTrue(OtaThrottle.onInstallLaunched( + OtaThrottle.State(targetVersion = V, attempts = MAX - 1, lastAttemptAt = 5L, backoffReported = false), 10L).second) + } + + @Test fun forcing_gives_a_full_budget_not_a_single_shot() { + // After forcing, three attempts are available again before it re-caps. + var s = OtaThrottle.onForcedCheck( + OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = 0L, backoffReported = true)) + var t = 1_000L + var launched = 0 + repeat(MAX + 2) { + val (afterCheck, action) = OtaThrottle.onUpdateAvailable(s, V, t) + s = afterCheck + if (action == OtaThrottle.Action.ATTEMPT) { s = OtaThrottle.onInstallLaunched(s, t).first; launched++ } + t += 60_000 + } + assertEquals(MAX, launched) + } } diff --git a/server/config.js b/server/config.js index 3189cf4..64b5321 100644 --- a/server/config.js +++ b/server/config.js @@ -127,6 +127,14 @@ module.exports = { // Disable public registration (OAuth auto-signup is also blocked when set). // First-user setup is still allowed so a fresh install can be initialized. disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()), + // #166 escape hatch: let players self-update EVEN WHEN an MDM/DPC owns the device. + // Off by default, because the default is the safe one — on a managed panel the install + // confirm dialog can't be reliably auto-dismissed and ends up sitting over customer content, + // and the MDM is normally the thing that pushes packages. Set this only when you run an MDM + // that does NOT distribute the player and you want ScreenTinker's OTA to own updates instead. + // Advertised to players in /api/update/check as `allow_managed`; a player that doesn't + // understand the field simply keeps its own behaviour. + otaAllowManagedDevices: ['true', '1'].includes(String(process.env.OTA_ALLOW_MANAGED_DEVICES || '').toLowerCase()), // Redirect / -> /app instead of serving the marketing landing page. // For self-hosted internal deployments that don't want the public homepage. disableHomepage: ['true', '1'].includes(String(process.env.DISABLE_HOMEPAGE || '').toLowerCase()), diff --git a/server/server.js b/server/server.js index be9d87a..bfa2590 100644 --- a/server/server.js +++ b/server/server.js @@ -759,6 +759,11 @@ app.get('/api/update/check', (req, res) => { download_url: '/download/apk', apk_size: updateAvailable ? apk.size : 0, apk_modified: updateAvailable ? apk.mtime : 0, + // #166 escape hatch (OTA_ALLOW_MANAGED_DEVICES). Tells a player it may self-update even when + // a foreign DPC owns the device. Always present, so a player can distinguish "the operator + // said no" from "this server is too old to have an opinion" — both mean stand down, but only + // the first is a decision. + allow_managed: !!config.otaAllowManagedDevices, }); }); diff --git a/server/test/ota-allow-managed-config.test.js b/server/test/ota-allow-managed-config.test.js new file mode 100644 index 0000000..1cdcbe8 --- /dev/null +++ b/server/test/ota-allow-managed-config.test.js @@ -0,0 +1,52 @@ +'use strict'; + +// #166 escape hatch: OTA_ALLOW_MANAGED_DEVICES lets players self-update even when an MDM/DPC owns +// the device. The default has to be OFF, and "off" has to be the answer for every shape of a +// not-set / mistyped value — an operator who fat-fingers the variable must not silently get the +// unsafe behaviour, because the failure mode is an install confirm dialog parked over a customer's +// content on a fleet nobody is standing in front of. +// +// The value is also advertised to players as `allow_managed` in /api/update/check. A player that +// gets no field at all (older server) must read that as NO. Absence is not consent. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +function loadConfig(value) { + if (value === undefined) delete process.env.OTA_ALLOW_MANAGED_DEVICES; + else process.env.OTA_ALLOW_MANAGED_DEVICES = value; + delete require.cache[require.resolve('../config')]; + return require('../config'); +} + +test('THE DEFAULT: unset means managed devices do NOT self-update', () => { + const config = loadConfig(undefined); + assert.equal(config.otaAllowManagedDevices, false); + assert.equal(typeof config.otaAllowManagedDevices, 'boolean'); +}); + +test('the documented ways to turn it on', () => { + for (const v of ['1', 'true', 'TRUE', 'True']) { + assert.equal(loadConfig(v).otaAllowManagedDevices, true, `${v} should enable`); + } +}); + +test('everything else is OFF — a typo must not enable an unsafe default', () => { + // '0'/'false' are the explicit no. The rest are the fat-finger cases: they must land on the + // safe side rather than being treated as "any non-empty string is truthy". + for (const v of ['0', 'false', 'FALSE', 'no', 'off', 'yes', 'ture', 'enabled', '2', '', ' ']) { + assert.equal(loadConfig(v).otaAllowManagedDevices, false, `${JSON.stringify(v)} should stay off`); + } +}); + +test('it is always a real boolean, never a string, so the JSON field is unambiguous', () => { + // Players read this over the wire; the string "false" is truthy in every client language. + for (const v of [undefined, '1', 'nonsense']) { + assert.equal(typeof loadConfig(v).otaAllowManagedDevices, 'boolean'); + } +}); + +test.after(() => { + delete process.env.OTA_ALLOW_MANAGED_DEVICES; + delete require.cache[require.resolve('../config')]; +});