mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
parent
bcb1b5c7a3
commit
5ba60ffa1a
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue