Skip to content

Commit 03f0a62

Browse files
authored
Prevent abandoned overlay windows (#2578)
- Capture overlay window state before service cleanup so stale resources remain releasable - Release WindowManager and SurfaceControl overlays from cleanup snapshots outside the generation gate - Cover cleanup behavior for handles, root removal, and already-removed windows 🤖 Auto-generated
1 parent 60c3194 commit 03f0a62

3 files changed

Lines changed: 292 additions & 42 deletions

File tree

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

Lines changed: 82 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,24 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
4747
private var isVisible = false
4848
private var windowGeneration = 0
4949
private var acceptingOverlayOperations = false
50+
private val windowStateCleaner = WindowStateCleaner()
5051

5152
companion object {
5253
private const val TAG = "SwitchifyAccessibilityWindow"
53-
private const val SHUTDOWN_CLEANUP_TIMEOUT_MS = 500L
54+
private const val SHUTDOWN_CLEANUP_TIMEOUT_MS = 2_000L
5455
val instance: SwitchifyAccessibilityWindow by lazy {
5556
SwitchifyAccessibilityWindow()
5657
}
5758
}
5859

60+
private data class WindowState(
61+
val generation: Int,
62+
val windowManager: WindowManager?,
63+
val baseLayout: RelativeLayout?,
64+
val surfaceHandles: MutableMap<ViewGroup, OverlayHandle>,
65+
val wasVisible: Boolean
66+
)
67+
5968
private val defaultDisplayTarget = OverlayTargets.defaultDisplay().copy(forceSurface = true)
6069

6170
override val lifecycle: Lifecycle
@@ -72,7 +81,9 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
7281
try {
7382
acceptingOverlayOperations = false
7483
windowGeneration += 1
75-
cleanupOnMainBlocking()
84+
if (!cleanupOnMainBlocking()) {
85+
Log.w(TAG, "Timed out while cleaning up previous window state before setup")
86+
}
7687

7788
this.context = context
7889
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@@ -215,22 +226,25 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
215226
*/
216227
fun cleanup() {
217228
if (Looper.myLooper() == Looper.getMainLooper()) {
218-
if (acceptingOverlayOperations) cleanupNow()
229+
cleanupCurrentStateNow()
219230
return
220231
}
221-
postIfCurrentGeneration { cleanupNow() }
232+
mainHandler.post { cleanupCurrentStateNow() }
222233
}
223234

224235
/**
225236
* Cleans up the window and its resources when the service is destroyed.
226237
*/
227238
fun onServiceDestroy() {
228239
acceptingOverlayOperations = false
240+
windowGeneration += 1
229241
ServiceMessageHUD.instance.dispose()
230242
MenuHighlightHud.instance.dispose()
231243
ServiceStartupSplash.instance.dispose()
232244
MediaPipeBackend.close()
233-
cleanupForServiceShutdown()
245+
if (!cleanupOnMainBlocking()) {
246+
Log.w(TAG, "Timed out waiting for service window cleanup")
247+
}
234248
getContext()?.let { ctx ->
235249
screenWatcher?.unregister(ctx)
236250
}
@@ -239,6 +253,8 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
239253
windowManager = null
240254
surfaceControlBackend = null
241255
baseLayout = null
256+
surfaceOverlayHandles.clear()
257+
isVisible = false
242258
}
243259

244260
/**
@@ -527,62 +543,86 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
527543
}
528544
}
529545

530-
private fun cleanupForServiceShutdown() {
531-
acceptingOverlayOperations = false
532-
cleanupOnMainBlocking()
533-
}
534-
535-
private fun cleanupOnMainBlocking() {
546+
private fun cleanupOnMainBlocking(): Boolean {
536547
if (Looper.myLooper() == Looper.getMainLooper()) {
537-
cleanupNow()
538-
return
548+
cleanupCurrentStateNow()
549+
return true
539550
}
551+
val state = captureCurrentWindowState()
540552
val latch = CountDownLatch(1)
541553
mainHandler.postAtFrontOfQueue {
542554
try {
543-
cleanupNow()
555+
cleanupSnapshotNow(state)
544556
} finally {
545557
latch.countDown()
546558
}
547559
}
548-
if (!latch.await(SHUTDOWN_CLEANUP_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
549-
Log.w(TAG, "Timed out waiting for window cleanup")
560+
return latch.await(SHUTDOWN_CLEANUP_TIMEOUT_MS, TimeUnit.MILLISECONDS).also { completed ->
561+
if (!completed) {
562+
Log.w(TAG, "Timed out waiting for window cleanup")
563+
}
550564
}
551565
}
552566

553-
private fun cleanupNow() {
554-
try {
555-
releaseSurfaceOverlays()
556-
for (i in 0 until (baseLayout?.childCount ?: 0)) {
557-
val child = baseLayout?.getChildAt(i)
567+
private fun cleanupCurrentStateNow() {
568+
cleanupSnapshotNow(captureCurrentWindowState())
569+
}
570+
571+
private fun captureCurrentWindowState(): WindowState {
572+
val state = WindowState(
573+
generation = windowGeneration,
574+
windowManager = windowManager,
575+
baseLayout = baseLayout,
576+
surfaceHandles = surfaceOverlayHandles.toMutableMap(),
577+
wasVisible = isVisible
578+
)
579+
surfaceOverlayHandles.clear()
580+
isVisible = false
581+
return state
582+
}
583+
584+
private fun cleanupSnapshotNow(state: WindowState) {
585+
windowStateCleaner.cleanup(
586+
WindowCleanupState(
587+
surfaceHandles = state.surfaceHandles.mapKeys { it.key as Any }
588+
.mapValues { OverlayHandleCleanupHandle(it.value) }
589+
.toMutableMap(),
590+
root = state.baseLayout?.let { layout ->
591+
AndroidWindowCleanupRoot(state.windowManager, layout)
592+
},
593+
wasVisible = state.wasVisible
594+
)
595+
)
596+
}
597+
598+
private class OverlayHandleCleanupHandle(
599+
private val handle: OverlayHandle
600+
) : WindowCleanupHandle {
601+
override fun release() {
602+
handle.release()
603+
}
604+
}
605+
606+
private class AndroidWindowCleanupRoot(
607+
private val windowManager: WindowManager?,
608+
private val layout: RelativeLayout
609+
) : WindowCleanupRoot {
610+
override val isAttachedToWindow: Boolean
611+
get() = layout.parent != null
612+
613+
override fun removeDescendantViews() {
614+
for (i in 0 until layout.childCount) {
615+
val child = layout.getChildAt(i)
558616
if (child is ViewGroup) {
559617
child.removeAllViews()
560618
}
561619
}
562-
baseLayout?.removeAllViews()
563-
baseLayout?.let { layout ->
564-
if (isVisible || layout.parent != null) {
565-
windowManager?.removeViewImmediate(layout)
566-
}
567-
}
568-
isVisible = false
569-
} catch (e: IllegalArgumentException) {
570-
isVisible = false
571-
Log.w(TAG, "Window was already removed during cleanup", e)
572-
} catch (e: Exception) {
573-
Log.e(TAG, "Error in cleanup: ${e.message}", e)
620+
layout.removeAllViews()
574621
}
575-
}
576622

577-
private fun releaseSurfaceOverlays() {
578-
surfaceOverlayHandles.values.forEach { handle ->
579-
try {
580-
handle.release()
581-
} catch (e: Exception) {
582-
Log.e(TAG, "Error releasing surface overlay", e)
583-
}
623+
override fun removeImmediately() {
624+
windowManager?.removeViewImmediate(layout)
584625
}
585-
surfaceOverlayHandles.clear()
586626
}
587627

588628
private fun layoutParamsForPlacement(placement: OverlayPlacement): RelativeLayout.LayoutParams {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.enaboapps.switchify.service.window
2+
3+
import android.util.Log
4+
5+
internal data class WindowCleanupState(
6+
val surfaceHandles: MutableMap<Any, WindowCleanupHandle>,
7+
val root: WindowCleanupRoot?,
8+
val wasVisible: Boolean
9+
)
10+
11+
internal interface WindowCleanupHandle {
12+
fun release()
13+
}
14+
15+
internal interface WindowCleanupRoot {
16+
val isAttachedToWindow: Boolean
17+
18+
fun removeDescendantViews()
19+
fun removeImmediately()
20+
}
21+
22+
internal class WindowStateCleaner(
23+
private val logError: (String, Throwable) -> Unit = { message, throwable ->
24+
Log.e(TAG, message, throwable)
25+
},
26+
private val logWarning: (String, Throwable) -> Unit = { message, throwable ->
27+
Log.w(TAG, message, throwable)
28+
}
29+
) {
30+
fun cleanup(state: WindowCleanupState) {
31+
state.surfaceHandles.values.forEach { handle ->
32+
try {
33+
handle.release()
34+
} catch (e: Exception) {
35+
logError("Error releasing surface overlay", e)
36+
}
37+
}
38+
state.surfaceHandles.clear()
39+
40+
val root = state.root ?: return
41+
try {
42+
root.removeDescendantViews()
43+
if (state.wasVisible || root.isAttachedToWindow) {
44+
root.removeImmediately()
45+
}
46+
} catch (e: IllegalArgumentException) {
47+
logWarning("Window was already removed during cleanup", e)
48+
} catch (e: Exception) {
49+
logError("Error in cleanup", e)
50+
}
51+
}
52+
53+
private companion object {
54+
private const val TAG = "WindowStateCleaner"
55+
}
56+
}

0 commit comments

Comments
 (0)