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
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-11-07 15:48:17 -05:00
1 parent d74575ed7f
commit 2476c824f6
4 files changed
+113 -2

No files matched your search

@@ -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)
@@ -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<Int>()
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
@@ -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<String, java.io.File> = emptyMap()
@@ -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<kotlinx.coroutines.flow.MutableStateFlow<com.droiddeck.launcher.glasses.GlassesState>>(
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)