mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
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:
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)
|
||||
|
||||
Reference in new issue
Block a user