Merge branch 'feat/ota-managed-override'

This commit is contained in:
ScreenTinker 2026-07-28 23:34:39 -05:00
commit 3b593e2a1c
11 changed files with 393 additions and 21 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

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

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

@ -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<State, Boolean> {
val attempts = state.attempts + 1
var s = state.copy(attempts = attempts, lastAttemptAt = now)
val enteredBackoff = attempts >= MAX_INSTALL_ATTEMPTS && !s.backoffReported
if (enteredBackoff) s = s.copy(backoffReported = true)
return s to enteredBackoff
// 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"
}
}

View file

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

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

@ -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<Long> {
var state = OtaThrottle.State()
val attemptsAt = mutableListOf<Long>()
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)
}
}

View file

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

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