From c779d62d636e0a0a35ff186fdae6c45a56da3fdb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 28 Jul 2026 23:07:30 -0500 Subject: [PATCH] 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')]; +});