mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Make "force update" actually forceful, and make it say what happened
The dashboard button sent the same checkForUpdate() the 30-minute timer calls, so it
was subject to every guard the timer is subject to, and every one of those guards
returns silently. The toast fires on ack.delivered — which only means the command
reached the device's socket — so a panel that was capped, or standing down under an
MDM, looked exactly like one that had updated. "You get the toast popup, but nothing
happens" was an accurate description of working code.
A forced run is a different thing from a timer tick: a human aimed it at one device
and is watching that screen. So it now
- hands the attempt budget back (OtaThrottle.onForcedCheck), un-parking a device
sitting in backoff instead of making it wait out the window,
- overrides the MDM stand-down, since a targeted human action is a stronger and
better-aimed signal than the global OTA_ALLOW_MANAGED_DEVICES switch,
- and REPORTS the outcome, including the boring ones. "Already on the latest
version" is the single most valuable line here: silence was indistinguishable
from failure, and that ambiguity is the whole bug.
It also distinguishes "install launched" from "installed". Off device-owner Android
raises a confirm dialog somebody has to accept, and the gap between those two states
is precisely where the button appears to do nothing — so the report names which one
happened and says the dialog is waiting.
The timer path is unchanged and stays quiet on purpose: reporting every capped tick
would move a Fire-OS-restart flood onto the WS channel, which is what #139 fixed.
Verified on a real panel end to end: dashboard socket emit -> ack {"delivered":true}
-> "Force update check triggered (operator)" -> "Force update: already on the latest
version (1.9.23)". OtaBackoffCadenceTest additionally pins the retry cadence that
prompted this (3 fast attempts, then one per 24h, full budget back on a new release)
so it stops being re-derived from the source each time it comes up.
This commit is contained in:
parent
c779d62d63
commit
56abfa3579
|
|
@ -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" -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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_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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue