Skip to content

Commit 9ed6b13

Browse files
Prevent stale Switchify overlay surfaces (#2592)
* Prevent stale Switchify overlay surfaces - Route default-display overlays through the WindowManager root instead of SurfaceControl - Gate root overlay creation on screen interactivity and add live debug cleanup - Add policy and surface limit tests for safer overlay attachment 🤖 Auto-generated * Allow overlay root on lock screen - Remove the root-show interactivity gate so Switchify can appear during unlock flows - Keep default-display SurfaceControl prevention as the main stale surface fix - Preserve screen watcher state initialization for restart accuracy 🤖 Auto-generated --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 1c4a56a commit 9ed6b13

11 files changed

Lines changed: 165 additions & 18 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ 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.MenuHighlightHud
9+
import com.enaboapps.switchify.service.window.ServiceMessageHUD
10+
import com.enaboapps.switchify.service.window.ServiceStartupSplash
811
import com.enaboapps.switchify.service.window.SwitchifyAccessibilityWindow
912
import com.enaboapps.switchify.switches.SwitchAction
1013

@@ -23,6 +26,14 @@ class AdbTestingBridgeReceiver : BroadcastReceiver() {
2326
SwitchifyAccessibilityWindow.instance.dumpOverlayDebugState()
2427
return
2528
}
29+
if (actionName == ACTION_CLEAR_OVERLAY_STATE) {
30+
Log.d(TAG, "Performing ADB testing command: $ACTION_CLEAR_OVERLAY_STATE")
31+
ServiceMessageHUD.instance.dispose()
32+
MenuHighlightHud.instance.dispose()
33+
ServiceStartupSplash.instance.dispose()
34+
SwitchifyAccessibilityWindow.instance.cleanup()
35+
return
36+
}
2637

2738
val actionId = resolveActionId(intent)
2839
if (!isValidActionId(actionId)) {
@@ -55,6 +66,7 @@ class AdbTestingBridgeReceiver : BroadcastReceiver() {
5566
const val EXTRA_ACTION_ID = "action_id"
5667
const val ACTION_RELOAD_SETTINGS = "reload_settings"
5768
const val ACTION_DUMP_OVERLAY_STATE = "dump_overlay_state"
69+
const val ACTION_CLEAR_OVERLAY_STATE = "clear_overlay_state"
5870
private const val INVALID_ACTION_ID = -1
5971
private const val TAG = "AdbTestingBridge"
6072

app/src/main/java/com/enaboapps/switchify/service/menu/MenuViewHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class MenuViewHandler {
2323
context: android.content.Context,
2424
target: OverlayTarget.Display = OverlayTargets.defaultDisplay()
2525
) {
26-
overlayTarget = target.copy(forceSurface = true)
26+
overlayTarget = target
2727
if (!isSetup()) {
2828
baseLayout = RelativeLayout(context).apply {
2929
layoutParams = RelativeLayout.LayoutParams(

app/src/main/java/com/enaboapps/switchify/service/utils/ScreenWatcher.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.Context
55
import android.content.Intent
66
import android.content.IntentFilter
77
import android.content.res.Configuration
8+
import android.os.PowerManager
89

910
/**
1011
* This class watches the screen for changes including screen state and orientation.
@@ -34,6 +35,7 @@ class ScreenWatcher(
3435
}
3536

3637
fun register(context: Context) {
38+
isScreenOn = isScreenInteractive(context)
3739
context.registerReceiver(screenReceiver, IntentFilter().apply {
3840
addAction(Intent.ACTION_SCREEN_ON)
3941
addAction(Intent.ACTION_SCREEN_OFF)
@@ -43,6 +45,11 @@ class ScreenWatcher(
4345
currentOrientation = context.resources.configuration.orientation
4446
}
4547

48+
internal fun isScreenInteractive(context: Context): Boolean {
49+
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
50+
return powerManager?.isInteractive ?: true
51+
}
52+
4653
/**
4754
* This method is called when the screen is turned on.
4855
*/
@@ -84,4 +91,4 @@ class ScreenWatcher(
8491
// Receiver not registered, ignore
8592
}
8693
}
87-
}
94+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class MenuHighlightHud private constructor() {
168168
description: String?,
169169
target: OverlayTarget.Display = OverlayTargets.defaultDisplay()
170170
) {
171-
val hudTarget = target.copy(forceSurface = true)
171+
val hudTarget = target
172172
if (name.isNullOrBlank()) {
173173
hide()
174174
return

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ class ServiceMessageHUD private constructor() {
423423
severity: MessageSeverity,
424424
target: OverlayTarget.Display
425425
) {
426-
val hudTarget = target.copy(forceSurface = true)
426+
val hudTarget = target
427427
applicationCtx ?: run {
428428
Log.e(TAG, "ApplicationContext is null, cannot show message. Call setup() first.")
429429
return

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

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import com.enaboapps.switchify.service.window.overlay.OverlayDisplayMetricsProvi
2727
import com.enaboapps.switchify.service.window.overlay.OverlayTarget
2828
import com.enaboapps.switchify.service.window.overlay.OverlayTargets
2929
import com.enaboapps.switchify.service.window.overlay.OverlayHandle
30+
import com.enaboapps.switchify.service.window.overlay.OverlaySurfacePolicy
31+
import com.enaboapps.switchify.service.window.overlay.SurfaceOverlayLimit
3032
import com.enaboapps.switchify.service.window.overlay.SurfaceControlOverlayBackend
3133
import com.enaboapps.switchify.service.window.overlay.SwitchifyOverlayHost
3234
import java.util.concurrent.CountDownLatch
@@ -99,7 +101,7 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
99101
val rootOverlayId: String?
100102
)
101103

102-
private val defaultDisplayTarget = OverlayTargets.defaultDisplay().copy(forceSurface = true)
104+
private val defaultDisplayTarget = OverlayTargets.defaultDisplay()
103105

104106
override val lifecycle: Lifecycle
105107
get() = SwitchifyLifecycleOwner.getInstance().lifecycle
@@ -394,15 +396,24 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
394396
postIfCurrentGeneration {
395397
try {
396398
if (shouldUseSurfaceBackend(target)) {
397-
if (baseLayout?.windowToken == null) {
398-
Log.w(TAG, "Surface overlay unavailable for $target because root token is missing")
399-
if (target is OverlayTarget.Window) return@postIfCurrentGeneration
399+
surfaceOverlayHandles.remove(view)?.release()
400+
if (!SurfaceOverlayLimit.canAddSurfaceOverlay(surfaceOverlayHandles.size)) {
401+
Log.w(
402+
TAG,
403+
"Surface overlay limit reached for $target view=${view.javaClass.name}"
404+
)
405+
if (!canUseDefaultRootFallback(target)) return@postIfCurrentGeneration
400406
} else {
401-
val handle = surfaceControlBackend?.attach(target, view, placement)
402-
if (handle != null) {
403-
handle.setVisible(isVisible)
404-
surfaceOverlayHandles[view] = handle
405-
return@postIfCurrentGeneration
407+
if (baseLayout?.windowToken == null) {
408+
Log.w(TAG, "Surface overlay unavailable for $target because root token is missing")
409+
if (target is OverlayTarget.Window) return@postIfCurrentGeneration
410+
} else {
411+
val handle = surfaceControlBackend?.attach(target, view, placement)
412+
if (handle != null) {
413+
handle.setVisible(isVisible)
414+
surfaceOverlayHandles[view] = handle
415+
return@postIfCurrentGeneration
416+
}
406417
}
407418
}
408419
if (target is OverlayTarget.Window) {
@@ -583,11 +594,7 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
583594
}
584595

585596
private fun shouldUseSurfaceBackend(target: OverlayTarget): Boolean {
586-
return when (target) {
587-
is OverlayTarget.Display -> target.forceSurface ||
588-
target.displayId != OverlayTargets.DEFAULT_DISPLAY_ID
589-
is OverlayTarget.Window -> true
590-
}
597+
return OverlaySurfacePolicy.shouldUseSurfaceBackend(target)
591598
}
592599

593600
private fun canUseDefaultRootFallback(target: OverlayTarget): Boolean {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.enaboapps.switchify.service.window.overlay
2+
3+
internal object OverlaySurfacePolicy {
4+
fun shouldUseSurfaceBackend(target: OverlayTarget): Boolean {
5+
return when (target) {
6+
is OverlayTarget.Display -> target.displayId != OverlayTargets.DEFAULT_DISPLAY_ID
7+
is OverlayTarget.Window -> true
8+
}
9+
}
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.enaboapps.switchify.service.window.overlay
2+
3+
internal object SurfaceOverlayLimit {
4+
const val MAX_ACTIVE_SURFACE_OVERLAYS = 8
5+
6+
fun canAddSurfaceOverlay(activeCount: Int): Boolean {
7+
return activeCount < MAX_ACTIVE_SURFACE_OVERLAYS
8+
}
9+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.enaboapps.switchify.service.window.overlay
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class OverlaySurfacePolicyTest {
8+
@Test
9+
fun defaultDisplayDoesNotUseSurfaceBackend() {
10+
assertFalse(
11+
OverlaySurfacePolicy.shouldUseSurfaceBackend(
12+
OverlayTarget.Display(displayId = OverlayTargets.DEFAULT_DISPLAY_ID)
13+
)
14+
)
15+
}
16+
17+
@Test
18+
fun defaultDisplayWithForceSurfaceDoesNotUseSurfaceBackend() {
19+
assertFalse(
20+
OverlaySurfacePolicy.shouldUseSurfaceBackend(
21+
OverlayTarget.Display(
22+
displayId = OverlayTargets.DEFAULT_DISPLAY_ID,
23+
forceSurface = true
24+
)
25+
)
26+
)
27+
}
28+
29+
@Test
30+
fun nonDefaultDisplayUsesSurfaceBackend() {
31+
assertTrue(
32+
OverlaySurfacePolicy.shouldUseSurfaceBackend(
33+
OverlayTarget.Display(displayId = OverlayTargets.DEFAULT_DISPLAY_ID + 1)
34+
)
35+
)
36+
}
37+
38+
@Test
39+
fun windowTargetUsesSurfaceBackend() {
40+
assertTrue(
41+
OverlaySurfacePolicy.shouldUseSurfaceBackend(
42+
OverlayTarget.Window(
43+
displayId = OverlayTargets.DEFAULT_DISPLAY_ID,
44+
accessibilityWindowId = 7,
45+
windowType = 2
46+
)
47+
)
48+
)
49+
}
50+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
package com.enaboapps.switchify.service.window.overlay
22

33
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
45
import org.junit.Test
56

67
class SurfaceControlOverlayBackendTest {
78
@Test
89
fun missingHostTokenRejectsSurfaceAttachment() {
910
assertFalse(SurfaceControlOverlayBackend.hasRequiredHostToken(null))
1011
}
12+
13+
@Test
14+
fun presentHostTokenAllowsSurfaceAttachmentPrerequisite() {
15+
val token = object : android.os.IBinder {
16+
override fun getInterfaceDescriptor(): String = "test"
17+
override fun pingBinder(): Boolean = true
18+
override fun isBinderAlive(): Boolean = true
19+
override fun queryLocalInterface(descriptor: String): android.os.IInterface? = null
20+
override fun dump(fd: java.io.FileDescriptor, args: Array<out String>?) = Unit
21+
override fun dumpAsync(fd: java.io.FileDescriptor, args: Array<out String>?) = Unit
22+
override fun transact(
23+
code: Int,
24+
data: android.os.Parcel,
25+
reply: android.os.Parcel?,
26+
flags: Int
27+
): Boolean = false
28+
29+
override fun linkToDeath(recipient: android.os.IBinder.DeathRecipient, flags: Int) = Unit
30+
override fun unlinkToDeath(
31+
recipient: android.os.IBinder.DeathRecipient,
32+
flags: Int
33+
): Boolean = true
34+
}
35+
36+
assertTrue(SurfaceControlOverlayBackend.hasRequiredHostToken(token))
37+
}
1138
}

0 commit comments

Comments
 (0)