diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index a56fb7631..5e9e004e0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -59,6 +59,7 @@ class SecondScreenPresentation( private var imeSeen = false private var fieldKeyboard = false private var terminalPanel: SessionTerminalPanel? = null + private var controlsView: InputSurfaceView? = null private var created = false init { @@ -212,6 +213,7 @@ class SecondScreenPresentation( dropChat() keyboardHost?.hide() keyboardHost = null + controlsView = null dismiss() } @@ -234,7 +236,14 @@ class SecondScreenPresentation( return super.dispatchGenericMotionEvent(event) } - fun refreshControls() { if (created) renderMode() } + fun refreshControls() { + if (!created || currentMode != SecondScreenMode.CUSTOM_CONTROLS) return + val controls = controlsView ?: return + val layout = com.droiddeck.launcher.input.InputLibraries.read(context) + .activeLayout(SessionState.mode == SessionService.MODE_DESKTOP, com.droiddeck.launcher.input.InputRole.SECONDARY) + controls.updateCustomLayout(layout?.elements.orEmpty()) + controls.updateStyle(layout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) + } fun showKeyboard(): Boolean { val host = keyboardHost ?: return false @@ -263,6 +272,7 @@ class SecondScreenPresentation( closeTerminal() closeDashboard() root.removeAllViews() + controlsView = null ownComposeViews() val library = com.droiddeck.launcher.input.InputLibraries.read(context) val profileLayout = library.activeLayout(SessionState.mode == SessionService.MODE_DESKTOP, com.droiddeck.launcher.input.InputRole.SECONDARY) @@ -277,8 +287,10 @@ class SecondScreenPresentation( when (mode) { SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.CUSTOM_CONTROLS -> { - root.addView(InputSurfaceView(context, pad, profileLayout?.elements.orEmpty(), - surfaceStyle = profileLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()), weightParams()) + val controls = InputSurfaceView(context, pad, profileLayout?.elements.orEmpty(), + surfaceStyle = profileLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) + controlsView = controls + root.addView(controls, weightParams()) attachKeyboard() } SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.DECK_CONTROLS -> Unit diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt index aaf7a9a8f..e03a81037 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/SessionSecondScreenTest.kt @@ -23,6 +23,13 @@ import com.droiddeck.launcher.input.PadBridgeRoutingTest import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import org.junit.After +import com.droiddeck.launcher.input.InputLibraries +import com.droiddeck.launcher.input.InputRole +import com.droiddeck.launcher.input.InputSurfaceElement +import com.droiddeck.launcher.input.InputSurfaceStyle +import com.droiddeck.launcher.input.InputControl +import org.junit.Assert.assertSame +import org.junit.Assert.assertNotNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -141,6 +148,74 @@ class SessionSecondScreenTest { } } + @Test fun profileRefreshLeavesTerminalAndDashboardIntact() { + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + val panel = SecondScreenPresentation(activity, display, {}, {}, {}, dashboard = { androidx.compose.material3.Text("Dashboard") }) + try { + panel.show() + for (mode in listOf(SecondScreenMode.TERMINAL, SecondScreenMode.DASHBOARD)) { + panel.showMode(mode) + val root = panel.window!!.decorView + val before = descendants(root).toList() + val terminal: Any? = ReflectionHelpers.getField(panel, "terminalPanel") + panel.refreshControls() + assertEquals(before, descendants(root).toList()) + assertSame(terminal, ReflectionHelpers.getField(panel, "terminalPanel")) + } + } finally { panel.closeControls() } + } + + @Test fun profileRefreshUpdatesControlsWithoutClosingTheFieldKeyboard() { + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + var closed = 0 + val panel = SecondScreenPresentation(activity, display, {}, {}, {}, onKeyboardClosed = { closed++ }) + try { + panel.show() + panel.showMode(SecondScreenMode.CUSTOM_CONTROLS) + panel.requestKeyboard(forField = true) + val host: Any = ReflectionHelpers.getField(panel, "keyboardHost") + val shown: MutableState = ReflectionHelpers.getField(panel, "keyboardShown") + shown.value = true + ReflectionHelpers.setField(panel, "imeSeen", true) + val controls = descendants(panel.window!!.decorView).filterIsInstance().single() + val element = InputSurfaceElement(InputControl.A, .3f, .2f, .2f, .2f, "updated") + InputLibraries.update(activity) { library -> + val layout = library.activeLayout(false, InputRole.SECONDARY)!! + library.withLayout(layout.copy(elements = listOf(element), style = InputSurfaceStyle(opacity = 35))) + } + panel.refreshControls() + assertSame(controls, descendants(panel.window!!.decorView).filterIsInstance().single()) + val child = descendants(controls).single { it.tag == element } + assertEquals(.35f, child.alpha, .001f) + assertSame(host, ReflectionHelpers.getField(panel, "keyboardHost")) + assertTrue(panel.fieldKeyboardOpen) + assertTrue(shown.value) + assertTrue(ReflectionHelpers.getField(panel, "imeSeen")) + assertEquals(0, closed) + } finally { panel.closeControls() } + } + + @Test fun profileRefreshPreservesTheLowerScreenChatPanel() { + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + var lost = 0 + val panel = SecondScreenPresentation(activity, display, {}, {}, {}) + try { + panel.show() + panel.showMode(SecondScreenMode.CUSTOM_CONTROLS) + panel.showChat(onLost = { lost++ }) { androidx.compose.material3.Text("Chat draft") } + val chat: View? = ReflectionHelpers.getField(panel, "chatView") + assertNotNull(chat) + panel.refreshControls() + assertSame(chat, ReflectionHelpers.getField(panel, "chatView")) + assertTrue(descendants(panel.window!!.decorView).any { it === chat }) + assertEquals(0, lost) + } finally { panel.closeControls() } + } + @Test @Config(sdk = [34], shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) fun controllerBumpersReachTheSecondScreenInBothSessionModes() { diff --git a/docs/development/unified-input-validation.md b/docs/development/unified-input-validation.md index 90f7256ae..f5dfdbfa8 100644 --- a/docs/development/unified-input-validation.md +++ b/docs/development/unified-input-validation.md @@ -113,6 +113,18 @@ runtime auto-update and Proton menu auto-selection. The gamescope p22 archive was downloaded from the newly pinned component repository and verified against upstream's SHA-256 before refreshing the local runtime inputs. +### Second-screen refresh follow-up, 2026-11-07 + +Refreshing a profile now updates the existing custom control surface in place. +Terminal and dashboard modes are untouched, and the custom surface keeps its +keyboard host, field-keyboard state and WoW chat panel. Actual mode changes +continue to use the existing full teardown/rebuild path. + +All **35 tests in 5 suites passed** across `SessionSecondScreenTest`, the +surface UI suites and `InputSurfaceSourceTest`. Three new regressions cover +terminal/dashboard identity, changed elements/style with an open field keyboard, +and preservation of the lower-screen chat view and its dismissal callback. + ## Input settings polish, 2026-11-06 The final input/UI host run passed **941 tests in 124 suites, zero failures**.