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