From c779d62d636e0a0a35ff186fdae6c45a56da3fdb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 23:07:30 -0500 Subject: [PATCH 1/3] Add an operator override for self-update on MDM-managed panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A player stands down from self-updating when another device owner manages the panel, on the assumption that the MDM distributes packages instead. That assumption does not always hold: an operator may run an MDM for policy alone and still want ScreenTinker's OTA to own the player. Until now there was no way to say so — the stand-down was a client-side decision with no operator input. OTA_ALLOW_MANAGED_DEVICES=1 makes the server advertise `allow_managed: true` in /api/update/check, and players skip the stand-down. Default off: the safe behaviour stays the default, and only an explicit opt-in changes it. Absence is not consent. The client parses the field with a false default, so a newer player against an older server that has never heard of it still stands down; and the server always emits the key, so a player can tell "the operator said no" from "this server has no opinion". Config parsing is strict for the same reason — only 1/true enable it, and anything else, including a plausible typo like "ture" or "yes", lands on the safe side rather than riding JavaScript truthiness. This deliberately does NOT grant silent install. Off device-owner, and without DELEGATION_PACKAGE_INSTALLATION delegated by the MDM, Android still raises a confirm dialog somebody has to accept, so the override alone will not fix a fleet whose installs are failing at that dialog — delegating the scope is the real fix there. The README says so at the point of use, because reaching for this flag is the natural mistake. Only reachable because the stand-down now runs after the version check rather than before it; it needs the server's answer in hand to consult. --- README.md | 22 ++++++++ .../remotedisplay/player/admin/STPolicy.kt | 10 ++++ .../player/service/UpdateChecker.kt | 12 ++++- .../player/admin/ManagedLogicTest.kt | 23 ++++++++ server/config.js | 8 +++ server/server.js | 5 ++ server/test/ota-allow-managed-config.test.js | 52 +++++++++++++++++++ 7 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 server/test/ota-allow-managed-config.test.js 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/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/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index f6c281f..e61b3ce 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 @@ -138,6 +138,10 @@ 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") @@ -159,7 +163,8 @@ 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()) { + if (com.remotedisplay.player.admin.ManagedLogic.standDownFromSelfOta( + isManagedByForeignDeviceOwner(), allowManaged)) { val (managed, first) = OtaThrottle.onManagedStandDown( otaState(), latestVersion, System.currentTimeMillis()) persistOta(managed) @@ -170,6 +175,11 @@ class UpdateChecker(private val context: Context) { } return@Thread } + if (allowManaged && isManagedByForeignDeviceOwner()) { + // Loud on purpose: the operator overrode a safety default, and the confirm + // dialog this may raise over customer content is the cost of that choice. + Log.i(TAG, "Managed by a foreign DPC, but the server allows managed self-update — proceeding") + } maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") } } catch (e: Exception) { 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/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')]; +}); From 56abfa35795acc30eb27f643b66ef09b75dbe8e5 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 23:24:01 -0500 Subject: [PATCH 2/3] Make "force update" actually forceful, and make it say what happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard button sent the same checkForUpdate() the 30-minute timer calls, so it was subject to every guard the timer is subject to, and every one of those guards returns silently. The toast fires on ack.delivered — which only means the command reached the device's socket — so a panel that was capped, or standing down under an MDM, looked exactly like one that had updated. "You get the toast popup, but nothing happens" was an accurate description of working code. A forced run is a different thing from a timer tick: a human aimed it at one device and is watching that screen. So it now - hands the attempt budget back (OtaThrottle.onForcedCheck), un-parking a device sitting in backoff instead of making it wait out the window, - overrides the MDM stand-down, since a targeted human action is a stronger and better-aimed signal than the global OTA_ALLOW_MANAGED_DEVICES switch, - and REPORTS the outcome, including the boring ones. "Already on the latest version" is the single most valuable line here: silence was indistinguishable from failure, and that ambiguity is the whole bug. It also distinguishes "install launched" from "installed". Off device-owner Android raises a confirm dialog somebody has to accept, and the gap between those two states is precisely where the button appears to do nothing — so the report names which one happened and says the dialog is waiting. The timer path is unchanged and stays quiet on purpose: reporting every capped tick would move a Fire-OS-restart flood onto the WS channel, which is what #139 fixed. Verified on a real panel end to end: dashboard socket emit -> ack {"delivered":true} -> "Force update check triggered (operator)" -> "Force update: already on the latest version (1.9.23)". OtaBackoffCadenceTest additionally pins the retry cadence that prompted this (3 fast attempts, then one per 24h, full budget back on a new release) so it stops being re-derived from the source each time it comes up. --- .../com/remotedisplay/player/MainActivity.kt | 8 +- .../player/service/OtaThrottle.kt | 12 +++ .../player/service/UpdateChecker.kt | 65 +++++++++++++-- .../player/service/OtaBackoffCadenceTest.kt | 83 +++++++++++++++++++ .../player/service/OtaThrottleTest.kt | 46 ++++++++++ 5 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt 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/service/OtaThrottle.kt b/android/app/src/main/java/com/remotedisplay/player/service/OtaThrottle.kt index faf4fa2..7021bd5 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 @@ -75,6 +75,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() 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 e61b3ce..318de17 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 { @@ -146,6 +157,9 @@ class UpdateChecker(private val context: Context) { 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())) { @@ -163,8 +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. + // + // 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( - isManagedByForeignDeviceOwner(), allowManaged)) { + managedNow, allowManaged || forced)) { val (managed, first) = OtaThrottle.onManagedStandDown( otaState(), latestVersion, System.currentTimeMillis()) persistOta(managed) @@ -175,12 +194,19 @@ class UpdateChecker(private val context: Context) { } return@Thread } - if (allowManaged && isManagedByForeignDeviceOwner()) { - // Loud on purpose: the operator overrode a safety default, and the confirm - // dialog this may raise over customer content is the cost of that choice. - Log.i(TAG, "Managed by a foreign DPC, but the server allows managed self-update — proceeding") + 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") } - maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") + 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}") @@ -202,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) @@ -212,17 +238,34 @@ 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)") announceOtaStatus() // transition -> emits 'manual_update_required' @@ -486,4 +529,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/service/OtaBackoffCadenceTest.kt b/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt new file mode 100644 index 0000000..0b7ce30 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt @@ -0,0 +1,83 @@ +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_first_three_attempts_burn_fast_at_the_check_cadence() { + val at = simulate(hours = 2) + assertEquals(3, at.size) + // Back to back on consecutive 30-min checks, so the cap is reached within about an hour. + assertEquals(listOf(0L, CHECK_INTERVAL, CHECK_INTERVAL * 2), at) + } + + @Test fun THE_QUESTION_after_the_cap_it_is_ONE_attempt_per_24h_not_three() { + val at = simulate(hours = 24 * 3 + 2) // three full days + // 3 in the opening burst, then one per 24h window. + assertEquals(6, at.size) + val afterBurst = at.drop(3) + assertEquals(3, afterBurst.size) + for (i in 1 until afterBurst.size) { + val gap = afterBurst[i] - afterBurst[i - 1] + assertEquals("post-cap retries must be 24h apart", OtaThrottle.BACKOFF_MS, gap) + } + } + + @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..2fa6141 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 @@ -144,4 +144,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) + } } From f5ce88c93f42d02b1ad6e1dcede28b29b7ca5067 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 23:31:47 -0500 Subject: [PATCH 3/3] Keep retrying an install for a working day, and flag a human straight away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three attempts inside one hour, then a day of silence, was calibrated for the wrong cost. The ~8.7MB re-download that throttle exists to prevent is already prevented by the APK cache — downloadAndInstall reuses a previously verified file, so attempts 2..N pull no bytes. What actually blocks these installs is a confirm dialog waiting for somebody to walk past, and giving up an hour in guarantees nobody has. The cap is now 40, roughly a working day at the 30-minute cadence, before falling back to the existing daily retry. Two things had to come with it, because raising the number alone would have made things worse: Telling the operator is now a SEPARATE threshold from giving up. It used to fire at the cap, so a bare bump would have pushed "this panel needs attention" from about an hour out to about twenty. It fires at ATTEMPTS_BEFORE_FLAGGING (3) instead, and statusFor keys on the same threshold, so a device reports manual_update_required as soon as a human is demonstrably needed and KEEPS reporting it while it retries. Previously the status dropped back to 'pending' once the backoff window elapsed, so a panel that needed hands looked healthy in between attempts. PackageInstaller sessions are now abandoned before a new one is opened. Every attempt stages a full copy of the APK via openWrite, and a session whose dialog is never accepted holds onto it. At three that was a rounding error; at forty it would be ~350MB of staged installs on hardware without it to spare, and would eventually trip the per-app session limit. The warning text no longer promises a 24h backoff it is not about to take, and says what would actually fix it — accept the prompt, or have the MDM delegate install permission. The three tests that broke encoded the old thresholds and were rewritten to the new intent rather than retuned to pass. --- .../player/service/OtaThrottle.kt | 29 ++++++++++++--- .../player/service/UpdateChecker.kt | 13 ++++++- .../player/service/OtaBackoffCadenceTest.kt | 36 ++++++++++++------- .../player/service/OtaThrottleTest.kt | 26 +++++++++----- 4 files changed, 77 insertions(+), 27 deletions(-) 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 7021bd5..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 } /** @@ -100,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 318de17..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 @@ -267,7 +267,7 @@ class UpdateChecker(private val context: Context) { "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' } } @@ -404,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 ) 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 index 0b7ce30..75dce99 100644 --- a/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/service/OtaBackoffCadenceTest.kt @@ -38,22 +38,32 @@ class OtaBackoffCadenceTest { return attemptsAt } - @Test fun the_first_three_attempts_burn_fast_at_the_check_cadence() { - val at = simulate(hours = 2) - assertEquals(3, at.size) - // Back to back on consecutive 30-min checks, so the cap is reached within about an hour. - assertEquals(listOf(0L, CHECK_INTERVAL, CHECK_INTERVAL * 2), at) + @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_QUESTION_after_the_cap_it_is_ONE_attempt_per_24h_not_three() { - val at = simulate(hours = 24 * 3 + 2) // three full days - // 3 in the opening burst, then one per 24h window. - assertEquals(6, at.size) - val afterBurst = at.drop(3) - assertEquals(3, afterBurst.size) + @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) { - val gap = afterBurst[i] - afterBurst[i - 1] - assertEquals("post-cap retries must be 24h apart", OtaThrottle.BACKOFF_MS, gap) + assertEquals("post-cap retries must be 24h apart", + OtaThrottle.BACKOFF_MS, afterBurst[i] - afterBurst[i - 1]) } } 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 2fa6141..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) --------------------