Clear ProvisioningActivity's service callbacks (the white-flash relaunch loop)

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.
This commit is contained in:
ScreenTinker 2026-07-29 18:01:34 -05:00
parent ce626c7e8e
commit 83c9bc5aa6

View file

@ -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