Skip to content

Commit deeef05

Browse files
Find abandoned overlay windows (#2580)
- Add stable WindowManager root titles and overlay registry diagnostics so stale Switchify overlays can be found after service restarts - Track SurfaceControl overlay attach and release identity, and close transactions after apply to avoid leaked transaction resources - Add a debug ADB command for dumping overlay state and extend cleaner tests around diagnostic outcomes 🤖 Auto-generated Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 1b93705 commit deeef05

7 files changed

Lines changed: 469 additions & 26 deletions

File tree

app/src/main/java/com/enaboapps/switchify/service/core/AdbTestingBridgeReceiver.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.Context
55
import android.content.Intent
66
import android.util.Log
77
import com.enaboapps.switchify.BuildConfig
8+
import com.enaboapps.switchify.service.window.SwitchifyAccessibilityWindow
89
import com.enaboapps.switchify.switches.SwitchAction
910

1011
class AdbTestingBridgeReceiver : BroadcastReceiver() {
@@ -17,6 +18,11 @@ class AdbTestingBridgeReceiver : BroadcastReceiver() {
1718
ServiceBridge.sendCommand(ServiceBridge.ServiceCommand.ReloadSettings)
1819
return
1920
}
21+
if (actionName == ACTION_DUMP_OVERLAY_STATE) {
22+
Log.d(TAG, "Performing ADB testing command: $ACTION_DUMP_OVERLAY_STATE")
23+
SwitchifyAccessibilityWindow.instance.dumpOverlayDebugState()
24+
return
25+
}
2026

2127
val actionId = resolveActionId(intent)
2228
if (!isValidActionId(actionId)) {
@@ -48,6 +54,7 @@ class AdbTestingBridgeReceiver : BroadcastReceiver() {
4854
const val EXTRA_ACTION = "action"
4955
const val EXTRA_ACTION_ID = "action_id"
5056
const val ACTION_RELOAD_SETTINGS = "reload_settings"
57+
const val ACTION_DUMP_OVERLAY_STATE = "dump_overlay_state"
5158
private const val INVALID_ACTION_ID = -1
5259
private const val TAG = "AdbTestingBridge"
5360

app/src/main/java/com/enaboapps/switchify/service/window/SwitchifyAccessibilityWindow.kt

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,37 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
4646
private var screenWatcher: ScreenWatcher? = null
4747
private var isVisible = false
4848
private var windowGeneration = 0
49+
private var rootOverlayId: Long? = null
4950
private var acceptingOverlayOperations = false
50-
private val windowStateCleaner = WindowStateCleaner()
51+
private val windowStateCleaner = WindowStateCleaner(
52+
onCleanupStarted = { state, rootAttached ->
53+
Log.d(
54+
TAG,
55+
"Cleaning window generation=${state.generation} surfaceHandles=${state.surfaceHandles.size} rootVisible=${state.wasVisible} rootAttached=$rootAttached"
56+
)
57+
},
58+
onHandleReleased = { key ->
59+
Log.d(TAG, "Released captured surface overlay key=${System.identityHashCode(key)}")
60+
},
61+
onHandleReleaseFailed = { key, throwable ->
62+
Log.w(
63+
TAG,
64+
"Failed to release captured surface overlay key=${System.identityHashCode(key)}",
65+
throwable
66+
)
67+
},
68+
onRootRemoved = { id ->
69+
SwitchifyOverlayDebugRegistry.recordRootRemoved(id)
70+
Log.d(TAG, "Removed WindowManager root overlay id=$id")
71+
},
72+
onRootAlreadyRemoved = { id, throwable ->
73+
SwitchifyOverlayDebugRegistry.recordRootRemoved(id)
74+
Log.w(TAG, "WindowManager root overlay already removed id=$id", throwable)
75+
},
76+
onRootRemoveFailed = { id, throwable ->
77+
Log.e(TAG, "Failed to remove WindowManager root overlay id=$id", throwable)
78+
}
79+
)
5180

5281
companion object {
5382
private const val TAG = "SwitchifyAccessibilityWindow"
@@ -62,7 +91,8 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
6291
val windowManager: WindowManager?,
6392
val baseLayout: RelativeLayout?,
6493
val surfaceHandles: MutableMap<ViewGroup, OverlayHandle>,
65-
val wasVisible: Boolean
94+
val wasVisible: Boolean,
95+
val rootOverlayId: Long?
6696
)
6797

6898
private val defaultDisplayTarget = OverlayTargets.defaultDisplay().copy(forceSurface = true)
@@ -88,7 +118,11 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
88118
this.context = context
89119
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
90120
surfaceControlBackend = (context as? AccessibilityService)?.let { service ->
91-
SurfaceControlOverlayBackend(service) { baseLayout?.windowToken }
121+
SurfaceControlOverlayBackend(
122+
service = service,
123+
hostTokenProvider = { baseLayout?.windowToken },
124+
generationProvider = { windowGeneration }
125+
)
92126
}
93127
createBaseLayout()
94128
acceptingOverlayOperations = true
@@ -188,11 +222,20 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
188222
)
189223
params.layoutInDisplayCutoutMode =
190224
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
225+
val title = "Switchify:root:generation=$windowGeneration"
226+
params.setTitle(title)
191227

192228
baseLayout?.let { layout ->
193229
// Add view with minimal overhead - lifecycle owners will be set up after
194230
windowManager?.addView(layout, params)
195231
isVisible = true
232+
rootOverlayId = SwitchifyOverlayDebugRegistry.recordRootShown(
233+
generation = windowGeneration,
234+
title = title,
235+
viewId = layout.id,
236+
viewClass = layout.javaClass.name,
237+
handleIdentityHash = System.identityHashCode(layout)
238+
)
196239
surfaceOverlayHandles.values.forEach { it.setVisible(true) }
197240

198241
setupLifecycleOwners()
@@ -212,6 +255,9 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
212255
if (isVisible && baseLayout != null) {
213256
windowManager?.removeView(baseLayout)
214257
isVisible = false
258+
SwitchifyOverlayDebugRegistry.recordRootRemoved(rootOverlayId)
259+
Log.d(TAG, "Removed WindowManager root overlay id=$rootOverlayId during hide")
260+
rootOverlayId = null
215261
}
216262
surfaceOverlayHandles.values.forEach { it.setVisible(false) }
217263
} catch (e: Exception) {
@@ -254,9 +300,14 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
254300
surfaceControlBackend = null
255301
baseLayout = null
256302
surfaceOverlayHandles.clear()
303+
rootOverlayId = null
257304
isVisible = false
258305
}
259306

307+
fun dumpOverlayDebugState() {
308+
Log.d(TAG, SwitchifyOverlayDebugRegistry.snapshotText())
309+
}
310+
260311
/**
261312
* Adds a view to the window.
262313
* @param view The view to add.
@@ -574,9 +625,11 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
574625
windowManager = windowManager,
575626
baseLayout = baseLayout,
576627
surfaceHandles = surfaceOverlayHandles.toMutableMap(),
577-
wasVisible = isVisible
628+
wasVisible = isVisible,
629+
rootOverlayId = rootOverlayId
578630
)
579631
surfaceOverlayHandles.clear()
632+
rootOverlayId = null
580633
isVisible = false
581634
return state
582635
}
@@ -588,9 +641,10 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
588641
.mapValues { OverlayHandleCleanupHandle(it.value) }
589642
.toMutableMap(),
590643
root = state.baseLayout?.let { layout ->
591-
AndroidWindowCleanupRoot(state.windowManager, layout)
644+
AndroidWindowCleanupRoot(state.windowManager, layout, state.rootOverlayId)
592645
},
593-
wasVisible = state.wasVisible
646+
wasVisible = state.wasVisible,
647+
generation = state.generation
594648
)
595649
)
596650
}
@@ -605,7 +659,8 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
605659

606660
private class AndroidWindowCleanupRoot(
607661
private val windowManager: WindowManager?,
608-
private val layout: RelativeLayout
662+
private val layout: RelativeLayout,
663+
override val debugOverlayId: Long?
609664
) : WindowCleanupRoot {
610665
override val isAttachedToWindow: Boolean
611666
get() = layout.parent != null
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.enaboapps.switchify.service.window
2+
3+
import java.util.concurrent.atomic.AtomicLong
4+
5+
internal object SwitchifyOverlayDebugRegistry {
6+
private val nextId = AtomicLong(1L)
7+
private val records = linkedMapOf<Long, OverlayDebugRecord>()
8+
9+
fun recordRootShown(
10+
generation: Int,
11+
title: String,
12+
viewId: Int,
13+
viewClass: String,
14+
handleIdentityHash: Int
15+
): Long {
16+
val id = nextId.getAndIncrement()
17+
synchronized(records) {
18+
records[id] = OverlayDebugRecord(
19+
id = id,
20+
backend = BACKEND_WINDOW_MANAGER_ROOT,
21+
generation = generation,
22+
target = title,
23+
viewId = viewId,
24+
viewClass = viewClass,
25+
handleIdentityHash = handleIdentityHash,
26+
attachedAtMs = System.currentTimeMillis()
27+
)
28+
}
29+
return id
30+
}
31+
32+
fun recordRootRemoved(id: Long?) {
33+
recordReleased(id)
34+
}
35+
36+
fun recordSurfaceAttached(
37+
id: Long,
38+
backend: String,
39+
generation: Int,
40+
target: String,
41+
viewId: Int,
42+
viewClass: String,
43+
handleIdentityHash: Int,
44+
surface: String
45+
) {
46+
synchronized(records) {
47+
records[id] = OverlayDebugRecord(
48+
id = id,
49+
backend = backend,
50+
generation = generation,
51+
target = target,
52+
viewId = viewId,
53+
viewClass = viewClass,
54+
handleIdentityHash = handleIdentityHash,
55+
attachedAtMs = System.currentTimeMillis(),
56+
surface = surface
57+
)
58+
}
59+
}
60+
61+
fun recordSurfaceReleased(id: Long) {
62+
recordReleased(id)
63+
}
64+
65+
fun recordSurfaceReleaseFailed(id: Long, throwable: Throwable) {
66+
synchronized(records) {
67+
records[id] = records[id]?.copy(
68+
releaseFailure = "${throwable.javaClass.simpleName}: ${throwable.message}"
69+
) ?: return
70+
}
71+
}
72+
73+
fun snapshotText(): String {
74+
val snapshot = synchronized(records) { records.values.toList() }
75+
if (snapshot.isEmpty()) return "Switchify overlay registry is empty"
76+
return buildString {
77+
appendLine("Switchify overlay registry:")
78+
snapshot.forEach { record ->
79+
append("id=").append(record.id)
80+
.append(" backend=").append(record.backend)
81+
.append(" generation=").append(record.generation)
82+
.append(" target=").append(record.target)
83+
.append(" viewId=").append(record.viewId)
84+
.append(" viewClass=").append(record.viewClass)
85+
.append(" handle=").append(record.handleIdentityHash)
86+
.append(" attachedAtMs=").append(record.attachedAtMs)
87+
.append(" released=").append(record.released)
88+
record.releasedAtMs?.let { append(" releasedAtMs=").append(it) }
89+
record.surface?.let { append(" surface=").append(it) }
90+
record.releaseFailure?.let { append(" releaseFailure=").append(it) }
91+
appendLine()
92+
}
93+
}.trimEnd()
94+
}
95+
96+
fun clearReleased() {
97+
synchronized(records) {
98+
records.entries.removeAll { it.value.released }
99+
}
100+
}
101+
102+
internal fun resetForTesting() {
103+
synchronized(records) {
104+
records.clear()
105+
}
106+
nextId.set(1L)
107+
}
108+
109+
fun nextOverlayId(): Long {
110+
return nextId.getAndIncrement()
111+
}
112+
113+
private fun recordReleased(id: Long?) {
114+
if (id == null) return
115+
synchronized(records) {
116+
records[id] = records[id]?.copy(
117+
released = true,
118+
releasedAtMs = System.currentTimeMillis()
119+
) ?: return
120+
}
121+
}
122+
123+
internal const val BACKEND_WINDOW_MANAGER_ROOT = "window_manager_root"
124+
internal const val BACKEND_SURFACE_CONTROL_DISPLAY = "surface_control_display"
125+
internal const val BACKEND_SURFACE_CONTROL_WINDOW = "surface_control_window"
126+
127+
private data class OverlayDebugRecord(
128+
val id: Long,
129+
val backend: String,
130+
val generation: Int,
131+
val target: String,
132+
val viewId: Int,
133+
val viewClass: String,
134+
val handleIdentityHash: Int,
135+
val attachedAtMs: Long,
136+
val releasedAtMs: Long? = null,
137+
val released: Boolean = false,
138+
val surface: String? = null,
139+
val releaseFailure: String? = null
140+
)
141+
}

app/src/main/java/com/enaboapps/switchify/service/window/WindowStateCleaner.kt

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import android.util.Log
55
internal data class WindowCleanupState(
66
val surfaceHandles: MutableMap<Any, WindowCleanupHandle>,
77
val root: WindowCleanupRoot?,
8-
val wasVisible: Boolean
8+
val wasVisible: Boolean,
9+
val generation: Int = -1
910
)
1011

1112
internal interface WindowCleanupHandle {
@@ -14,6 +15,7 @@ internal interface WindowCleanupHandle {
1415

1516
internal interface WindowCleanupRoot {
1617
val isAttachedToWindow: Boolean
18+
val debugOverlayId: Long?
1719

1820
fun removeDescendantViews()
1921
fun removeImmediately()
@@ -25,13 +27,23 @@ internal class WindowStateCleaner(
2527
},
2628
private val logWarning: (String, Throwable) -> Unit = { message, throwable ->
2729
Log.w(TAG, message, throwable)
28-
}
30+
},
31+
private val onCleanupStarted: (WindowCleanupState, Boolean) -> Unit = { _, _ -> },
32+
private val onHandleReleased: (Any) -> Unit = {},
33+
private val onHandleReleaseFailed: (Any, Throwable) -> Unit = { _, _ -> },
34+
private val onRootRemoved: (Long?) -> Unit = {},
35+
private val onRootAlreadyRemoved: (Long?, Throwable) -> Unit = { _, _ -> },
36+
private val onRootRemoveFailed: (Long?, Throwable) -> Unit = { _, _ -> }
2937
) {
3038
fun cleanup(state: WindowCleanupState) {
31-
state.surfaceHandles.values.forEach { handle ->
39+
val rootAttachedAtCapture = state.root?.isAttachedToWindow == true
40+
onCleanupStarted(state, rootAttachedAtCapture)
41+
state.surfaceHandles.forEach { (key, handle) ->
3242
try {
3343
handle.release()
44+
onHandleReleased(key)
3445
} catch (e: Exception) {
46+
onHandleReleaseFailed(key, e)
3547
logError("Error releasing surface overlay", e)
3648
}
3749
}
@@ -40,12 +52,15 @@ internal class WindowStateCleaner(
4052
val root = state.root ?: return
4153
try {
4254
root.removeDescendantViews()
43-
if (state.wasVisible || root.isAttachedToWindow) {
55+
if (state.wasVisible || rootAttachedAtCapture || root.isAttachedToWindow) {
4456
root.removeImmediately()
57+
onRootRemoved(root.debugOverlayId)
4558
}
4659
} catch (e: IllegalArgumentException) {
60+
onRootAlreadyRemoved(root.debugOverlayId, e)
4761
logWarning("Window was already removed during cleanup", e)
4862
} catch (e: Exception) {
63+
onRootRemoveFailed(root.debugOverlayId, e)
4964
logError("Error in cleanup", e)
5065
}
5166
}

0 commit comments

Comments
 (0)