diff --git a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt index 62526cc..d0b3b6a 100644 --- a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt +++ b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt @@ -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 + ): 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 { 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 bfa0e0e..faf4fa2 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 @@ -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 { + 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() 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 26cf718..f6c281f 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 @@ -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) { diff --git a/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt new file mode 100644 index 0000000..66dce19 --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt @@ -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)) + } +} 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 e853bf1..634864d 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 @@ -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) + } }