mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Merge branch 'feat/ota-managed-override'
This commit is contained in:
commit
3b593e2a1c
22
README.md
22
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" -> {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
52
server/test/ota-allow-managed-config.test.js
Normal file
52
server/test/ota-allow-managed-config.test.js
Normal 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')];
|
||||
});
|
||||
Loading…
Reference in a new issue