From 2476c824f623c44ba22e6f06bb02cf05848fc9aa Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sat, 7 Nov 2026 15:48:17 -0500 Subject: [PATCH] fix(dual-screen): docked handhelds switch to single-screen mode (#2232) * fix(dual-screen): docked handhelds switch to single-screen mode With Dual screen on Auto, a dual-screen handheld connected to a wired external display (dock, HDMI or USB-C monitor) no longer puts the menu, tabs or session controls on the built-in bottom screen, which nobody can see while it sits in a dock. The launcher and sessions use the single-screen layout on the main screen, which the external display mirrors. A bottom panel opened automatically closes once when the dock is connected and comes back after undocking. XR glasses, virtual and overlay displays and switched-off displays do not count as a dock, and Dual screen set to On keeps the bottom screen. * fix(dual-screen): only an automatic bottom panel closes on docking, and glasses are told apart from the dock display * fix(dual-screen): keep the automatic-panel state across activity recreation and reconcile docking before settling the panel --- .../com/droiddeck/launcher/SessionActivity.kt | 26 ++++++- .../droiddeck/launcher/input/DualScreen.kt | 16 ++++- .../launcher/session/SessionState.kt | 1 + .../launcher/input/DualScreenTest.kt | 72 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index d65f8bd33..b6a440937 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -676,6 +676,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM))) SessionState.secondScreenAutoOpened = false SessionState.secondScreenProvisional = false + SessionState.secondScreenAutomatic = false SessionState.gameOnBottom = false gameOnBottom = false } @@ -1378,6 +1379,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onSecondScreenMode = { mode -> if (docked) drawerOpen = false typingReturnMode = null + automaticPanel = false if (mode != SecondScreenMode.NONE) SessionPrefs.setSecondScreenPanel(this@SessionActivity, desktopSession, mode.id) selectSecondScreenMode(mode) }, @@ -3248,6 +3250,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } + private var dockedToDisplay = false + private var automaticPanel: Boolean + get() = SessionState.secondScreenAutomatic + set(value) { SessionState.secondScreenAutomatic = value } + private val shownOnDisplayId: Int get() = if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId else windowManager.defaultDisplay.displayId @@ -3259,6 +3266,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { .filter { glassesScreen == null || it.id != glasses.displayId } val oldIds = secondScreenDisplays.map { it.id }.toSet() secondScreenDisplays = candidates + reconcileDock(primaryId) bottomSwapAvailable = bottomGame != null || bottomGameDisplay() != null val newIds = candidates.map { it.id }.toSet() if (secondScreenMode != SecondScreenMode.NONE && oldIds.isNotEmpty() && selectedSecondScreenDisplay !in newIds) { @@ -3275,6 +3283,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updateKeyboardRoute() } + private fun reconcileDock(primaryId: Int) { + val nowDocked = com.droiddeck.launcher.input.DualScreen.dockedToDisplay(displayManager, primaryId) + val automaticOnBuiltIn = automaticPanel && secondScreenMode != SecondScreenMode.NONE && + displayManager.getDisplay(selectedSecondScreenDisplay)?.let(com.droiddeck.launcher.input.DualScreen::builtIn) == true + if (com.droiddeck.launcher.input.DualScreen.leavesBottomOnDock(dockedToDisplay, nowDocked, SessionPrefs.dualScreenSessionMode(this), automaticOnBuiltIn)) { + closeSecondScreen(reset = true) + SessionState.secondScreenAutoOpened = false + } + dockedToDisplay = nowDocked + } + private fun openDualScreenControls() { if (SessionState.secondScreenAutoOpened || secondScreenMode != SecondScreenMode.NONE || pipUi) return if (!SessionPrefs.dualScreenSessionControls(this)) return @@ -3283,6 +3302,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionState.secondScreenAutoOpened = true selectedSecondScreenDisplay = target.displayId selectSecondScreenMode(openingPanel()) + automaticPanel = secondScreenMode != SecondScreenMode.NONE dualControlsProvisional = ::loading.isInitialized && loading.visible } @@ -3697,7 +3717,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (focused && started) com.droiddeck.launcher.ui.SessionTyping.returnFocus(this, taskId) else com.droiddeck.launcher.ui.SessionTyping.close() updateKeyboardRoute() - if (reset) panelMode(SecondScreenMode.NONE) + if (reset) { + panelMode(SecondScreenMode.NONE) + automaticPanel = false + } } private fun panelMode(mode: SecondScreenMode) { @@ -4484,6 +4507,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { for (name in arrayOf("peak_refresh_rate", "min_refresh_rate")) { contentResolver.registerContentObserver(android.provider.Settings.System.getUriFor(name), false, refreshSettingObserver) } + reconcileDock(shownOnDisplayId) settleDualScreenControls() refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) diff --git a/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt b/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt index cfd37c95c..848fc6570 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/DualScreen.kt @@ -16,6 +16,7 @@ object DualScreen { const val MODE_OFF = "off" val modes = listOf(MODE_AUTO, MODE_ON, MODE_OFF) private const val TYPE_INTERNAL = 1 + private const val TYPE_EXTERNAL = 2 private val knownModels = setOf("retroid pocket duo", "ayn thor") private val touchedDisplays = mutableSetOf() private val takenListeners = java.util.concurrent.CopyOnWriteArraySet<() -> Unit>() @@ -85,9 +86,22 @@ object DualScreen { when (mode) { MODE_OFF -> null MODE_ON -> internalDisplay(displayManager, primaryDisplayId) ?: anyDisplay(displayManager, primaryDisplayId) - else -> builtInDisplay(displayManager, primaryDisplayId) + else -> if (dockedToDisplay(displayManager, primaryDisplayId)) null else builtInDisplay(displayManager, primaryDisplayId) }?.takeUnless { taken(it.displayId) } + fun leavesBottomOnDock(wasDocked: Boolean, docked: Boolean, mode: String, automaticOnBuiltIn: Boolean): Boolean = + docked && !wasDocked && mode == MODE_AUTO && automaticOnBuiltIn + + fun dockedToDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Boolean { + val glasses = com.droiddeck.launcher.glasses.Glasses.state.value + val external = displayManager.getDisplays().filter { + it.isValid && it.displayId != primaryDisplayId && it.state != Display.STATE_OFF && displayType(it) == TYPE_EXTERNAL + } + val glassesId = glasses.displayId.takeIf { it != Display.INVALID_DISPLAY } + ?: com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(external, glasses.present)?.displayId + return external.any { it.displayId != glassesId && com.droiddeck.launcher.glasses.Glasses.selectGlassesDisplay(listOf(it), false) == null } + } + fun anyDisplay(displayManager: DisplayManager, primaryDisplayId: Int): Display? = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).firstOrNull { it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 2e66d2b80..fa6eb38c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -93,6 +93,7 @@ object SessionState { @Volatile var secondScreenDisplay = -1 @Volatile var secondScreenAutoOpened = false @Volatile var secondScreenProvisional = false + @Volatile var secondScreenAutomatic = false @Volatile var gameOnBottom = false @Volatile var steamLibraryMounts: Map = emptyMap() diff --git a/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt index e50fcbadf..c510dc72f 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/DualScreenTest.kt @@ -12,7 +12,10 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowDisplayManager +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) @@ -51,6 +54,75 @@ class DualScreenTest { assertTrue(DualScreen.hasOwnTouchPanel(main, null, listOf(1920 to 1080))) } + @Test @Config(sdk = [34]) fun aWiredExternalDisplayDocksTheDevice() { + withDisplay(EXTERNAL, "HDMI Screen") { assertTrue(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) + } + + @Test @Config(sdk = [34]) fun glassesVirtualOverlayAndSwitchedOffDisplaysDoNotDock() { + withDisplay(EXTERNAL, "VITURE Luma Ultra") { assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + withDisplay(VIRTUAL, "Screen recording") { assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + withDisplay(OVERLAY, "Overlay #1") { assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + withDisplay(EXTERNAL, "HDMI Screen") { display -> + shadowOf(display).setState(Display.STATE_OFF) + assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) + } + } + + @Test @Config(sdk = [34]) fun dockingLeavesTheBuiltInPanelWhenDualScreenIsForcedOn() { + withDisplay(INTERNAL, "Bottom screen") { bottom -> + withDisplay(EXTERNAL, "HDMI Screen") { + assertTrue(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) + assertEquals(bottom.displayId, DualScreen.display(context, displays, Display.DEFAULT_DISPLAY, DualScreen.MODE_ON)?.displayId) + assertNull(DualScreen.display(context, displays, Display.DEFAULT_DISPLAY, DualScreen.MODE_AUTO)) + assertNull(DualScreen.display(context, displays, Display.DEFAULT_DISPLAY, DualScreen.MODE_OFF)) + } + } + } + + @Test @Config(sdk = [34]) fun glassesOnUsbDoNotHideAnUnnamedDockDisplay() { + val state = ReflectionHelpers.getField>( + com.droiddeck.launcher.glasses.Glasses, "_state") + val before = state.value + try { + state.value = before.copy(present = true) + withDisplay(EXTERNAL, "") { assertFalse(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + withDisplay(EXTERNAL, "VITURE Luma Ultra") { + withDisplay(EXTERNAL, "") { assertTrue(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + } + withDisplay(EXTERNAL, "") { glasses -> + state.value = before.copy(present = true, displayId = glasses.displayId) + withDisplay(EXTERNAL, "") { assertTrue(DualScreen.dockedToDisplay(displays, Display.DEFAULT_DISPLAY)) } + } + } finally { state.value = before } + } + + @Test fun onlyTheMomentOfDockingClosesAnAutomaticBottomPanel() { + assertTrue(DualScreen.leavesBottomOnDock(wasDocked = false, docked = true, DualScreen.MODE_AUTO, automaticOnBuiltIn = true)) + assertFalse(DualScreen.leavesBottomOnDock(wasDocked = true, docked = true, DualScreen.MODE_AUTO, automaticOnBuiltIn = true)) + assertFalse(DualScreen.leavesBottomOnDock(wasDocked = false, docked = false, DualScreen.MODE_AUTO, automaticOnBuiltIn = true)) + assertFalse(DualScreen.leavesBottomOnDock(wasDocked = false, docked = true, DualScreen.MODE_AUTO, automaticOnBuiltIn = false)) + assertFalse(DualScreen.leavesBottomOnDock(wasDocked = false, docked = true, DualScreen.MODE_ON, automaticOnBuiltIn = true)) + assertFalse(DualScreen.leavesBottomOnDock(wasDocked = false, docked = true, DualScreen.MODE_OFF, automaticOnBuiltIn = true)) + } + + private companion object { + const val INTERNAL = 1 + const val EXTERNAL = 2 + const val OVERLAY = 4 + const val VIRTUAL = 5 + } + + private fun withDisplay(type: Int, name: String, check: (Display) -> Unit) { + val id = ShadowDisplayManager.addDisplay("w640dp-h480dp", type) + try { + val display = displays.getDisplay(id) + shadowOf(display).setFlags(Display.FLAG_PRESENTATION) + shadowOf(display).setName(name) + check(display) + } finally { ShadowDisplayManager.removeDisplay(id) } + } + @Test fun oneScreenDevicesGetNoSecondScreenInAnyMode() { for (mode in DualScreen.modes) { SessionPrefs.setDualScreenMode(context, mode)