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 d9b9039..88f5b88 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -1328,6 +1328,19 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { remoteStreaming = false + // Everything below this line exists for the same reason the wall/group shutdown does, and + // was missing: these Handlers are on the MAIN LOOPER, which outlives the Activity. + // + // PlaylistController kept advancing after the Activity was destroyed. Each tick wrote the + // resume index and emitted play_start/play_end through the still-live WebSocketService, so + // after a relaunch (the "launch" command, Relauncher after OTA/boot, a re-pair, or a config + // change outside the ones we handle) TWO controllers were reporting playback for one screen + // — inflating Total Plays and Hours in Reports, and racing over the resume position that + // #234 relies on. Widget items also re-entered showWidget on a WebView nobody owned. + if (::playlistController.isInitialized) playlistController.stop() + if (::updateChecker.isInitialized) updateChecker.shutdown() + // The 30s failure-check loop and anything else this Activity posted. + handler.removeCallbacksAndMessages(null) // Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper // (outlives this Activity), so a surviving tick would keep broadcasting sync frames against // the released player forever — the zombie-leader / split-brain / garbage-position leak. 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 8933a06..d29af58 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 @@ -38,6 +38,8 @@ class UpdateChecker(private val context: Context) { private val CHECK_INTERVAL = 30 * 60 * 1000L private var installReceiverRegistered = false + // Held so shutdown() can unregister it; without a handle the receiver outlives the Activity. + private var installReceiver: BroadcastReceiver? = null // #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity // to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter. @@ -92,6 +94,7 @@ class UpdateChecker(private val context: Context) { @Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter) } installReceiverRegistered = true + installReceiver = receiver } fun startPeriodicCheck() { @@ -113,6 +116,25 @@ class UpdateChecker(private val context: Context) { checkTimer = null } + /** + * Full teardown for an Activity that is going away. + * + * stopPeriodicCheck alone leaves the install receiver registered against a dead Context, and + * installReceiverRegistered is per-instance — so each Activity recreate produced another + * checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those means + * one STATUS_PENDING_USER_ACTION fires N confirm dialogs over customer content, and concurrent + * checkers race in tryPackageInstaller, which begins by abandoning ALL of this app's installer + * sessions — so one can abandon another's staged session mid-flight and the update never lands. + */ + fun shutdown() { + stopPeriodicCheck() + if (installReceiverRegistered) { + installReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Throwable) { /* already gone */ } } + installReceiver = null + installReceiverRegistered = false + } + } + /** * [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