Stand down from self-OTA only for a real device owner, and say so when we do

The MDM auto-detect added in #166 asked "is any device admin active outside our
package". On a stock Fire TV stick the answer is yes: com.amazon.tv.parentalcontrols
is registered, holding wipe-data and nothing else. A retail stick with no enrolment
anywhere therefore declared itself MDM-managed and opted out of updates for good —
one sat 12 versions behind (1.9.11 against 1.9.23) while the server offered it every
release in between.

Device admin is not device owner. isDeviceOwnerApp/isProfileOwnerApp are public since
API 21 and accept any package name, so the owner really can be read directly; the
comment claiming otherwise was the root of the over-broad test. Profile owner is not
enough either — on that same stick parental controls owns user 0 — so the check is
now a foreign DEVICE owner, and delegated install scope short-circuits it since an
owner that delegated installs to us wants us installing.

Where doubt remains the asymmetry decides it: standing down wrongly is silent and
permanent, while attempting wrongly is capped at MAX_INSTALL_ATTEMPTS and surfaces
manual_update_required. Better to be the kind of wrong that reaches a dashboard.

That visibility was missing too. The stand-down ran before the version check, so a
managed panel never learned an update existed and kept reporting ota_status 'none' —
indistinguishable from up to date, which is why nothing flagged it. It now checks
first and parks genuinely-managed panels in manual_update_required, announced once
per target version rather than every polling cycle.

ManagedLogic is a pure seam alongside TierLogic; the admin shapes under test are the
ones dumped from the real device.
This commit is contained in:
ScreenTinker 2026-07-28 21:40:18 -05:00
parent bcb1b5c7a3
commit 5ba60ffa1a
5 changed files with 233 additions and 13 deletions

View file

@ -37,13 +37,32 @@ class STPolicy(context: Context) {
} catch (_: Throwable) { emptyList() }
/**
* A foreign DPC (MDM) manages this device: an active admin outside our package while we are NOT
* owner. Public getActiveAdmins() signal (a normal app can't read the owner component directly).
* Errs safe (managed => true) so self-OTA stands down on a managed panel. Single source for #166.
* A foreign DPC (MDM) OWNS this device the condition under which self-OTA must stand down
* because the MDM pushes packages instead.
*
* This used to answer "is any device ADMIN active outside our package", on the belief that a
* normal app can't read the owner. It can: isDeviceOwnerApp/isProfileOwnerApp are public since
* API 21 and accept ANY package name. An active admin is a far weaker and much more common
* thing than an owner Fire OS registers Amazon admins on a stock stick, and plenty of Android
* boxes ship one so the broad reading made ordinary unmanaged panels declare themselves
* MDM-managed and opt out of updates permanently, with no MDM anywhere to push them an APK.
*
* Erring "managed" is only safe when something else is doing the updating. It isn't, so the
* question has to be asked precisely: an active admin outside our package that is the actual
* device owner, or the profile owner of the user we run as. Single source for #166.
*/
fun hasForeignDeviceOwner(): Boolean = try {
if (isDeviceOwner()) false
else dpm?.activeAdmins?.any { it.packageName != pkg } == true
ManagedLogic.foreignDpcOwnsInstalls(
weCanInstallSilently = canInstallSilently(),
ourPackage = pkg,
admins = dpm?.activeAdmins.orEmpty().map {
ManagedLogic.Admin(
packageName = it.packageName,
isDeviceOwner = runCatching { dpm?.isDeviceOwnerApp(it.packageName) == true }.getOrDefault(false),
isProfileOwner = runCatching { dpm?.isProfileOwnerApp(it.packageName) == true }.getOrDefault(false)
)
}
)
} catch (_: Throwable) { false }
/** Silent PackageInstaller (no confirm dialog): device owner OR delegated install scope. */
@ -162,6 +181,48 @@ class STPolicy(context: Context) {
}
}
/**
* Pure "is this panel managed by someone else's DPC" decision, extracted so it's unit-testable
* without a device / DevicePolicyManager. STPolicy is the shell that reads real admin state.
*/
object ManagedLogic {
/** One active device admin, plus whether it actually OWNS this device / our user's profile. */
data class Admin(
val packageName: String,
val isDeviceOwner: Boolean = false,
val isProfileOwner: Boolean = false
)
/**
* True only when a package other than ours is the DEVICE OWNER, and we have no way to install
* for ourselves. Deliberately narrow, on evidence from a stock Fire TV stick (AFTKRT, Fire OS 7):
*
* Profile Owner (User 0): com.amazon.tv.parentalcontrols policies: wipe-data
* Device Owner: (none)
*
* So neither "any active admin" nor "device or profile owner" is usable a retail stick out of
* the box satisfies both, and parental controls is plainly not going to install our APK for us.
* Only a genuine device owner implies a DPC that provisions packages.
*
* The asymmetry settles the remaining doubt. Standing down wrongly is silent and permanent: the
* panel opts out of updates and nothing ever retries. Attempting wrongly is bounded and visible:
* OtaThrottle caps it at MAX_INSTALL_ATTEMPTS and surfaces manual_update_required. When unsure,
* we should be the kind of wrong that shows up on a dashboard.
*
* Delegated install scope also counts as "we can do it ourselves" a foreign owner that handed
* us DELEGATION_PACKAGE_INSTALLATION wants us installing, so standing down would defeat it.
*/
fun foreignDpcOwnsInstalls(
weCanInstallSilently: Boolean,
ourPackage: String,
admins: List<Admin>
): Boolean {
if (weCanInstallSilently) return false
// isProfileOwner is carried on Admin but intentionally NOT consulted — see above.
return admins.any { it.packageName != ourPackage && it.isDeviceOwner }
}
}
/** Pure tier decision, extracted so it's unit-testable without a device / DevicePolicyManager. */
object TierLogic {
fun tier(isDeviceOwner: Boolean, canInstallSilently: Boolean, isAdminActive: Boolean): Int = when {

View file

@ -55,6 +55,26 @@ object OtaThrottle {
return s to enteredBackoff
}
/**
* A check found [latestVersion] available on a panel whose installs belong to a foreign DPC.
* We must not self-install but we must not go quiet either: reporting nothing leaves the
* panel showing "no update pending" forever while it silently rots on an old build, which is
* precisely how one stayed 12 versions behind unnoticed. Park it in the same
* manual_update_required state a capped device reaches, and say so ONCE per target version
* (the check runs every cycle; the operator does not need to hear it every cycle).
*/
fun onManagedStandDown(state: State, latestVersion: String, now: Long): Pair<State, Boolean> {
val s = if (isNewTarget(state, latestVersion)) State(targetVersion = latestVersion) else state
val report = !s.backoffReported
// attempts pinned at the cap and lastAttemptAt refreshed so statusFor() keeps reading
// manual_update_required for as long as the update is genuinely outstanding.
return s.copy(
attempts = MAX_INSTALL_ATTEMPTS,
lastAttemptAt = now,
backoffReported = true
) to report
}
/** 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()

View file

@ -115,14 +115,6 @@ class UpdateChecker(private val context: Context) {
fun checkForUpdate() {
if (config.serverUrl.isEmpty()) return
// #155/#161: if a foreign device owner (an MDM/DPC) manages this panel, IT owns updates.
// Stand down — never self-install: on a managed device the self-install confirm dialog
// can't be reliably auto-dismissed and ends up over customer content. The MDM pushes the
// APK instead. Pure client-side safety net, independent of the server-side OTA switch.
if (isManagedByForeignDeviceOwner()) {
Log.i(TAG, "Managed by a foreign device owner (MDM) — self-OTA stands down; MDM owns updates")
return
}
Thread {
try {
@ -159,6 +151,25 @@ class UpdateChecker(private val context: Context) {
announceOtaStatus() // transition -> emits 'none' so the badge clears promptly
}
} else if (downloadUrl.isNotEmpty()) {
// #155/#161: if a foreign DPC genuinely OWNS this panel, IT owns updates. Stand
// down — never self-install: on a managed device the confirm dialog can't be
// reliably auto-dismissed and ends up over customer content. The MDM pushes the
// APK instead. Client-side safety net, independent of the server OTA switch.
//
// 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()) {
val (managed, first) = OtaThrottle.onManagedStandDown(
otaState(), latestVersion, System.currentTimeMillis())
persistOta(managed)
Log.i(TAG, "Managed by a foreign DPC — self-OTA stands down; $latestVersion needs the MDM (or a human)")
if (first) {
report("warn", "Update $latestVersion available but this panel is managed by another device owner — self-install is disabled; push it from your MDM or update manually")
announceOtaStatus() // transition -> 'manual_update_required' so the badge shows
}
return@Thread
}
maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl")
}
} catch (e: Exception) {

View file

@ -0,0 +1,78 @@
package com.remotedisplay.player.admin
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* #166: what counts as "someone else's DPC owns this panel", and therefore when self-OTA may stand
* down and stop updating.
*
* Grounded in a real stock Fire TV stick (AFTKRT, Fire OS 7, nothing enrolled, no MDM):
*
* Profile Owner (User 0): com.amazon.tv.parentalcontrols policies: wipe-data
* Device Owner: (none)
*
* Reading that as "managed" is how a retail stick opted itself out of updates permanently and sat
* 12 versions behind while reporting no update pending. Parental controls was never going to
* install our APK. Only a genuine device owner implies a DPC that provisions packages.
*/
class ManagedLogicTest {
private val OURS = "com.remotedisplay.player"
private val AMAZON_PCON = "com.amazon.tv.parentalcontrols"
@Test fun no_admins_at_all_is_not_managed() {
assertFalse(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, emptyList()))
}
@Test fun THE_BUG_stock_fire_tv_profile_owner_is_not_managed() {
// Exactly the dump above: sole admin, profile owner of user 0, no device owner anywhere.
val admins = listOf(ManagedLogic.Admin(AMAZON_PCON, isDeviceOwner = false, isProfileOwner = true))
assertFalse(
"a stock Fire TV must keep updating itself",
ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins)
)
}
@Test fun THE_BUG_a_foreign_active_admin_that_owns_nothing_is_not_managed() {
// The weaker shape the check originally matched: merely an enabled admin.
val admins = listOf(ManagedLogic.Admin("com.oem.factorytool"))
assertFalse(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins))
}
@Test fun several_foreign_admins_and_profile_owners_still_do_not_add_up_to_an_owner() {
val admins = listOf(
ManagedLogic.Admin(AMAZON_PCON, isProfileOwner = true),
ManagedLogic.Admin("com.google.android.gms"),
ManagedLogic.Admin("com.oem.factorytool")
)
assertFalse(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins))
}
@Test fun a_foreign_device_owner_IS_managed() {
// A real enrolment: the DPC provisions packages, so self-install must stand down.
val admins = listOf(ManagedLogic.Admin("com.airwatch.androidagent", isDeviceOwner = true))
assertTrue(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins))
}
@Test fun a_foreign_owner_is_still_found_when_listed_after_harmless_admins() {
val admins = listOf(
ManagedLogic.Admin(AMAZON_PCON, isProfileOwner = true),
ManagedLogic.Admin("com.mdm.dpc", isDeviceOwner = true)
)
assertTrue(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins))
}
@Test fun we_are_never_foreign_to_ourselves() {
val admins = listOf(ManagedLogic.Admin(OURS, isDeviceOwner = true, isProfileOwner = true))
assertFalse(ManagedLogic.foreignDpcOwnsInstalls(false, OURS, admins))
}
@Test fun if_we_can_install_silently_we_never_stand_down() {
// Tier 2: we are owner, or a foreign owner delegated DELEGATION_PACKAGE_INSTALLATION to us.
// Standing down would defeat the very delegation that was granted to let us install.
val admins = listOf(ManagedLogic.Admin("com.mdm.dpc", isDeviceOwner = true))
assertFalse(ManagedLogic.foreignDpcOwnsInstalls(true, OURS, admins))
}
}

View file

@ -94,4 +94,54 @@ class OtaThrottleTest {
assertEquals("pending", OtaThrottle.statusFor(
OtaThrottle.State(targetVersion = V, attempts = MAX, lastAttemptAt = now), now + WINDOW + 1))
}
// ---- #166: managed stand-down (a foreign DPC owns installs on this panel) --------------------
@Test fun managed_standDown_reports_once_per_target_not_every_cycle() {
val now = 1_000_000L
val (s1, first) = OtaThrottle.onManagedStandDown(OtaThrottle.State(), V, now)
assertTrue("first sighting of this target must be reported", first)
// The check runs on a timer; the operator hears about it once, not forever.
val (s2, again) = OtaThrottle.onManagedStandDown(s1, V, now + 60_000)
assertFalse("same target must not re-report", again)
val (_, third) = OtaThrottle.onManagedStandDown(s2, V, now + 120_000)
assertFalse("still must not re-report", third)
}
@Test fun managed_standDown_reads_as_manual_update_required_not_none() {
// THE BUG: standing down before the check left ota_status 'none' — indistinguishable from
// "up to date", so a rotting panel looked healthy on the dashboard.
val now = 1_000_000L
val (s, _) = OtaThrottle.onManagedStandDown(OtaThrottle.State(), V, now)
assertEquals("manual_update_required", OtaThrottle.statusFor(s, now))
assertEquals(V, s.targetVersion)
}
@Test fun managed_standDown_stays_flagged_across_the_backoff_window() {
// A capped device flips back to "pending" once the window elapses, because a retry is due.
// A managed panel has no retry coming: refreshing lastAttemptAt each cycle keeps it honest.
val now = 1_000_000L
var (s, _) = OtaThrottle.onManagedStandDown(OtaThrottle.State(), V, now)
val later = now + WINDOW + 1
s = OtaThrottle.onManagedStandDown(s, V, later).first
assertEquals("manual_update_required", OtaThrottle.statusFor(s, later))
}
@Test fun a_newer_version_re_reports_because_it_is_news() {
val now = 1_000_000L
val (s1, _) = OtaThrottle.onManagedStandDown(OtaThrottle.State(), V, now)
val (s2, fresh) = OtaThrottle.onManagedStandDown(s1, "1.9.24", now + 5_000)
assertTrue("a different target is a new thing to say", fresh)
assertEquals("1.9.24", s2.targetVersion)
}
@Test fun managed_standDown_never_consumes_a_healthy_devices_budget() {
// It pins attempts at the cap by design; the point is that it is reached WITHOUT ever
// launching an install, so no APK was downloaded and no confirm dialog was thrown up.
val now = 1_000_000L
val (s, _) = OtaThrottle.onManagedStandDown(OtaThrottle.State(), V, now)
assertEquals(MAX, s.attempts)
assertTrue(s.backoffReported)
}
}