Add an operator override for self-update on MDM-managed panels

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.
This commit is contained in:
ScreenTinker 2026-07-28 23:07:30 -05:00
parent ed22693a6e
commit c779d62d63
7 changed files with 131 additions and 1 deletions

View file

@ -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

View file

@ -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. */

View file

@ -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) {

View file

@ -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))
}
}

View file

@ -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()),

View file

@ -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,
});
});

View file

@ -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')];
});