From 83c9bc5aa69dbffdb65a3fc2706ac705475575d7 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 29 Jul 2026 18:01:34 -0500 Subject: [PATCH] Clear ProvisioningActivity's service callbacks (the white-flash relaunch loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on #234 as a screen that flashes white "over and over", unkillable — "there is nothing we can do on the tablet". It is a leaked listener. ProvisioningActivity installs onRegistered/onUnpaired/onPaired on WebSocketService and then finish()es. The service outlives it and nothing ever clears them: MainActivity assigns neither of those three, so nothing overwrites them either. onPaired therefore stays wired to a destroyed Activity for the life of the process — keeping it alive, and still firing. And it fires often. The server sends device:paired on EVERY register, not only the first. So: register -> paired -> the stale callback starts MainActivity with CLEAR_TASK -> new Activity binds and registers -> paired -> again. Measured on an Android 12 tablet with a bare paired device and nothing assigned: 240 activity starts in 180 seconds, about 1.3 a second, indefinitely. Android 12 is where it becomes intolerable rather than merely wasteful: every launch draws a splash screen there, so each iteration is a visible white flash. The same loop on Android 9 has no splash and reads as an occasional glitch — which is why it was originally dismissed as unreproducible after a clean reinstall. A clean reinstall starts MainActivity directly and never runs ProvisioningActivity, so the callback is never installed and the loop never begins. Pairing is what arms it. onPaired is now one-shot — the hand-off to MainActivity is all it was ever for — and all three are dropped in onDestroy too, which covers backing out before pairing completes. Same device, same pairing flow, 180s: 240 activity starts and 240 splash screens before, 0 and 0 after, with registrations falling from 240 to 2. ⚠️ No other callback is ever nulled either (there are ~20). MainActivity's are overwritten by the next MainActivity so they self-heal, but each one leaks the previous Activity until then. Worth a sweep; this commit fixes only the three that never get overwritten. --- .../player/ProvisioningActivity.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt index 63997c4..24e4900 100644 --- a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt @@ -288,6 +288,13 @@ class ProvisioningActivity : AppCompatActivity() { wsService?.onPaired = { deviceId, name -> runOnUiThread { + // ONE-SHOT. This callback's only job is the hand-off to MainActivity, but it was + // being left installed on a service that OUTLIVES this Activity — and the server + // sends device:paired on EVERY register, not just the first. So each register + // relaunched MainActivity with CLEAR_TASK, which re-registered, which paired again: + // a self-sustaining loop, ~1.3 relaunches/second measured on Android 12. On 12+ every + // launch draws a splash screen, which is the "white screen flashing" users report. + clearServiceCallbacks() cancelStuckTimer() stopRepairTicker() statusText.text = "Paired as: $name" @@ -298,6 +305,19 @@ class ProvisioningActivity : AppCompatActivity() { finish() } } + } + + /** + * Drop the callbacks this Activity installed on the long-lived service. MainActivity never + * assigns onRegistered/onUnpaired/onPaired, so nothing else would ever overwrite them — they + * would keep firing into a destroyed Activity for the life of the process (and keep it alive). + */ + private fun clearServiceCallbacks() { + try { + wsService?.onPaired = null + wsService?.onUnpaired = null + wsService?.onRegistered = null + } catch (_: Throwable) { } // Re-pair path: the socket is usually already up (service kept running). Make sure it's // connecting, then render any pairing code the service already issued (race-free). If we're @@ -315,6 +335,10 @@ class ProvisioningActivity : AppCompatActivity() { override fun onDestroy() { cancelStuckTimer() stopRepairTicker() + // Before unbinding: the service keeps running, so a callback left pointing here would both + // leak this Activity and keep firing. Matters when pairing never completes and the user + // backs out, where the one-shot clear above never runs. + clearServiceCallbacks() if (bound) { unbindService(connection) bound = false