Compare commits

..
88 changed files with 168 additions and 1183 deletions

No files matched your search

+10 -3
View File
@@ -785,11 +785,18 @@ jobs:
cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60
python-tests:
name: Python and native regression tests
python-runner:
needs: source
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
uses: ./.github/workflows/pick-runner.yml
secrets:
RUNNER_STATUS_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
python-tests:
name: Python and native regression tests
needs: [source, python-runner]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ${{ fromJSON(needs.python-runner.outputs.labels) }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
@@ -603,19 +603,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}
fun selectDriver(id: String, linux: Boolean) {
if (!linux && id == TurnipDriver.AUTO) {
DriverBundle.selectDisplay(activity, id)
pairModeOpen = false
lastAutoCheck = 0L
setMode(auto = true)
refreshDrivers()
return
}
val bundle = DriverBundle.containing(activity, id, linux)
when {
bundle != null -> useBundle(bundle)
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> DriverBundle.selectDisplay(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
pairModeOpen = false
setMode(auto = false, mix = true)
@@ -23,7 +23,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -874,7 +873,6 @@ class FrontEndActivity : ComponentActivity() {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
Library.recordPlayed(this, game)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.returnTo(returnHome)
@@ -890,15 +888,7 @@ class FrontEndActivity : ComponentActivity() {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}
.playing(game), steamSession = true)
}
private fun Intent.playing(game: Library.SteamGame): Intent =
apply { if (game.library == Library.ADDED) putExtra(SessionActivity.EXTRA_PLAYED_GAME, game.gameIdString) }
private fun recordPlayed(intent: Intent) {
intent.getStringExtra(SessionActivity.EXTRA_PLAYED_GAME)?.let { Library.recordPlayed(this, it) }
}, steamSession = true)
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
@@ -925,17 +915,14 @@ class FrontEndActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
.playing(game)
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, next, returnHome) -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args -> {
recordPlayed(next)
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
else -> pendingSwitch = next
}
}
@@ -1136,10 +1123,6 @@ class FrontEndActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
val prootPack by com.droiddeck.launcher.core.AddonPack.PROOT.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(prootPack) {
if (prootPack == com.droiddeck.launcher.core.AddonPack.State.Ready && installed == null) refresh()
}
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
@@ -2293,14 +2276,14 @@ class FrontEndActivity : ComponentActivity() {
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
handToBattleNet(full, next) -> {}
handToBattleNet(full) { pendingSwitch = next } -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, next: Intent, returnHome: Boolean = false): Boolean {
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
@@ -2311,13 +2294,10 @@ class FrontEndActivity : ComponentActivity() {
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
ui.post {
if (isDestroyed) return@post
if (!handed) pendingSwitch = next
else {
recordPlayed(next)
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
if (!handed) otherwise()
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
}, "battlenet-request").start()
return true
@@ -178,9 +178,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var unfoldedPanel by mutableStateOf<OnScreenControls?>(null)
private var unfoldedColumn: android.widget.LinearLayout? = null
private var unfoldedDeckSurface: com.droiddeck.launcher.ui.InputSurfaceView? = null
private var unfoldedDeckPanel by mutableStateOf<com.droiddeck.launcher.input.DeckPanel?>(null)
private var deckPanelLayouts: Map<com.droiddeck.launcher.input.DeckPanel, com.droiddeck.launcher.input.SavedInputLayout?> = emptyMap()
private lateinit var sessionRoot: FrameLayout
private var inputRouter: InputRouter? = null
private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null
@@ -568,7 +565,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val inputWindowDisplays = mutableListOf<Int>()
private val bottomScreenListener: () -> Unit = {
if (secondScreenPresentation?.display?.displayId?.let(com.droiddeck.launcher.input.DualScreen::taken) == true) closeSecondScreen(reset = false)
updateBottomGame()
@@ -981,7 +977,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay({ cursorPos }, cursorVisible && (!uiPointer || onlyPcKeyboardOpen()) && !bottomOutputActive,
CursorOverlay({ cursorPos }, cursorVisible && !uiPointer && !bottomOutputActive,
resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (memoryHud.text.isNotEmpty()) HudText(memoryHud.text, hudColor, hudOpacity)
@@ -1226,7 +1222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground, touchMode = touchMode,
micAvailable = SessionState.micAvailable && !SessionState.suspended,
micEnabled = SessionState.micEnabled,
@@ -1447,7 +1443,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
},
onClose = { drawerOpen = false },
onDismissScreen = if (docked) ({ selectSecondScreenMode(SecondScreenMode.NONE) }) else null,
components = drawerComponents,
onComponentsRefresh = {
refreshDrawerComponents()
@@ -2230,7 +2225,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_GAME_ID),
sessionActivityDisplayId,
)
intent.getStringExtra(EXTRA_PLAYED_GAME)?.let { com.droiddeck.launcher.frontend.Library.recordPlayed(this, it) }
}
applyFrameGen()
applyGameAlignment()
@@ -2321,11 +2315,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::twoFingerGestures.isInitialized) twoFingerGestures.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
if (sessionTouchDown) dismissTouch = true
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
@@ -2834,7 +2826,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return onScreenControls?.let { takes(it, it::takesTouchAt) } == true ||
steamTouchControls?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedDeckSurface?.let { takes(it, it::takesTouchAt) } == true ||
unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true
}
@@ -3387,10 +3378,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
if (target.displayId in inputWindowDisplays) {
panelMode(mode)
return
}
dropDockedDrawer()
presentation?.closeControls()
presentation = secondScreenPresentation(target)
@@ -3531,15 +3518,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val main = window.decorView.display
if (role == InputRole.PRIMARY) return bottomGame?.display ?: main
if (bottomGame != null) return main
return com.droiddeck.launcher.input.SecondScreenDisplays.editorCandidates(selectedSecondScreenDisplay,
secondScreenDisplays.map { it.id }, com.droiddeck.launcher.input.DualScreen::taken).firstNotNullOfOrNull(displayManager::getDisplay)
}
fun inputWindowShown(displayId: Int) { inputWindowDisplays += displayId }
fun inputWindowClosed(displayId: Int) {
inputWindowDisplays -= displayId
if (started && displayId !in inputWindowDisplays && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
return displayManager.getDisplay(selectedSecondScreenDisplay)
?: secondScreenDisplays.firstOrNull()?.let { displayManager.getDisplay(it.id) }
}
private fun refreshInputProfile() {
@@ -3550,7 +3530,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
adaptiveSticks = primaryInputLayout?.adaptiveSticks
quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty()
secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this))
deckPanelLayouts = com.droiddeck.launcher.input.DeckPanel.entries.associateWith { library.panelLayout(desktop, it.bindings) }
inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native }
inputUsesPointer = inputProfile.bindings.values.any { bindings ->
bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll }
@@ -3564,8 +3543,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls?.updateLayout(primaryInputLayout)
bottomInputControls?.updateLayout(primaryInputLayout)
unfoldedPanel?.updateLayout(primaryInputLayout)
if (unfoldedPanel != null) applyUnfoldedControls()
showUnfoldedDeckPanel()
updateOnScreenPad()
secondScreenPresentation?.refreshControls()
}
@@ -3617,15 +3594,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
else "unfolded controls: game full screen")
}
val portrait = !com.droiddeck.launcher.session.SessionDisplay.foldable(this)
val dock = if (SessionState.deckPad) com.droiddeck.launcher.input.OnScreenLayout.Dock.deck(portrait, deckPanelLayouts[com.droiddeck.launcher.input.DeckPanel.GAMEPAD])
else com.droiddeck.launcher.input.OnScreenLayout.Dock(portrait, false)
val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad)
var panel = unfoldedPanel
if (panel != null && (!wanted || panel.dock != dock)) {
sessionRoot.removeView(unfoldedColumn)
unfoldedColumn = null
unfoldedDeckSurface = null
unfoldedDeckPanel = null
unfoldedPanel = null
panel = null
if (!wanted) updateOnScreenControls()
@@ -3642,36 +3615,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1)
unfoldedColumn = column
unfoldedPanel = panel
showUnfoldedDeckPanel()
updateOnScreenControls()
}
unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
}
private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) {
val deck = unfoldedDeckPanel
com.droiddeck.launcher.ui.sessionBarModel(getString(if (deck != null) R.string.second_mode_deck_controls else R.string.deck_tab_gamepad),
{ chooseUnfoldedControls(false) }, { drawerOpen = true }, ::tapSteamButton, ::tapQam,
tabs = deck?.let { com.droiddeck.launcher.ui.deckPanelTabs(this, it, ::selectDeckPanel) })
}
private fun showUnfoldedDeckPanel() {
val column = unfoldedColumn
val panel = unfoldedPanel
val selected = SessionPrefs.deckPanel(this).takeIf { column != null && panel?.dock?.grips == true }
unfoldedDeckPanel = selected
val layout = selected?.takeIf { it != com.droiddeck.launcher.input.DeckPanel.GAMEPAD }?.let { deckPanelLayouts[it] }
if (layout != null && unfoldedDeckSurface?.tag == layout) return
unfoldedDeckSurface?.let { it.releaseInputs(); column?.removeView(it) }
unfoldedDeckSurface = null
if (column == null || panel == null) return
if (layout == null) { panel.visibility = View.VISIBLE; return }
panel.releaseAll()
panel.visibility = View.GONE
unfoldedDeckSurface = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, layout.elements, InputRole.PRIMARY, layout.style, haptics = true).also {
it.tag = layout
column.addView(it, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) },
{ drawerOpen = true }, ::tapSteamButton, ::tapQam)
}
private fun bottomKeyboardPresentation(): SecondScreenPresentation? {
@@ -3967,17 +3918,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var lockedSwipeX = 0f
private var lockedSwipeY = 0f
private var dismissTouch = false
private var sessionTouchDown = false
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
sessionTouchDown = true
dismissTouch = false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> sessionTouchDown = false
}
if (drawerOpen && drawerDocked && event.actionMasked == MotionEvent.ACTION_DOWN) {
drawerLayerOpen = false
drawerOpenState = false
@@ -3989,7 +3932,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) dismissTouch = false
return true
}
val uiBlocking = drawerOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
val uiBlocking = drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || inputSettingsOpen || pipUi || SessionState.suspended
if (event.actionMasked == MotionEvent.ACTION_DOWN && !uiBlocking && routedTouchMode != SessionPrefs.TOUCH_GESTURES) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -4017,7 +3960,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.input.TouchRouter.Route.SCROLL -> scrollTouch.onTouch(event)
else -> {
if (com.droiddeck.launcher.input.TouchRouter.steamKeyboardZone(snapshot)) {
val wasActive = threeFingerTap.active && event.actionMasked != MotionEvent.ACTION_DOWN
val wasActive = threeFingerTap.active
if (threeFingerTap.onTouch(event)) padBridge?.triggerSteamKeyboard()
if (threeFingerTap.active && !wasActive) {
if (route == com.droiddeck.launcher.input.TouchRouter.Route.TOUCHPAD) touchpad.cancel()
@@ -4093,10 +4036,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false
if (lockedSwipeUsed && (event.actionMasked != MotionEvent.ACTION_MOVE || !programLocksPointer())) return true
if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) {
if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancelTouch()
lockedSwipeUsed = true
directTouchHold.cancelPendingHold()
val scale = sceneScale(rect)
sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale)
return true
@@ -4213,9 +4155,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyPlatformPointer(appUiOwnsPointer())
}
private fun onlyPcKeyboardOpen(): Boolean =
pcKeyboardOpen && !drawerOpen && !achievementsOpen && !inputSettingsOpen && !pipUi && !glassesOutputActive && !touchEditorOpen && !wowChatOnTop
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || inputSettingsOpen || pipUi || glassesOutputActive || bottomOutputActive || touchEditorOpen || wowChatOnTop
@@ -4306,7 +4245,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenPad()
val c = onScreenControls
glassesSlot = when {
c == null || c.visibility != View.VISIBLE || c.isPinnedOnly -> 0
c == null || c.visibility != View.VISIBLE -> 0
c.isButtonsOnly -> -1
primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } }
?: (controllerSettings?.keyboardButton != false) -> 2
@@ -4321,17 +4260,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
fun showPinned() {
controls.setPinnedOnly(true)
val pinned = controls.hasPinned && !pipUi && !inputSettingsOpen
if (!pinned) controls.releaseAll()
controls.visibility = if (pinned) View.VISIBLE else View.GONE
}
if (pipUi || unfoldedPanel != null || pcKeyboardOpen || inputSettingsOpen ||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
onScreenButtonsVisible = false
hideBottomControls()
showPinned()
controls.releaseAll()
controls.visibility = View.GONE
steamTouchControls?.let { it.cancelEditing(); it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
@@ -4364,7 +4298,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val drawn = show && !keyboardCoversPad()
if (steam != null) {
hideBottomControls()
showPinned()
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!drawn) { steam.cancelEditing(); steam.releaseAll() }
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
@@ -4373,7 +4307,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
val bottomRoot = bottomGame?.surfaceView?.parent as? android.view.ViewGroup
if (bottomRoot != null && (drawn || controls.hasPinned)) {
if (drawn && bottomRoot != null) {
controls.releaseAll(); controls.visibility = View.GONE
var bottom = bottomInputControls
if (bottom?.parent !== bottomRoot) {
@@ -4386,14 +4320,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
bottom.updateLayout(primaryInputLayout)
bottom.setButtonsOnly(buttonsOnly)
bottom.setPinnedOnly(!drawn)
bottom.visibility = View.VISIBLE
return
}
hideBottomControls()
val wasDrawn = controls.visibility == View.VISIBLE && !controls.isPinnedOnly
if (!drawn) showPinned() else { controls.setPinnedOnly(false); controls.visibility = View.VISIBLE }
if (drawn == wasDrawn) return
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
@@ -5136,7 +5069,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra)))
SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label),
tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel)))
else -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(secondScreenMode.label)))
else -> Unit
}
androidx.compose.runtime.key(profileLayout) {
androidx.compose.ui.viewinterop.AndroidView(
@@ -5457,7 +5390,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_LINK_TASK = "linkTask"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_PLAYED_GAME = "com.droiddeck.launcher.PLAYED_GAME"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TEXT_FIELD_TAP_MS = 1_000L
@@ -18,10 +18,9 @@ object Library {
val source: String = SOURCE_STEAM,
val storeId: String? = null,
val logo: File? = null,
val installedAt: Long = 0L,
) {
fun withArt(art: File?, hero: File?, icon: File?, logo: File? = this.logo) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo, installedAt)
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId, logo)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val launchKey: String get() = if (storeId != null) "$source:$storeId" else gameIdString
@@ -32,7 +31,6 @@ object Library {
}
}
const val ADDED = "added"
private const val PLAYED = "added_games_played"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
const val BATTLENET_LAUNCHER = "battlenet-launcher"
@@ -223,18 +221,6 @@ object Library {
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
}
fun recordPlayed(context: Context, game: SteamGame) {
if (game.library == ADDED) recordPlayed(context, game.gameIdString)
}
fun recordPlayed(context: Context, gameId: String) {
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).edit()
.putLong(gameId, System.currentTimeMillis() / 1000).apply()
}
internal fun recordedPlay(context: Context, gameId: Long): Long =
context.getSharedPreferences(PLAYED, Context.MODE_PRIVATE).getLong(java.lang.Long.toUnsignedString(gameId), 0L)
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
@@ -263,13 +249,12 @@ object Library {
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, lastPlayed = maxOf(lastPlayed, recordedPlay(context, g.gameId)), gameFiles = g.folder,
hero = art.hero ?: art.header, lastPlayed = lastPlayed, gameFiles = g.folder,
protonPrefix = if (g.source == com.droiddeck.launcher.stores.Store.BATTLENET.id) File(com.droiddeck.launcher.runtime.BattleNet.prefix(context), "pfx").takeIf { it.isDirectory }
else protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
source = g.source, storeId = g.storeId,
logo = art.logo,
installedAt = g.folder.lastModified(),
)
}
@@ -27,7 +27,6 @@ object LibraryCache {
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
logo = o.file("logo"),
installedAt = o.optLong("installedAt"),
)
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
@@ -45,7 +44,7 @@ object LibraryCache {
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon).putFile("logo", g.logo)
.put("source", g.source).put("installedAt", g.installedAt).apply { g.storeId?.let { put("storeId", it) } },
.put("source", g.source).apply { g.storeId?.let { put("storeId", it) } },
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -159,12 +159,6 @@ object DriverBundle {
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
}
fun selectDisplay(context: Context, id: String) {
val left = active(context)
SessionPrefs.setAndroidDriver(context, id)
if (left != null && left.androidId != id) SessionPrefs.setLinuxDriver(context, "")
}
fun remove(context: Context, bundle: Bundle) {
TurnipDriver(context).remove(bundle.androidId)
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
@@ -9,7 +9,6 @@ import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.SystemClock
import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
@@ -34,8 +33,6 @@ object BottomScreenGuests {
else -> null
}
internal fun current(arrivedAt: Long, ownShownAt: Long?): Boolean = ownShownAt == null || arrivedAt >= ownShownAt
internal fun ignorable(type: Int): Boolean = type == AccessibilityWindowInfo.TYPE_INPUT_METHOD ||
type == AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY ||
type == AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER ||
@@ -46,7 +43,7 @@ object BottomScreenGuests {
}
class BottomScreenWatch(private val service: AccessibilityService) {
private class Arrival(val pkg: String, val at: Long)
private class Arrival(val pkg: String)
private val thread = HandlerThread("bottom-screen").apply { start() }
private val handler = Handler(thread.looper)
@@ -55,8 +52,6 @@ class BottomScreenWatch(private val service: AccessibilityService) {
private var homes: Set<String> = emptySet()
private var keyboards: Set<String> = emptySet()
private var holders: Map<Int, String?> = emptyMap()
private var ownWindows: Map<Int, Set<Int>> = emptyMap()
private val ownShownAt = mutableMapOf<Int, Long>()
private val check = Runnable { evaluate() }
fun start() {
@@ -77,7 +72,7 @@ class BottomScreenWatch(private val service: AccessibilityService) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val pkg = event.packageName?.toString() ?: return
if (event.displayId == Display.DEFAULT_DISPLAY || pkg == own || pkg == SYSTEM_UI) return
arrivals[event.displayId] = Arrival(pkg, event.eventTime)
arrivals[event.displayId] = Arrival(pkg)
}
AccessibilityEvent.TYPE_WINDOWS_CHANGED -> Unit
else -> return
@@ -102,25 +97,19 @@ class BottomScreenWatch(private val service: AccessibilityService) {
val all = runCatching { service.windowsOnAllDisplays }.getOrNull() ?: return
val displays = service.getSystemService(DisplayManager::class.java) ?: return
val taken = mutableMapOf<Int, String?>()
val shown = mutableMapOf<Int, Set<Int>>()
val now = SystemClock.uptimeMillis()
for (i in 0 until all.size()) {
val id = all.keyAt(i)
if (id == Display.DEFAULT_DISPLAY) continue
val display = displays.getDisplay(id)?.takeIf(DualScreen::builtIn) ?: continue
val windows = all.valueAt(i)
val topPkg = windows.asSequence()
.filter { !BottomScreenGuests.ignorable(it.type) && cover(it, display) >= BottomScreenGuests.MIN_COVER }
.sortedByDescending { it.layer }
.map { it to packageOf(it) }
val ours = windows.filter { it.second == own }.map { it.first.id }.toSet()
if (!ownWindows[id].orEmpty().containsAll(ours)) ownShownAt[id] = now
shown[id] = ours
val topPkg = windows.firstNotNullOfOrNull { it.second?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]?.takeIf { BottomScreenGuests.current(it.at, ownShownAt[id]) }
seen[id]?.takeIf { topPkg != null || arrival == null }?.let { arrivals.remove(id, it) }
.firstNotNullOfOrNull { packageOf(it)?.takeUnless { pkg -> pkg == own } }
val arrival = seen[id]
if (topPkg != null && arrival != null) arrivals.remove(id, arrival)
BottomScreenGuests.holder(topPkg, arrival?.pkg?.takeUnless { it in keyboards }, homes)?.let { taken[id] = it }
}
ownWindows = shown
if (current !== this) return
if (taken != holders) Log.i(TAG, "bottom screen holders: $taken")
holders = taken
@@ -73,7 +73,7 @@ object ControllerPrefs {
keyboardButton = p.getBoolean("keyboardButton", true),
selectSteam = p.getBoolean("selectSteam", false),
joyConVr = joyConVr(context),
snap = snap(context),
snap = p.getBoolean("snap", true),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
deadZone = deadZone(context),
@@ -87,6 +87,11 @@ object ControllerPrefs {
prefs(context).edit().putString("map.$id", target).apply()
}
fun resetMapping(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("map.") }.forEach { remove(it) } }.apply()
}
fun setTint(context: Context, tint: Int) {
prefs(context).edit().putInt("tint", tint).apply()
}
@@ -124,6 +129,10 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("steamButton", on).apply()
}
fun setQamButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("qamButton", on).apply()
}
fun setKeyboardButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
@@ -138,8 +147,6 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("joyConVr", on).apply()
}
fun snap(context: Context): Boolean = prefs(context).getBoolean("snap", true)
fun setSnap(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("snap", on).apply()
}
@@ -165,4 +172,9 @@ object ControllerPrefs {
fun resetAll(context: Context) {
prefs(context).edit().clear().putBoolean("rumble", true).apply()
}
fun resetAllLayouts(context: Context) {
val p = prefs(context)
p.edit().apply { p.all.keys.filter { it.startsWith("layout.") }.forEach { remove(it) } }.apply()
}
}
@@ -70,14 +70,6 @@ class DirectTouchHold(
handler.removeCallbacks(hold)
}
fun cancelTouch() {
handler.removeCallbacks(hold)
handler.removeCallbacks(lift)
liftPending = false
if (forwarded) touch.cancel()
forwarded = false
}
fun onTouch(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
if (liftPending) {
@@ -4,13 +4,12 @@ import android.content.Context
import android.hardware.display.DisplayManager
import android.util.DisplayMetrics
import android.view.Display
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
object InputLibraries {
private const val VERSION = 14
private const val VERSION = 12
private const val KEY = "library"
private const val RECOVERY = "library-recovery."
const val OVERLAY = "builtin.overlay"
@@ -31,8 +30,6 @@ object InputLibraries {
if (version < 11) library = neutralStockLayouts(library)
if (version < 10) library = restyleGamepads(context, library)
if (version < 12) library = barStockLayouts(library)
if (version < 13) library = LegacyScreenKeyMigration.pin(library)
if (version < 14) library = deckGamepadSurfaces(context, library)
if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) {
val legacy = LegacyScreenKeyMigration.snapshot(context)
if (legacy != null) {
@@ -266,15 +263,6 @@ object InputLibraries {
return addStockLayouts(result)
}
private fun deckGamepadSurfaces(context: Context, library: InputLibrary): InputLibrary {
val leftPad = SessionPrefs.gamepadSurface(context, right = false) == SessionPrefs.SURFACE_TRACKPAD
val rightStick = SessionPrefs.gamepadSurface(context, right = true) == SessionPrefs.SURFACE_STICK
if (!leftPad && !rightStick) return library
val saved = library.layouts.firstOrNull { it.id == DeckPanel.GAMEPAD.layout } ?: return library
if (saved.elements != InputStockLayouts.gamepad(leftPad = false, rightStick = false)) return library
return library.withLayout(saved.copy(elements = InputStockLayouts.gamepad(leftPad, rightStick))).validate()
}
private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String {
val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8)
val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed)
@@ -304,7 +292,7 @@ object InputLibraries {
for (e in layout.elements) elements.put(JSONObject().put("id", e.id).put("kind", e.kind.name)
.put("control", e.control.id).put("name", e.name).put("x", e.x.toDouble()).put("y", e.y.toDouble())
.put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image)
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel).put("alwaysShown", e.alwaysShown))
.put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel))
layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements)
.put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble())
.put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL))
@@ -345,7 +333,7 @@ object InputLibraries {
e.getDouble("y").toFloat(), e.getDouble("width").toFloat(), e.getDouble("height").toFloat(),
e.getString("id"), InputElementKind.valueOf(e.getString("kind")), e.getString("name"),
e.optBoolean("floating", true), e.optBoolean("hideWhenIdle", false), e.optBoolean("mouseGestures", false), e.optBoolean("toggle", false), e.optInt("icon", 0), e.optString("image", ""),
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true), e.optBoolean("alwaysShown", false))
e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true))
}
SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")),
InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100),
@@ -45,14 +45,6 @@ internal object InputStockLayouts {
InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL),
InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL))
}
fun gamepad(leftPad: Boolean, rightStick: Boolean) = gamepad().map {
when {
leftPad && it.control == InputControl.LS -> InputSurfaceElement(InputControl.LEFT_PAD, it.x, it.y, it.width, it.height)
rightStick && it.control == InputControl.RIGHT_PAD -> InputSurfaceElement(InputControl.RS, it.x, it.y, it.width, it.height)
else -> it
}
}
fun layouts() = listOf(
SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY),
SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf(
@@ -34,7 +34,6 @@ data class InputSurfaceElement(
val opacity: Float = 1f,
val buttonShape: InputButtonShape = InputButtonShape.ROUNDED,
val showLabel: Boolean = true,
val alwaysShown: Boolean = false,
) {
val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD
@@ -11,7 +11,6 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole
private val gestureRight = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(273)) else null
private val gestureMiddle = if (element.mouseGestures) router?.channel(role, InputControl.A, fixedAction = InputAction.MouseButton(274)) else null
val mouseGestures get() = element.mouseGestures && route?.action is InputAction.Pointer
val directions get() = route?.action as? InputAction.Directions
fun gestureContact(touching: Boolean) { if (!closed) route?.update(0f, 0f, touching) }
fun pointerDelta(dx: Float, dy: Float) {
@@ -225,8 +225,7 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
)
}
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean, left: InputControl = InputControl.LS,
right: InputControl = InputControl.RS): List<InputSurfaceElement> {
fun dock(stored: List<InputSurfaceElement>, portrait: Boolean, grips: Boolean): List<InputSurfaceElement> {
val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait)
val placed = mutableListOf<InputSurfaceElement>()
fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) {
@@ -244,9 +243,9 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
b.cx + direction.first * spread, b.cy + direction.second * spread, radius)
}
}
box(left, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE)
cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND)
box(right, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE)
cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND)
box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL)
box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL)
@@ -255,8 +254,7 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid
box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL)
box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL)
if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) }
val surfaces = listOf(left, right).filter { it.isTrackpad }
return withGeometry(stored, placed) + placed.filter { p -> (GRIPS.any { it.first == p.control } || p.control in surfaces) && stored.none { it.id == p.id } }
return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } }
}
private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) =
@@ -40,7 +40,7 @@ internal object LegacyScreenKeyMigration {
if (result.profiles.any { it.id == id }) continue
val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density)
if (converted.isEmpty()) continue
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first.copy(alwaysShown = true) }, style = style, screen = InputLayoutScreen.MAIN,
val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN,
aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f))
val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second })
val base = library.profile(library.steam)
@@ -68,17 +68,6 @@ internal object LegacyScreenKeyMigration {
return result.validate()
}
fun pin(library: InputLibrary): InputLibrary {
val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" }?.elements.orEmpty()
return library.copy(layouts = library.layouts.map { layout ->
val imported = layout.id.startsWith("migrated.screenkeys.")
layout.copy(elements = layout.elements.map { e ->
val key = e.id.startsWith("imported:") && (imported || keys.any { it.id == e.id && it.kind == e.kind && it.name == e.name })
if (key) e.copy(alwaysShown = true) else e
})
})
}
internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List<Pair<InputSurfaceElement, InputAction>> =
convert(context, parse(raw), width, height, density)
@@ -29,9 +29,6 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private set
var isQuickHidden = false
private set
var isPinnedOnly = false
private set
val hasPinned get() = layout?.elements?.any { it.alwaysShown } == true
var onTouchMiss: ((MotionEvent) -> Unit)?
get() = surface.onTouchMiss
set(value) { surface.onTouchMiss = value }
@@ -64,13 +61,6 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
render()
}
fun setPinnedOnly(enabled: Boolean) {
if (isPinnedOnly == enabled) return
releaseAll()
isPinnedOnly = enabled
render()
}
private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean)
private fun placed(): List<InputSurfaceElement> {
@@ -82,16 +72,12 @@ class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayo
private fun render() {
val elements = placed()
surface.updateCustomLayout(when {
isPinnedOnly -> elements.filter { it.alwaysShown }
isQuickHidden -> elements.filter { it.id == "builtin:keyboard" || it.alwaysShown }
else -> elements
})
surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements)
val style = layout?.style ?: InputSurfaceStyle()
surface.updateStyle(style)
quickToggle.tint = style.tint
quickToggle.alpha = style.opacity / 100f
quickToggle.visibility = if (isButtonsOnly || isPinnedOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE
quickToggle.invalidate()
}
@@ -7,18 +7,7 @@ import kotlin.math.max
import kotlin.math.min
object OnScreenLayout {
data class Dock(val portrait: Boolean, val grips: Boolean, val left: InputControl = InputControl.LS, val right: InputControl = InputControl.RS) {
companion object {
fun deck(portrait: Boolean, gamepad: SavedInputLayout?): Dock {
fun side(right: Boolean) = gamepad?.elements?.firstOrNull {
(it.kind == InputElementKind.STICK || it.kind == InputElementKind.TRACKPAD) && (it.x + it.width / 2f >= .5f) == right
}?.kind
val left = if (side(false) == InputElementKind.TRACKPAD) InputControl.LEFT_PAD else InputControl.LS
val right = if (side(true) == InputElementKind.STICK) InputControl.RS else InputControl.RIGHT_PAD
return Dock(portrait, true, left, right)
}
}
}
data class Dock(val portrait: Boolean, val grips: Boolean)
private const val TOLERANCE = .01f
private const val KEYBOARD_ID = "builtin:keyboard"
@@ -28,13 +17,13 @@ object OnScreenLayout {
val stored = layout?.elements ?: return emptyList()
if (width <= 0 || height <= 0) return stored
val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored)
val (reference, generated) = reference(context, layout, width, height)
if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + fit(stored.filter { it.alwaysShown }, reference.first, reference.second, width, height, safe)
val gamepad = if (generated) stored.filter(::isGamepad) else emptyList()
val others = fit(stored.filterNot { generated && isGamepad(it) || dock != null && it.alwaysShown }, reference.first, reference.second, width, height, safe)
val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe)
val placed = when {
gamepad.isEmpty() -> emptyList()
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips, dock.left, dock.right)
dock != null -> generator.dock(gamepad, dock.portrait, dock.grips)
else -> generator.regenerate(gamepad)
}
val byId = (placed + others).associateBy { it.id }
@@ -52,7 +52,4 @@ object SecondScreenDisplays {
)
}
.toList()
fun editorCandidates(selected: Int, available: List<Int>, taken: (Int) -> Boolean): List<Int> =
(listOf(selected) + available).distinct().filterNot(taken)
}
@@ -82,12 +82,8 @@ public final class LinuxRuntimeInstaller {
private LinuxRuntimeInstaller() {}
public static String installedVersion(Context context) {
return LinuxRuntime.isInstalled(context) ? rootfsVersion(context) : null;
}
private static String rootfsVersion(Context context) {
File marker = new File(LinuxRuntime.rootDir(context), VERSION_FILE);
if (!marker.isFile()) return null;
if (!marker.isFile() || !LinuxRuntime.isInstalled(context)) return null;
String v = FileUtils.readString(marker);
return v == null ? null : v.trim();
}
@@ -148,10 +144,6 @@ public final class LinuxRuntimeInstaller {
}
};
boolean desktop = DesktopCatalog.INSTANCE.desktopInstalled(app);
if (packsOnly(app, release, progress)) {
job.ok = true;
return true;
}
job.ok = installOnce(app, release, progress);
if (job.ok && desktop) keepDesktop(app, progress);
return job.ok;
@@ -291,31 +283,6 @@ public final class LinuxRuntimeInstaller {
for (File file : files) file.delete();
}
private static kotlin.jvm.functions.Function1<AddonPack.State, kotlin.Unit> packProgress(ProgressListener listener, String stage) {
return state -> {
if (listener != null && state instanceof AddonPack.State.Downloading) {
listener.onProgress(Step.DOWNLOADING, stage, Math.round(((AddonPack.State.Downloading) state).getProgress() * 100f));
}
return kotlin.Unit.INSTANCE;
};
}
private static boolean packsOnly(Context context, Release release, ProgressListener listener) {
if (!LinuxRuntime.hasRootfs(context) || LinuxRuntime.isInstalled(context) || !release.version.equals(rootfsVersion(context))) return false;
File root = LinuxRuntime.rootDir(context);
try {
recoverInterruptedSwap(root, new File(root.getParentFile(), LinuxRuntime.DIR + ".new"), new File(root.getParentFile(), LinuxRuntime.DIR + ".old"));
} catch (IOException e) {
Log.w(TAG, "packs only: " + e);
return false;
}
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
return AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
&& AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))
&& LinuxRuntime.isInstalled(context);
}
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), archiveName(release));
boolean keepPartial = false;
@@ -327,10 +294,10 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context, () -> true, packProgress(listener, downloading))
if (!LinuxRuntime.patchedProot(context) && !AddonPack.PROOT.await(context)
&& !SessionPrefs.allowStockProot(context))
throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context, () -> true, packProgress(listener, downloading))) throw new IOException("Cannot download DirectAudio");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -68,9 +68,8 @@ object GameStorage {
externalStoragePath: Boolean = isExternalStoragePath(path),
): Boolean = when {
state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY -> true
path.isDirectory -> true
externalStoragePath -> false
state == Environment.MEDIA_UNKNOWN || state == null -> path.parentFile?.isDirectory == true
state == Environment.MEDIA_UNKNOWN || state == null -> path.isDirectory || path.parentFile?.isDirectory == true
else -> false
}
@@ -1555,6 +1555,10 @@ object SessionPrefs {
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
}
fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel =
com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null))
@@ -65,8 +65,6 @@ private fun releaseDateValue(raw: String): Long =
runCatching { java.time.LocalDate.parse(day).toEpochDay() }.getOrDefault(0L)
} ?: 0L
internal fun entryTime(first: Long?, fallback: Long?): Long = first?.takeIf { it > 0 } ?: fallback?.takeIf { it > 0 } ?: 0L
fun <T> sortGames(
items: List<T>,
sort: String,
@@ -204,8 +204,7 @@ object BattleNetBackend : StoreBackend {
fun installedGames(context: Context): List<InstalledStoreGame> = installed(context).map { game ->
val art = BattleNet.art(context, game.title)
val cover = art.cover?.let { Uri.fromFile(it).toString() }
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", installedAt = game.folder.lastModified(),
cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
InstalledStoreGame(StoreGameSidecar(store, game.title.uid, game.name, "", cover = cover, hero = art.header?.let { Uri.fromFile(it).toString() } ?: cover), game.folder)
}
private fun shelves(context: Context): StoreShelves {
@@ -16,7 +16,7 @@ import com.droiddeck.launcher.input.*
internal fun InputActionEditor(
control: InputControl, current: InputAction, title: String, onBack: () -> Unit, onSave: (InputAction) -> Unit,
element: InputSurfaceElement? = null, nativeTargets: Boolean = true, detail: String? = null,
properties: (@Composable ColumnScope.(InputAction) -> Unit)? = null,
properties: (@Composable ColumnScope.() -> Unit)? = null,
click: Boolean = false,
) {
val context = LocalContext.current
@@ -46,7 +46,7 @@ internal fun InputActionEditor(
SettingsPage(host, title, onBack, eyebrow = stringResource(R.string.input_properties), lede = detail,
action = { PrimaryButton(stringResource(R.string.gesture_save_changes), enabled = valid(draft),
modifier = Modifier.testTag("input-action-save")) { onSave(draft) } }) {
properties?.invoke(this, draft)
properties?.invoke(this)
Spacer(Modifier.height(12.dp))
SheetChips(listOf(stringResource(R.string.input_route_controller), stringResource(R.string.input_route_local), stringResource(R.string.input_disabled)), route) {
when (it) {
@@ -58,17 +58,12 @@ internal fun InputDisplayContent(host: ComponentActivity, display: Display, onDi
dismiss()
}
}
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowShown(display.displayId)
showPresentation()
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) showPresentation()
}
host.lifecycle.addObserver(observer)
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? com.droiddeck.launcher.SessionActivity)?.inputWindowClosed(display.displayId)
}
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
}
}
@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
internal fun InputElementProperties(element: InputSurfaceElement, action: InputAction? = null, onChange: (InputSurfaceElement) -> Unit) {
internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) {
val current by rememberUpdatedState(element)
val change by rememberUpdatedState(onChange)
OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) },
@@ -72,9 +72,7 @@ internal fun InputElementProperties(element: InputSurfaceElement, action: InputA
}
val host = rememberMenuHost()
if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() ||
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:") ||
(element.kind == InputElementKind.STICK || element.kind == InputElementKind.DPAD) && (action as? InputAction.Directions)
?.let { listOf(it.up, it.right, it.down, it.left).plus(if (element.kind == InputElementKind.STICK) it.diagonals.orEmpty() else emptyList()).any { code -> code != 0 } } == true)
element.kind == InputElementKind.STICK && element.id.startsWith("legacy:"))
ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) }
SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5,
format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) }
@@ -169,7 +169,7 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe
?: b.controls[editing.control] ?: InputAction.Native()
InputActionEditor(editing.control, current, if (editing.click) stringResource(R.string.input_click_name, editing.element!!.name)
else editing.element?.name ?: editing.key?.label ?: editing.control.label,
properties = { action -> if (!editing.click) elementProperties?.let { e -> InputElementProperties(e, action) { elementProperties = it } } },
properties = { if (!editing.click) elementProperties?.let { e -> InputElementProperties(e) { elementProperties = it } } },
onBack = { edit = null }, element = editing.element, nativeTargets = editing.key == null, click = editing.click,
detail = if (library.usersOfBindings(b.id).size > 1) stringResource(R.string.input_shared_detail, b.name, library.usersOfBindings(b.id).size)
else "${profile.name} · ${roleLabel(role)}", onSave = { action ->
@@ -69,7 +69,7 @@ private class GamepadScaler(private val context: android.content.Context, privat
}
private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List<InputSurfaceElement>? = null,
private val gamepad: GamepadScaler? = null, snap: Boolean = true) {
private val gamepad: GamepadScaler? = null) {
var elements by mutableStateOf(layout.elements)
var actions by mutableStateOf(preset.elements)
var clicks by mutableStateOf(preset.clicks)
@@ -77,7 +77,7 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB
var toolsExpanded by mutableStateOf(false)
var toolsX by mutableFloatStateOf(.02f)
var toolsY by mutableFloatStateOf(.02f)
var snap by mutableStateOf(snap)
var snap by mutableStateOf(true)
var adding by mutableStateOf(false)
var properties by mutableStateOf(false)
var clickProperties by mutableStateOf(false)
@@ -132,7 +132,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels)
GamepadScaler(display, w, h)
}
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad, ControllerPrefs.snap(context))
SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad)
}
val manager = remember { context.getSystemService(DisplayManager::class.java) }
var revision by remember { mutableIntStateOf(0) }
@@ -162,7 +162,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val listener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(id: Int) { revision++ }
override fun onDisplayRemoved(id: Int) { revision++ }
override fun onDisplayChanged(id: Int) = Unit
override fun onDisplayChanged(id: Int) { revision++ }
}
manager.registerDisplayListener(listener, Handler(Looper.getMainLooper()))
onDispose { manager.unregisterDisplayListener(listener) }
@@ -171,18 +171,13 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
val presentation = SurfaceEditorPresentation(host, target, transparent = host is SessionActivity && role == InputRole.PRIMARY) {
SurfaceCanvas(draft, transparent = host is SessionActivity && role == InputRole.PRIMARY)
}
(host as? SessionActivity)?.inputWindowShown(target.displayId)
runCatching { presentation.show() }.onFailure { failure = it.message }
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && !host.screenIsOff()) presentation.hide()
if (event == Lifecycle.Event.ON_START) runCatching { presentation.show() }
}
host.lifecycle.addObserver(observer)
onDispose {
host.lifecycle.removeObserver(observer)
presentation.dismiss()
(host as? SessionActivity)?.inputWindowClosed(target.displayId)
}
onDispose { host.lifecycle.removeObserver(observer); presentation.dismiss() }
}
// Compose 1.6 custom dialog sizing subtracts system bars even when they are hidden.
Dialog(onDismissRequest = ::leave, properties = DialogProperties(usePlatformDefaultWidth = true, decorFitsSystemWindows = false)) {
@@ -203,8 +198,8 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () ->
if (draft.clickProperties) draft.clickAction(element) else draft.action(element),
if (draft.clickProperties) stringResource(R.string.input_click_name, element.name) else element.name,
onBack = { draft.properties = false }, element = element, click = draft.clickProperties,
properties = { action ->
if (!draft.clickProperties) InputElementProperties(properties, action) { properties = it }
properties = {
if (!draft.clickProperties) InputElementProperties(properties) { properties = it }
if (layoutUsers > 1) Text(stringResource(R.string.input_editor_shared, layoutUsers),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp))
@@ -314,14 +309,13 @@ private fun FloatingSurfaceTools(draft: SurfaceDraft, onSave: () -> Unit, onLeav
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () -> Unit) {
val context = LocalContext.current
FlowRow(Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave)
SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true }
SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() }
if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(draft.snap, { draft.snap = it; ControllerPrefs.setSnap(context, it) })
Checkbox(draft.snap, { draft.snap = it })
Text(stringResource(R.string.ctrl_editor_snap))
}
PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave)
@@ -32,12 +32,10 @@ import com.droiddeck.launcher.input.InputSurfaceStyle
import com.droiddeck.launcher.input.pointerSubset
import com.droiddeck.launcher.input.InputSurfaceSource
import com.droiddeck.launcher.input.InputElementKind
import com.droiddeck.launcher.input.InputAction
import com.droiddeck.launcher.input.InputButtonIcons
import com.droiddeck.launcher.input.InputButtonShape
import com.droiddeck.launcher.input.InputButtonImages
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.pcKeyLabel
import com.droiddeck.launcher.session.SessionPrefs
import kotlin.math.atan2
import kotlin.math.hypot
@@ -197,7 +195,7 @@ class InputSurfaceView(
canvas.addView(when (element.kind) {
InputElementKind.STICK -> StickView(context, element, source, element.floating, element.hideWhenIdle && pad != null)
InputElementKind.TRACKPAD -> TrackpadView(context, element.control == InputControl.RIGHT_PAD, source = source)
InputElementKind.DPAD -> DirectionPad(context, element, source)
InputElementKind.DPAD -> DirectionPad(context, source)
else -> HoldButton(context, element, source)
}.apply { tag = element; contentDescription = element.name; styleElement(this); source?.onStateChanged = { postInvalidate() } })
}
@@ -543,7 +541,6 @@ class InputSurfaceView(
if (touching || clicking) shade(color, 160, 1f) else shade(color, 120, 0.35f), if (touching) light(color, 230, 0.7f) else light(color, 150, 0.25f))
if (!element.showLabel) return
labelPaint.color = textColor(touching)
if (drawDirectionLabels(canvas, source, false, cx, cy, radius * 0.66f, radius * 0.34f, labelPaint)) return
labelPaint.textSize = knobRadius * 0.8f
val label = if (element.name == element.control.label) glyph(element.control) else element.name
canvas.drawText(label, cx + knobX, cy + knobY + labelPaint.textSize * 0.35f, labelPaint)
@@ -555,9 +552,6 @@ class InputSurfaceView(
canvas.drawCircle(cx, cy, reach(), ring)
knob.color = if (touching) ACCENT else KNOB
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
if (!element.showLabel) return
labelPaint.color = if (touching) ACCENT else TEXT
drawDirectionLabels(canvas, source, false, cx, cy, reach() * 1.32f, reach() * 0.68f, labelPaint)
}
@SuppressLint("ClickableViewAccessibility")
@@ -619,11 +613,10 @@ class InputSurfaceView(
}
}
private inner class DirectionPad(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource? = null) : View(context) {
private inner class DirectionPad(context: Context, private val source: InputSurfaceSource? = null) : View(context) {
private val density = resources.displayMetrics.density
private var held = 0
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD); color = TEXT }
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
@@ -643,7 +636,6 @@ class InputSurfaceView(
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
arm.color = KNOB
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
if (element.showLabel) drawDirectionLabels(canvas, source, true, cx, cy, (half + thick) / 2f, thick * 1.7f, label)
}
@SuppressLint("ClickableViewAccessibility")
@@ -725,29 +717,6 @@ class InputSurfaceView(
else -> control.id.uppercase()
}
private val CARDINAL = listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)
private val DIAGONAL = listOf(.7071f to -.7071f, .7071f to .7071f, -.7071f to .7071f, -.7071f to -.7071f)
internal fun directionLabels(context: Context, action: InputAction.Directions?, cardinalOnly: Boolean): List<Pair<Pair<Float, Float>, String>> {
if (action == null) return emptyList()
val codes = listOf(action.up, action.right, action.down, action.left).zip(CARDINAL) +
if (cardinalOnly) emptyList() else action.diagonals.orEmpty().zip(DIAGONAL)
return codes.filter { it.first != 0 }.map { (code, unit) -> unit to pcKeyLabel(context, code) }
}
private fun View.drawDirectionLabels(canvas: Canvas, source: InputSurfaceSource?, cardinalOnly: Boolean, cx: Float, cy: Float,
distance: Float, box: Float, paint: Paint): Boolean {
val labels = directionLabels(context, source?.directions, cardinalOnly)
paint.style = Paint.Style.FILL
for ((unit, text) in labels) {
paint.textSize = box * .6f
val measured = paint.measureText(text)
if (measured > box) paint.textSize *= box / measured
canvas.drawText(text, cx + unit.first * distance, cy + unit.second * distance - (paint.ascent() + paint.descent()) / 2f, paint)
}
return labels.isNotEmpty()
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int, paint: Paint) {
val (fx, fy) = when (direction) { 0 -> 0f to -1f; 1 -> 1f to 0f; 2 -> 0f to 1f; else -> -1f to 0f }
val path = Path()
@@ -109,7 +109,6 @@ import com.droiddeck.launcher.stores.itch.ItchUpdates
import com.droiddeck.launcher.stores.gog.GogMaintenance
import com.droiddeck.launcher.stores.retainOrder
import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
import com.droiddeck.launcher.stores.entryTime
import com.droiddeck.launcher.stores.sortGames
import kotlinx.coroutines.launch
import androidx.compose.runtime.setValue
@@ -297,8 +296,8 @@ internal fun LibraryPage(s: FrontEndState, a: FrontEndActions, selected: String,
val sizesPending = sizeSort && !sizesWaited && candidates.any { sizeOf(it) == null }
val sorted = sortGames(
candidates, sort, title = { it.title }, releaseDate = { it.item?.releaseDate.orEmpty() },
lastPlayed = { e -> entryTime(e.game?.lastPlayed, e.storeKey?.let { lastPlayed[it] }) },
installedAt = { e -> entryTime(e.storeKey?.let { installedAt[it] }, e.game?.installedAt) },
lastPlayed = { e -> e.game?.lastPlayed ?: e.storeKey?.let { lastPlayed[it] } ?: 0L },
installedAt = { e -> e.storeKey?.let { installedAt[it] } ?: 0L },
size = { e -> sizeOf(e) ?: 0L },
)
var stable by remember { mutableStateOf(emptyList<LibraryEntry>()) }
@@ -323,7 +323,7 @@ class SecondScreenPresentation(
context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard),
icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() }))
SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel))
SecondScreenMode.DASHBOARD, SecondScreenMode.CUSTOM_CONTROLS -> model(swap)
SecondScreenMode.DASHBOARD -> model(swap)
SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard,
tag = "bottom-keyboard") { terminalPanel?.showKeyboard() }))
else -> null
@@ -219,6 +219,7 @@ class DrawerActions(
val frameGen: FrameGen.Mode,
val lossless: Lossless.State,
val oscMode: String,
val onScreenButtonsVisible: Boolean,
val suspendPolicy: String,
val steamDownloadsInBackground: Boolean = false,
val micAvailable: Boolean = false,
@@ -324,7 +325,6 @@ class DrawerActions(
val onStop: () -> Unit,
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
val onDismissScreen: (() -> Unit)? = null,
val components: ComponentsManager.Snapshot? = null,
val systemPowerVr: Boolean = false,
val onComponentsRefresh: () -> Unit = {},
@@ -488,7 +488,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
) {
SteamActionPill(
stringResource(R.string.common_close), Icons.Outlined.Close, focus.track(page, "close"),
) { host.open = null; (a.onDismissScreen ?: a.onClose)() }
) { host.open = null; a.onClose() }
if (roomyHeader) Spacer(Modifier.weight(1f))
SteamActionPill(
stringResource(R.string.stores_dl_pause), Icons.Outlined.Pause, focus.track(page, "pause"),
+1
View File
@@ -1259,6 +1259,7 @@
<string name="deck_tab_gamepad">يد التحكّم</string>
<string name="deck_surface_stick">عصا التحكّم</string>
<string name="deck_surface_trackpad">لوحة اللمس</string>
<string name="deck_surface_switch">التبديل بين عصا التحكّم ولوحة اللمس</string>
<string name="deck_close">إغلاق عناصر التحكّم على الشاشة الثانية</string>
<string name="deck_open_steam_menu">فتح قائمة Steam</string>
<string name="deck_right_trackpad">لوحة اللمس اليمنى</string>
@@ -1308,6 +1308,7 @@
<string name="deck_tab_gamepad">Control</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тракпад</string>
<string name="deck_surface_switch">Превключване между стик и тракпад</string>
<string name="deck_close">Затваряне на контролите за втория екран</string>
<string name="deck_open_steam_menu">Отваряне на менюто на Steam</string>
<string name="deck_right_trackpad">Десен тракпад</string>
+1
View File
@@ -1245,6 +1245,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Páčka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Přepnout mezi páčkou a trackpadem</string>
<string name="deck_close">Zavřít ovládání druhé obrazovky</string>
<string name="deck_open_steam_menu">Otevřít nabídku Steamu</string>
<string name="deck_right_trackpad">Pravý trackpad</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Skift mellem stick og trackpad</string>
<string name="deck_close">Luk kontrollerne til den anden skærm</string>
<string name="deck_open_steam_menu">Åbn Steam-menuen</string>
<string name="deck_right_trackpad">Højre trackpad</string>
+1
View File
@@ -1223,6 +1223,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Zwischen Stick und Trackpad wechseln</string>
<string name="deck_close">Steuerung auf dem Zweitbildschirm schließen</string>
<string name="deck_open_steam_menu">Steam-Menü öffnen</string>
<string name="deck_right_trackpad">Rechtes Trackpad</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Χειριστήριο</string>
<string name="deck_surface_stick">Μοχλός</string>
<string name="deck_surface_trackpad">Πινακίδα αφής</string>
<string name="deck_surface_switch">Εναλλαγή μεταξύ μοχλού και πινακίδας αφής</string>
<string name="deck_close">Κλείσιμο χειριστηρίων δεύτερης οθόνης</string>
<string name="deck_open_steam_menu">Άνοιγμα μενού Steam</string>
<string name="deck_right_trackpad">Δεξιά πινακίδα αφής</string>
+1
View File
@@ -1238,6 +1238,7 @@
<string name="deck_tab_gamepad">Mando</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Cambiar entre stick y trackpad</string>
<string name="deck_close">Cerrar controles de la segunda pantalla</string>
<string name="deck_open_steam_menu">Abrir menú de Steam</string>
<string name="deck_right_trackpad">Trackpad derecho</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Peliohjain</string>
<string name="deck_surface_stick">Tatti</string>
<string name="deck_surface_trackpad">Kosketuslevy</string>
<string name="deck_surface_switch">Vaihda tatin ja kosketuslevyn välillä</string>
<string name="deck_close">Sulje toisen näytön ohjaimet</string>
<string name="deck_open_steam_menu">Avaa Steam-valikko</string>
<string name="deck_right_trackpad">Oikea kosketuslevy</string>
+1
View File
@@ -1238,6 +1238,7 @@
<string name="deck_tab_gamepad">Manette</string>
<string name="deck_surface_stick">Joystick</string>
<string name="deck_surface_trackpad">Pavé tactile</string>
<string name="deck_surface_switch">Basculer entre joystick et pavé tactile</string>
<string name="deck_close">Fermer les commandes du deuxième écran</string>
<string name="deck_open_steam_menu">Ouvrir le menu Steam</string>
<string name="deck_right_trackpad">Pavé tactile droit</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">गेमपैड</string>
<string name="deck_surface_stick">स्टिक</string>
<string name="deck_surface_trackpad">ट्रैकपैड</string>
<string name="deck_surface_switch">स्टिक और ट्रैकपैड के बीच बदलें</string>
<string name="deck_close">दूसरी स्क्रीन के कंट्रोल बंद करें</string>
<string name="deck_open_steam_menu">Steam मेनू खोलें</string>
<string name="deck_right_trackpad">दायां ट्रैकपैड</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Kontroller</string>
<string name="deck_surface_stick">Kar</string>
<string name="deck_surface_trackpad">Érintőpad</string>
<string name="deck_surface_switch">Váltás a kar és az érintőpad között</string>
<string name="deck_close">Második képernyős vezérlők bezárása</string>
<string name="deck_open_steam_menu">Steam-menü megnyitása</string>
<string name="deck_right_trackpad">Jobb érintőpad</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stik</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Beralih antara stik dan trackpad</string>
<string name="deck_close">Tutup kontrol layar kedua</string>
<string name="deck_open_steam_menu">Buka menu Steam</string>
<string name="deck_right_trackpad">Trackpad kanan</string>
+1
View File
@@ -1230,6 +1230,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Levetta</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alterna tra levetta e trackpad</string>
<string name="deck_close">Chiudi i controlli sul secondo schermo</string>
<string name="deck_open_steam_menu">Apri il menu Steam</string>
<string name="deck_right_trackpad">Trackpad destro</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">ゲームパッド</string>
<string name="deck_surface_stick">スティック</string>
<string name="deck_surface_trackpad">トラックパッド</string>
<string name="deck_surface_switch">スティックとトラックパッドを切り替え</string>
<string name="deck_close">セカンドスクリーンコントロールを閉じる</string>
<string name="deck_open_steam_menu">Steamメニューを開く</string>
<string name="deck_right_trackpad">右トラックパッド</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">게임패드</string>
<string name="deck_surface_stick">스틱</string>
<string name="deck_surface_trackpad">터치패드</string>
<string name="deck_surface_switch">스틱과 터치패드 간 전환</string>
<string name="deck_close">보조 화면 컨트롤 닫기</string>
<string name="deck_open_steam_menu">Steam 메뉴 열기</string>
<string name="deck_right_trackpad">오른쪽 터치패드</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stik</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Tukar antara stik dan trackpad</string>
<string name="deck_close">Tutup kawalan skrin kedua</string>
<string name="deck_open_steam_menu">Buka menu Steam</string>
<string name="deck_right_trackpad">Trackpad kanan</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Kontroller</string>
<string name="deck_surface_stick">Stikke</string>
<string name="deck_surface_trackpad">Styreflate</string>
<string name="deck_surface_switch">Bytt mellom stikke og styreflate</string>
<string name="deck_close">Lukk kontroller for andre skjerm</string>
<string name="deck_open_steam_menu">Åpne Steam-menyen</string>
<string name="deck_right_trackpad">Høyre styreflate</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Wisselen tussen stick en trackpad</string>
<string name="deck_close">Bediening op tweede scherm sluiten</string>
<string name="deck_open_steam_menu">Steam-menu openen</string>
<string name="deck_right_trackpad">Rechtertrackpad</string>
+1
View File
@@ -1245,6 +1245,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Gałka</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Przełącz między gałką a trackpadem</string>
<string name="deck_close">Zamknij sterowanie na drugim ekranie</string>
<string name="deck_open_steam_menu">Otwórz menu Steam</string>
<string name="deck_right_trackpad">Prawy trackpad</string>
@@ -1230,6 +1230,7 @@
<string name="deck_tab_gamepad">Comando</string>
<string name="deck_surface_stick">Joystick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alternar entre joystick e trackpad</string>
<string name="deck_close">Fechar controlos do segundo ecrã</string>
<string name="deck_open_steam_menu">Abrir menu Steam</string>
<string name="deck_right_trackpad">Trackpad direito</string>
+1
View File
@@ -1230,6 +1230,7 @@
<string name="deck_tab_gamepad">Controle</string>
<string name="deck_surface_stick">Analógico</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Alternar entre analógico e trackpad</string>
<string name="deck_close">Fechar controles da segunda tela</string>
<string name="deck_open_steam_menu">Abrir o menu Steam</string>
<string name="deck_right_trackpad">Trackpad direito</string>
+1
View File
@@ -1238,6 +1238,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Manșă</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Comută între manșă și trackpad</string>
<string name="deck_close">Închide comenzile celui de-al doilea ecran</string>
<string name="deck_open_steam_menu">Deschide meniul Steam</string>
<string name="deck_right_trackpad">Trackpad-ul drept</string>
+1
View File
@@ -1282,6 +1282,7 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стик</string>
<string name="deck_surface_trackpad">Тачпад</string>
<string name="deck_surface_switch">Переключение между стиком и тачпадом</string>
<string name="deck_close">Закрыть управление вторым экраном</string>
<string name="deck_open_steam_menu">Открыть меню Steam</string>
<string name="deck_right_trackpad">Правый тачпад</string>
+1
View File
@@ -1231,6 +1231,7 @@
<string name="deck_tab_gamepad">Handkontroll</string>
<string name="deck_surface_stick">Styrspak</string>
<string name="deck_surface_trackpad">Styrplatta</string>
<string name="deck_surface_switch">Växla mellan styrspak och styrplatta</string>
<string name="deck_close">Stäng kontrollerna på andra skärmen</string>
<string name="deck_open_steam_menu">Öppna Steam-menyn</string>
<string name="deck_right_trackpad">Höger styrplatta</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">เกมแพด</string>
<string name="deck_surface_stick">สติ๊ก</string>
<string name="deck_surface_trackpad">แทร็กแพด</string>
<string name="deck_surface_switch">สลับระหว่างสติ๊กและแทร็กแพด</string>
<string name="deck_close">ปิดการควบคุมหน้าจอที่สอง</string>
<string name="deck_open_steam_menu">เปิดเมนู Steam</string>
<string name="deck_right_trackpad">แทร็กแพดขวา</string>
+1
View File
@@ -1223,6 +1223,7 @@
<string name="deck_tab_gamepad">Oyun kolu</string>
<string name="deck_surface_stick">Çubuk</string>
<string name="deck_surface_trackpad">Dokunmatik yüzey</string>
<string name="deck_surface_switch">Çubuk ile dokunmatik yüzey arasında geçiş yap</string>
<string name="deck_close">İkinci ekran kontrollerini kapat</string>
<string name="deck_open_steam_menu">Steam menüsünü aç</string>
<string name="deck_right_trackpad">Sağ dokunmatik yüzey</string>
+1
View File
@@ -1245,6 +1245,7 @@
<string name="deck_tab_gamepad">Геймпад</string>
<string name="deck_surface_stick">Стік</string>
<string name="deck_surface_trackpad">Трекпад</string>
<string name="deck_surface_switch">Перемкнути між стіком і трекпадом</string>
<string name="deck_close">Закрити керування на другому екрані</string>
<string name="deck_open_steam_menu">Відкрити меню Steam</string>
<string name="deck_right_trackpad">Правий трекпад</string>
+1
View File
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">Tay cầm</string>
<string name="deck_surface_stick">Cần</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Chuyển giữa cần và trackpad</string>
<string name="deck_close">Đóng điều khiển màn hình phụ</string>
<string name="deck_open_steam_menu">Mở menu Steam</string>
<string name="deck_right_trackpad">Trackpad phải</string>
@@ -1308,6 +1308,7 @@
<string name="deck_tab_gamepad">手柄</string>
<string name="deck_surface_stick">摇杆</string>
<string name="deck_surface_trackpad">触控板</string>
<string name="deck_surface_switch">在摇杆与触控板之间切换</string>
<string name="deck_close">关闭副屏控制</string>
<string name="deck_open_steam_menu">打开 Steam 菜单</string>
<string name="deck_right_trackpad">右触控板</string>
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">控制器</string>
<string name="deck_surface_stick">搖桿</string>
<string name="deck_surface_trackpad">觸控板</string>
<string name="deck_surface_switch">在搖桿和觸控板之間切換</string>
<string name="deck_close">關閉第二螢幕控制</string>
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
<string name="deck_right_trackpad">右側觸控板</string>
@@ -1224,6 +1224,7 @@
<string name="deck_tab_gamepad">手把</string>
<string name="deck_surface_stick">搖桿</string>
<string name="deck_surface_trackpad">觸控板</string>
<string name="deck_surface_switch">在搖桿與觸控板之間切換</string>
<string name="deck_close">關閉第二螢幕控制</string>
<string name="deck_open_steam_menu">開啟 Steam 選單</string>
<string name="deck_right_trackpad">右側觸控板</string>
+1
View File
@@ -1382,6 +1382,7 @@
<string name="deck_tab_gamepad">Gamepad</string>
<string name="deck_surface_stick">Stick</string>
<string name="deck_surface_trackpad">Trackpad</string>
<string name="deck_surface_switch">Switch between stick and trackpad</string>
<string name="deck_close">Close second-screen controls</string>
<string name="deck_open_steam_menu">Open Steam menu</string>
<string name="deck_right_trackpad">Right trackpad</string>
@@ -7,7 +7,6 @@ import android.os.Looper
import com.droiddeck.launcher.session.QuestExperimental
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.gpu.TpUbwcHint
import com.droiddeck.launcher.gpu.TurnipDriver
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -35,31 +34,6 @@ class DriverMenusAutoTest {
ShadowBuild.setDevice("eureka")
}
@Test fun pickingTheAutoDisplayHandsBothHalvesToAuto() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
SessionPrefs.setGpuDriverMode(activity, SessionPrefs.GPU_DRIVERS_MIX)
SessionPrefs.setAndroidDriver(activity, "chosen-display")
SessionPrefs.setLinuxDriver(activity, "chosen-linux")
val menus = DriverMenus(activity, Handler(Looper.getMainLooper()))
menus.selectDriver(TurnipDriver.AUTO, linux = false)
assertEquals(SessionPrefs.GPU_DRIVERS_AUTO, SessionPrefs.gpuDriverMode(activity))
assertEquals(QuestExperimental.DRIVER, SessionPrefs.androidDriver(activity))
assertEquals("", SessionPrefs.linuxDriver(activity))
}
@Test fun pickingTheAutoDisplayWithNoPairForTheGpuStillPutsTheDisplayOnAuto() {
ShadowBuild.setModel("Generic phone")
ShadowBuild.setDevice("generic")
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
SessionPrefs.setGpuDriverMode(activity, SessionPrefs.GPU_DRIVERS_MIX)
SessionPrefs.setAndroidDriver(activity, "chosen-display")
SessionPrefs.setLinuxDriver(activity, "chosen-linux")
DriverMenus(activity, Handler(Looper.getMainLooper())).selectDriver(TurnipDriver.AUTO, linux = false)
assertEquals(SessionPrefs.GPU_DRIVERS_AUTO, SessionPrefs.gpuDriverMode(activity))
assertEquals(TurnipDriver.AUTO, SessionPrefs.androidDriver(activity))
assertEquals("chosen-linux", SessionPrefs.linuxDriver(activity))
}
@Test fun selectingAutoUsesTheQuestPairWithoutStartingADownload() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
SessionPrefs.setGpuDriverMode(activity, SessionPrefs.GPU_DRIVERS_MANUAL)
@@ -45,30 +45,6 @@ class LibraryShortcutsLastPlayedTest {
assertEquals(mapOf(3388195926L to 1792931476L, 1234L to 1700000000L), Library.shortcutsLastPlayed(root))
}
@Test fun aLaunchedAddedGameRemembersItsPlayTime() {
val context = org.robolectric.RuntimeEnvironment.getApplication()
val added = Library.SteamGame(-1, "World of Warcraft", null, Library.ADDED, gameId = 0xC0FFEE0002000000uL.toLong())
val steam = Library.SteamGame(570, "Dota 2", null, "/steam")
assertEquals(0L, Library.recordedPlay(context, added.gameId))
val before = System.currentTimeMillis() / 1000
Library.recordPlayed(context, added)
Library.recordPlayed(context, steam)
assert(Library.recordedPlay(context, added.gameId) >= before)
assertEquals(0L, Library.recordedPlay(context, steam.gameId))
}
@Test fun anAddedGameBuiltAgainKeepsItsRecordedPlayTime() {
val context = org.robolectric.RuntimeEnvironment.getApplication()
val dir = tmp.newFolder("Celeste")
val exe = File(dir, "Celeste.exe").apply { writeText("x") }
val g = AddedGames.Game(dir, "Celeste", exe, "/g/Celeste.exe", "/g", 0x82000003L, 0x82000003L, emptyList())
val shown = Library.addedGame(context, g, emptyList())
assertEquals(0L, shown.lastPlayed)
Library.recordPlayed(context, shown)
assertEquals(Library.recordedPlay(context, g.gameId), Library.addedGame(context, g, emptyList()).lastPlayed)
assertEquals(1900000000L, Library.addedGame(context, g, emptyList(), lastPlayed = 1900000000L).lastPlayed)
}
@Test fun missingOrBrokenShortcutsGiveNothing() {
val root = steamRoot()
assertEquals(emptyMap<Long, Long>(), Library.shortcutsLastPlayed(root))
@@ -101,33 +101,6 @@ class DriverBundleTest {
assertTrue(DriverBundle.all(context).isEmpty())
}
@Test fun aDisplayPickedAwayFromABundleTakesItsRuntimeHalfWithIt() {
val b = DriverBundle.install(context, bundle())
DriverBundle.select(context, b)
DriverBundle.selectDisplay(context, TurnipDriver.AUTO)
assertEquals(TurnipDriver.AUTO, SessionPrefs.androidDriver(context))
assertEquals("", SessionPrefs.linuxDriver(context))
assertNull(DriverBundle.active(context))
DriverBundle.select(context, b)
DriverBundle.selectDisplay(context, "turnip25.1.0")
assertEquals("turnip25.1.0", SessionPrefs.androidDriver(context))
assertEquals("", SessionPrefs.linuxDriver(context))
}
@Test fun aDisplayPickOutsideABundleKeepsTheChosenRuntime() {
SessionPrefs.setLinuxDriver(context, "my-linux-driver")
SessionPrefs.setAndroidDriver(context, "turnip25.1.0")
DriverBundle.selectDisplay(context, TurnipDriver.AUTO)
assertEquals(TurnipDriver.AUTO, SessionPrefs.androidDriver(context))
assertEquals("my-linux-driver", SessionPrefs.linuxDriver(context))
val b = DriverBundle.install(context, bundle())
DriverBundle.select(context, b)
DriverBundle.selectDisplay(context, b.androidId)
assertEquals(b.linuxId, SessionPrefs.linuxDriver(context))
}
@Test fun aRefusedHalfLeavesNothingInstalled() {
try {
DriverBundle.install(context, bundle(linuxLibc = "libc.so"))
@@ -48,17 +48,6 @@ class BottomScreenGuestsTest {
assertEquals(null, BottomScreenGuests.holder(null, null, homes))
}
@Test fun anAppThatArrivedBeforeDroidDeckCoveredTheScreenAgainNoLongerHoldsIt() {
assertFalse(BottomScreenGuests.current(arrivedAt = 1_000, ownShownAt = 2_000))
assertTrue(BottomScreenGuests.current(arrivedAt = 3_000, ownShownAt = 2_000))
assertTrue(BottomScreenGuests.current(arrivedAt = 2_000, ownShownAt = 2_000))
assertTrue(BottomScreenGuests.current(arrivedAt = 1_000, ownShownAt = null))
fun holder(arrivedAt: Long, ownShownAt: Long) = BottomScreenGuests.holder(null,
"com.android.chrome".takeIf { BottomScreenGuests.current(arrivedAt, ownShownAt) }, homes)
assertEquals(null, holder(arrivedAt = 1_000, ownShownAt = 2_000))
assertEquals("com.android.chrome", holder(arrivedAt = 3_000, ownShownAt = 2_000))
}
@Test fun keyboardsAndOverlaysNeverTakeTheScreen() {
assertTrue(BottomScreenGuests.ignorable(AccessibilityWindowInfo.TYPE_INPUT_METHOD))
assertTrue(BottomScreenGuests.ignorable(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY))
@@ -141,26 +141,6 @@ class DirectTouchHoldTest {
assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons)
}
@Test fun cancelTouchEndsALiveTouchAndKeepsAHeldButton() {
touch(MotionEvent.ACTION_DOWN)
touch(MotionEvent.ACTION_MOVE, 60f)
hold.cancelTouch()
assertEquals(listOf("down 0 at 40.0,30.0", "move 0 at 60.0,30.0", "cancel"), touches)
assertTrue(buttons.isEmpty())
touches.clear()
hold.configure(TouchGestureConfig(longPressDelay = 500,
directHoldBehavior = TouchGestureConfig.MOUSE_BEHAVIOR_HOLD))
touch(MotionEvent.ACTION_DOWN)
advance(500)
hold.cancelTouch()
advance(1000)
assertEquals(listOf("true at 40.0,30.0"), buttons)
assertTrue(touches.isEmpty())
touch(MotionEvent.ACTION_UP)
assertEquals(listOf("true at 40.0,30.0", "false at 40.0,30.0"), buttons)
}
@Test fun cancelForwardsACancelWhenTheTouchIsLive() {
touch(MotionEvent.ACTION_DOWN)
touch(MotionEvent.ACTION_MOVE, 60f)
@@ -205,28 +205,6 @@ class OnScreenControlsTest {
assertFalse(view.isQuickHidden)
}
@Test fun hidingTheGamepadKeepsAlwaysShownKeysDrawnAndTouchable() {
val key = InputSurfaceElement(InputControl.A, .45f, .1f, .1f, .1f, "imported:0", name = "Find", alwaysShown = true)
view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button, key), aspect = ASPECT))
measure(view)
assertTrue(view.hasPinned)
view.setPinnedOnly(true)
measure(view)
assertTrue(view.takesTouchAt(500f, 90f))
assertFalse(view.takesTouchAt(800f, 250f))
assertFalse(view.takesTouchAt(200f, 300f))
view.setPinnedOnly(false)
measure(view)
assertTrue(view.takesTouchAt(800f, 250f))
assertTrue(view.takesTouchAt(500f, 90f))
view.setQuickHidden(true)
measure(view)
assertTrue(view.takesTouchAt(500f, 90f))
assertFalse(view.takesTouchAt(800f, 250f))
view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button), aspect = ASPECT))
assertFalse(view.hasPinned)
}
private companion object { const val ASPECT = 1000f / 600f }
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>, flags: Int = 0): Boolean {
@@ -19,7 +19,7 @@ class OnScreenLayoutTest {
private val density get() = context.resources.displayMetrics.density
@Before fun reset() {
for (name in listOf("input-profiles", "input-surfaces", "controller", "session")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()
for (name in listOf("input-profiles", "input-surfaces", "controller")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit()
}
private fun gamepad() = InputLibraries.read(context).layouts.first { it.id == InputLibraries.OVERLAY }
@@ -188,105 +188,4 @@ class OnScreenLayoutTest {
val selected = old.selectProfile(false, "migrated.screenkeys.0").selectProfile(true, "migrated.screenkeys.0")
assertEquals(selected, LegacyScreenKeyMigration.merge(selected, shown))
}
@Test fun alwaysShownKeysStayInButtonsOnlyModeAndLeaveTheUnfoldedStrip() {
val key = InputSurfaceElement(InputControl.A, .45f, .1f, .1f, .1f, "imported:0", name = "Find", alwaysShown = true)
val plain = InputSurfaceElement(InputControl.A, .45f, .4f, .1f, .1f, "imported:1", name = "Jump")
val layout = gamepad().let { it.copy(elements = it.elements + key + plain) }
val buttons = OnScreenLayout.elements(context, layout, 1000, 500, Rect(), buttonsOnly = true)
assertTrue(buttons.any { it.id == key.id })
assertFalse(buttons.any { it.id == plain.id })
assertEquals(OnScreenLayout.elements(context, gamepad(), 1000, 500, Rect(), buttonsOnly = true), buttons.filterNot { it.id == key.id })
val strip = OnScreenLayout.elements(context, layout, 2208, 800, Rect(), dock = OnScreenLayout.Dock(false, grips = false))
assertFalse(strip.any { it.id == key.id })
assertTrue(strip.any { it.id == plain.id })
assertTrue(OnScreenLayout.elements(context, layout, 1000, 500, Rect()).map { it.id }.containsAll(listOf(key.id, plain.id)))
}
@Test fun migratedScreenKeysAreAlwaysShownButOtherElementsAreNot() {
prefs.edit().clear().commit()
ControllerPrefs.prefs(context).edit().putString("screenKeys", legacyKeys).commit()
val migrated = InputLibraries.read(context)
assertTrue(importedOn(migrated).all { it.alwaysShown })
assertTrue(migrated.layouts.first { it.id == "migrated.screenkeys.0.layout" }.elements.all { it.alwaysShown })
assertTrue(migrated.layouts.flatMap { it.elements }.filterNot { it.id.startsWith("imported:") }.none { it.alwaysShown })
assertFalse(LegacyScreenKeyMigration.convert(context, legacyKeys, 1000, 500, 2f).any { it.first.alwaysShown })
}
@Test fun keysMergedByAnEarlierVersionBecomeAlwaysShownOnUpgrade() {
val fresh = InputLibraries.read(context)
val backup = JSONObject().put("screenKeys", legacyKeys).toString()
val unpinned = LegacyScreenKeyMigration.merge(LegacyScreenKeyMigration.migrate(context, fresh, backup), backup).let { library ->
library.copy(layouts = library.layouts.map { l -> l.copy(elements = l.elements.map { it.copy(alwaysShown = false) }) })
}
val other = SavedInputLayout("user.keys", "Mine", listOf(InputSurfaceElement(InputControl.A, .1f, .1f, .1f, .1f, "imported:0", name = "Other")))
val stored = unpinned.withLayout(other)
prefs.edit().putString("library", JSONObject(InputLibraries.encode(stored)).put("version", 12).toString())
.putString(LegacyScreenKeyMigration.BACKUP, backup).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()
val upgraded = InputLibraries.read(context)
assertEquals(1, importedOn(upgraded).size)
assertTrue(importedOn(upgraded).single().alwaysShown)
assertTrue(upgraded.layouts.first { it.id == "migrated.screenkeys.0.layout" }.elements.all { it.alwaysShown })
assertFalse(upgraded.layouts.first { it.id == "user.keys" }.elements.single().alwaysShown)
assertEquals(stored.layouts.map { l -> l.elements.map { it.copy(alwaysShown = false) } }, upgraded.layouts.filter { l -> stored.layouts.any { it.id == l.id } }
.map { l -> l.elements.map { it.copy(alwaysShown = false) } })
}
@Test fun aDeckStripPutsTheDeckGamepadSurfacesInTheStickSlotsAndOtherPadsKeepSticks() {
val stock = InputStockLayouts.layouts().first { it.id == DeckPanel.GAMEPAD.layout }
val deck = OnScreenLayout.Dock.deck(false, stock)
assertEquals(OnScreenLayout.Dock(false, true, InputControl.LS, InputControl.RIGHT_PAD), deck)
assertEquals(deck, OnScreenLayout.Dock.deck(false, null))
val (w, h) = 2208 to 800
val boxes = GamepadGeometry.layout(w.toFloat(), h.toFloat(), density, false)
val placed = OnScreenLayout.elements(context, gamepad(), w, h, Rect(), dock = deck)
val pad = placed.single { it.control == InputControl.RIGHT_PAD }
assertEquals(InputElementKind.TRACKPAD, pad.kind)
assertEquals(boxes.getValue(GamepadGeometry.Slot.RIGHT_MAIN).left, pad.x * w, 1f)
assertNull(placed.firstOrNull { it.control == InputControl.RS })
assertTrue(placed.any { it.control == InputControl.LS })
val plain = OnScreenLayout.elements(context, gamepad(), w, h, Rect(), dock = OnScreenLayout.Dock(false, false))
assertTrue(plain.any { it.control == InputControl.RS })
assertNull(plain.firstOrNull { it.kind == InputElementKind.TRACKPAD })
val swapped = stock.copy(elements = stock.elements.map {
when (it.control) {
InputControl.LS -> it.copy(control = InputControl.LEFT_PAD, id = "legacy:left-pad", kind = InputElementKind.TRACKPAD)
InputControl.RIGHT_PAD -> it.copy(control = InputControl.RS, id = "legacy:rs", kind = InputElementKind.STICK)
else -> it
}
})
assertEquals(OnScreenLayout.Dock(false, true, InputControl.LEFT_PAD, InputControl.RS), OnScreenLayout.Dock.deck(false, swapped))
}
private fun deckGamepad(library: InputLibrary) = library.layouts.first { it.id == DeckPanel.GAMEPAD.layout }
private fun upgradeWithSurfaces(left: String?, right: String?, change: (InputLibrary) -> InputLibrary = { it }): InputLibrary {
val stored = change(InputLibraries.read(context))
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putString("gamepadLeft", left).putString("gamepadRight", right).commit()
prefs.edit().putString("library", JSONObject(InputLibraries.encode(stored)).put("version", 13).toString()).commit()
return InputLibraries.read(context)
}
@Test fun theSavedDeckGamepadStickOrTrackpadChoiceMovesIntoTheUntouchedDeckGamepadOnce() {
val upgraded = upgradeWithSurfaces("trackpad", "stick")
val elements = deckGamepad(upgraded).elements
assertEquals(InputStockLayouts.gamepad(leftPad = true, rightStick = true), elements)
assertEquals(InputElementKind.TRACKPAD, elements.single { it.control == InputControl.LEFT_PAD }.kind)
assertEquals(InputElementKind.STICK, elements.single { it.control == InputControl.RS }.kind)
assertNull(elements.firstOrNull { it.control == InputControl.LS || it.control == InputControl.RIGHT_PAD })
assertEquals(OnScreenLayout.Dock(false, true, InputControl.LEFT_PAD, InputControl.RS), OnScreenLayout.Dock.deck(false, deckGamepad(upgraded)))
assertEquals(upgraded, InputLibraries.read(context))
}
@Test fun theDefaultDeckGamepadChoiceOrAnEditedDeckGamepadIsLeftAlone() {
val stock = InputStockLayouts.layouts().first { it.id == DeckPanel.GAMEPAD.layout }
assertEquals(stock.elements, deckGamepad(upgradeWithSurfaces(null, null)).elements)
assertEquals(stock.elements, deckGamepad(upgradeWithSurfaces("stick", "trackpad")).elements)
val moved = stock.elements.map { if (it.control == InputControl.A) it.copy(x = .7f) else it }
val edited = upgradeWithSurfaces("trackpad", "stick") { it.withLayout(deckGamepad(it).copy(elements = moved)) }
assertEquals(moved, deckGamepad(edited).elements)
val others = InputLibraries.read(context).layouts.filter { it.id != DeckPanel.GAMEPAD.layout }
assertEquals(InputStockLayouts.layouts().filter { it.id != DeckPanel.GAMEPAD.layout }.map { it.elements },
others.filter { l -> InputStockLayouts.layouts().any { it.id == l.id } }.map { it.elements })
}
}
@@ -64,13 +64,4 @@ class SecondScreenModeTest {
assertEquals(SecondScreenMode.TERMINAL, SecondScreenMode.opening("terminal", deckPad, dashboard, desktopSession = true))
}
}
@Test fun theEditorOpensOnTheChosenBottomScreenUnlessAnotherAppHoldsIt() {
val free = { _: Int -> false }
assertEquals(listOf(2, 3), SecondScreenDisplays.editorCandidates(2, listOf(2, 3), free))
assertEquals(listOf(3, 2), SecondScreenDisplays.editorCandidates(3, listOf(2, 3), free))
assertEquals(listOf(-1, 2), SecondScreenDisplays.editorCandidates(-1, listOf(2), free))
assertEquals(listOf(3), SecondScreenDisplays.editorCandidates(2, listOf(2, 3)) { it == 2 })
assertEquals(emptyList<Int>(), SecondScreenDisplays.editorCandidates(2, listOf(2)) { it == 2 })
}
}
@@ -95,14 +95,6 @@ class GameStorageTest {
assertFalse(GameStorage.attached(File("/mnt/media_rw/B2BD-BCFC/STEAM GAMES")) { null })
}
@Test fun aSavedFolderThatIsThereIsAttachedWhateverItsVolumeReports() {
val folder = tmp.newFolder("card", "STEAM GAMES")
listOf(Environment.MEDIA_UNKNOWN, Environment.MEDIA_UNMOUNTED, Environment.MEDIA_REMOVED, null).forEach {
assertTrue(GameStorage.attached(folder, it, externalStoragePath = true))
}
assertTrue(GameStorage.attached(folder) { Environment.MEDIA_UNKNOWN })
}
@Test fun onlyASavedDriveThatIsMissingCountsAsDisconnected() {
SessionPrefs.setGameStorage(app, "", "")
assertFalse(GameStorage.disconnected(app))
@@ -20,14 +20,6 @@ class SortCatalogItemsTest {
assertEquals(listOf("Zelda", "Beta", "alpha"), sortCatalogItems(items, "titleDesc").map { it.title })
}
@Test fun anEntryTimeFallsBackWhenTheFirstSourceHasNone() {
assertEquals(30L, entryTime(30L, 20L))
assertEquals(20L, entryTime(0L, 20L))
assertEquals(20L, entryTime(null, 20L))
assertEquals(0L, entryTime(0L, null))
assertEquals(0L, entryTime(null, 0L))
}
@Test fun playedAndInstalledComeFirstWithTitlesBreakingTies() {
val items = listOf(item("1", "Zelda"), item("2", "alpha"), item("3", "Beta"))
val played = mapOf("gog:3" to 900L, "gog:1" to 900L)
@@ -383,10 +383,9 @@ class InputSettingsTest {
@Test fun elementPropertiesEditOpacityLabelsAndOfferImages() {
val element = mutableStateOf(InputSurfaceElement(InputControl.A, .1f, .1f, .2f, .2f, "custom", name = "Custom button"))
val action = mutableStateOf<InputAction?>(null)
compose.setContent { DroidDeckTheme {
androidx.compose.foundation.layout.Column(androidx.compose.ui.Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) {
InputElementProperties(element.value, action.value) { element.value = it }
InputElementProperties(element.value) { element.value = it }
}
} }
compose.onNodeWithTag("setting-element-opacity").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(60f) }
@@ -400,17 +399,6 @@ class InputSettingsTest {
compose.runOnIdle { element.value = InputSurfaceElement(InputControl.LEFT_PAD, .1f, .1f, .4f, .4f, "pad", InputElementKind.TRACKPAD, "Pad") }
compose.onNodeWithTag("setting-element-label").assertDoesNotExist()
compose.onNodeWithTag("setting-element-opacity").assertExists()
for (kind in listOf(InputElementKind.DPAD, InputElementKind.STICK)) {
compose.runOnIdle { element.value = InputSurfaceElement(kind.control, .1f, .1f, .4f, .4f, "imported:0", kind, "WASD"); action.value = InputAction.Directions(17, 32, 31, 30) }
compose.onNodeWithTag("setting-element-label").performScrollTo().performClick()
compose.runOnIdle { assertFalse(element.value.showLabel); action.value = InputAction.Native() }
compose.onNodeWithTag("setting-element-label").assertDoesNotExist()
compose.runOnIdle { action.value = InputAction.Directions(0, 0, 0, 0, listOf(16, 18, 44, 46)) }
if (kind == InputElementKind.STICK) compose.onNodeWithTag("setting-element-label").assertExists()
else compose.onNodeWithTag("setting-element-label").assertDoesNotExist()
}
compose.runOnIdle { element.value = InputSurfaceElement(InputControl.LS, .1f, .1f, .4f, .4f, "legacy:left-stick", InputElementKind.STICK, "Move") }
compose.onNodeWithTag("setting-element-label").assertExists()
}
@Test fun editorScalesEveryControlAndRestoresStockPositions() {
@@ -433,23 +421,6 @@ class InputSettingsTest {
compose.runOnIdle { assertEquals(layout.elements, InputLibraries.read(context).layouts.first { it.id == layout.id }.elements) }
}
@Test fun editorSnapChoiceIsRemembered() {
com.droiddeck.launcher.input.ControllerPrefs.setSnap(context, true)
val library = InputLibraries.read(context)
val layout = library.layout("game", InputRole.SECONDARY)!!
val round = mutableStateOf(0)
compose.setContent { DroidDeckTheme { androidx.compose.runtime.key(round.value) { InputSurfaceDialog("game", {}, {}, role = InputRole.PRIMARY,
layoutId = layout.id, bindingsId = library.profile("game").binding(InputRole.SECONDARY)) } } }
val snap = isToggleable() and hasAnySibling(hasText(context.getString(R.string.ctrl_editor_snap)))
compose.onNodeWithTag("input-editor-tools").performClick()
compose.onNode(snap).performScrollTo().assertIsOn().performClick()
compose.runOnIdle { assertFalse(com.droiddeck.launcher.input.ControllerPrefs.snap(context)) }
compose.runOnIdle { round.value = 1 }
compose.onNodeWithTag("input-editor-tools").performClick()
compose.onNode(snap).performScrollTo().assertIsOff()
com.droiddeck.launcher.input.ControllerPrefs.setSnap(context, true)
}
@Test fun quickButtonTogglesHideBuiltInButtonsWithoutDeletingThem() {
compose.setContent { DroidDeckTheme { InputSettingsPage({}) } }
compose.onNodeWithTag("input-profile-game").performScrollTo().performClick()
@@ -58,16 +58,6 @@ class InputSurfaceViewTest {
} finally { root.removeAllViews(); bridge.stop(); controller.pause().stop().destroy() }
}
@Test fun keyedDirectionsAreLabelledWithTheirKeysAndOtherActionsAreNot() {
val context = RuntimeEnvironment.getApplication()
val wasd = InputAction.Directions(17, 32, 31, 30, listOf(18, 0, 0, 16))
assertEquals(listOf((0f to -1f) to "W", (1f to 0f) to "D", (0f to 1f) to "S", (-1f to 0f) to "A"),
InputSurfaceView.directionLabels(context, wasd, cardinalOnly = true))
assertEquals(listOf("W", "D", "S", "A", "E", "Q"), InputSurfaceView.directionLabels(context, wasd, cardinalOnly = false).map { it.second })
assertEquals(listOf("W", "A"), InputSurfaceView.directionLabels(context, InputAction.Directions(17, 0, 0, 30), cardinalOnly = false).map { it.second })
assertTrue(InputSurfaceView.directionLabels(context, null, cardinalOnly = false).isEmpty())
}
@Test fun takesTouchAtMatchesWhatATouchDoes() {
val directory = Files.createTempDirectory("deck-panel").toFile()
try {
@@ -99,7 +99,7 @@ class SecondScreenBarTest {
} finally { panel.closeControls() }
}
@Test fun deckPanelHasTheBarWithSwapAndTabsAndProfileControlsKeepTheBar() {
@Test fun deckPanelHasTheBarWithSwapAndTabsAndProfileControlsStayFullScreen() {
SessionState.mode = SessionService.MODE_STEAM
SessionState.deckPad = true
var swaps = 0
@@ -116,14 +116,8 @@ class SecondScreenBarTest {
assertEquals(DeckPanel.entries.map { context.getString(it.label) }, bar.tabs!!.labels)
assertEquals(DeckPanel.BOTH.ordinal, bar.tabs!!.selected)
panel.showMode(SecondScreenMode.CUSTOM_CONTROLS)
val profile = panel.barModel(SecondScreenMode.CUSTOM_CONTROLS)!!
assertEquals(context.getString(R.string.input_profile_controls_mode), profile.title)
assertEquals(listOf(context.getString(R.string.swap_screens)), profile.extras.map { it.description })
assertNotNull(profile.onClose); assertNotNull(profile.onMenu)
profile.extras.single().onClick()
assertEquals(2, swaps)
assertTrue(rootChildren(panel)[0] is ComposeView)
assertTrue(rootChildren(panel)[1] is InputSurfaceView)
assertNull(panel.barModel(SecondScreenMode.CUSTOM_CONTROLS))
assertTrue(rootChildren(panel)[0] is InputSurfaceView)
} finally { panel.closeControls() }
val noSwap = presentation(swap = null)
try {
@@ -227,47 +221,6 @@ class SecondScreenBarTest {
compose.onNodeWithTag("bottom-bar-close").assertIsDisplayed()
}
@Test fun swappedTopScreenProfileControlsHaveTheBarWithSwap() {
SessionState.mode = SessionService.MODE_STEAM
val activity = Robolectric.buildActivity(SessionActivity::class.java).get()
ReflectionHelpers.getField<MutableState<SecondScreenMode>>(activity, "secondScreenMode\$delegate").value = SecondScreenMode.CUSTOM_CONTROLS
val method = SessionActivity::class.java.getDeclaredMethod("SwappedTopPanel", androidx.compose.runtime.Composer::class.java, Int::class.javaPrimitiveType)
method.isAccessible = true
compose.setContent { DroidDeckTheme { method.invoke(activity, currentComposer, 0) } }
compose.onNodeWithText(context.getString(R.string.input_profile_controls_mode)).assertIsDisplayed()
compose.onNodeWithContentDescription(context.getString(R.string.swap_screens)).assertIsDisplayed()
compose.onNodeWithTag("bottom-bar-close").assertIsDisplayed()
}
@Test fun theBottomPanelWaitsUnderAnOpenInputEditorAndReturnsWhenItCloses() {
SessionState.mode = SessionService.MODE_STEAM
val id = org.robolectric.shadows.ShadowDisplayManager.addDisplay("w1280dp-h960dp")
val activity = Robolectric.buildActivity(SessionActivity::class.java).get()
val displays = activity.getSystemService(DisplayManager::class.java)
org.robolectric.Shadows.shadowOf(displays.getDisplay(id)).setFlags(Display.FLAG_PRESENTATION)
ReflectionHelpers.setField(activity, "displayManager", displays)
ReflectionHelpers.setField(activity, "started", true)
ReflectionHelpers.getField<MutableState<Int>>(activity, "selectedSecondScreenDisplay\$delegate").value = id
val mode = ReflectionHelpers.getField<MutableState<SecondScreenMode>>(activity, "secondScreenMode\$delegate")
mode.value = SecondScreenMode.KEYBOARD_TRACKPAD
fun panel() = ReflectionHelpers.getField<SecondScreenPresentation?>(activity, "secondScreenPresentation")
fun show() = ReflectionHelpers.callInstanceMethod<Unit>(activity, "showSecondScreen",
ReflectionHelpers.ClassParameter.from(SecondScreenMode::class.java, SecondScreenMode.KEYBOARD_TRACKPAD))
activity.inputWindowShown(Display.DEFAULT_DISPLAY)
activity.inputWindowShown(id)
activity.inputWindowShown(id)
show()
assertNull(panel())
assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, mode.value)
activity.inputWindowClosed(id)
assertNull(panel())
activity.inputWindowClosed(id)
try {
assertEquals(id, panel()!!.display.displayId)
assertTrue(panel()!!.isShowing)
} finally { panel()?.closeControls() }
}
@Test fun unfoldedControlsGetTheSessionBar() {
SessionState.mode = SessionService.MODE_STEAM
val activity = Robolectric.buildActivity(SessionActivity::class.java).get()
@@ -376,7 +376,7 @@ class SessionSecondScreenTest {
isHomeApp = false, androidApps = emptyList(), hudOn = false,
micAvailable = micAvailable, micEnabled = micEnabled, onMicEnabled = onMic,
frameGen = FrameGen.Mode.OFF, lossless = Lossless.State.NONE, oscMode = "never",
suspendPolicy = "auto", backActionsInverted = false,
onScreenButtonsVisible = false, suspendPolicy = "auto", backActionsInverted = false,
touchMode = "auto", touchAuto = "trackpad", fexPreset = "default",
secondScreenMode = SecondScreenMode.NONE, secondScreenDisplays = displays, selectedSecondScreenDisplay = 1,
onHud = {}, onFrameGenPick = {}, onImportLossless = {}, onKeyboard = {}, onHardwareKeyboard = {},
-13
View File
@@ -302,7 +302,6 @@ For games that confine a visible cursor and warp it, which the automatic rule ca
The rule only counted a lock with a cursor position hint. A game that hides the cursor and confines the
pointer through a grab (Wine's ClipCursor) has a constraint with no hint, so the nested pointer stayed
absolute, the constraint held the X pointer still and no raw motion was produced: the camera froze.
0133 narrows this, and the upstream hint rule, to games that hold the X pointer.
## 0129 steamcompmgr, wayland: release a pointer lock at the game's pointer
@@ -332,15 +331,3 @@ With `--follow-game-window-mode`, the outer window of a nested SDL gamescope fol
asking for `_NET_WM_STATE_FULLSCREEN`, or a Steam game covering the X screen without minimise or maximise functions or
decorations in `_MOTIF_WM_HINTS`, makes the outer window fullscreen on the desktop. Anything else makes it a normal window of the
game window's size, or a maximised one when that size does not fit the desktop with its title bar and panel. A change is applied only when the wanted mode changes, so a manual resize or Super+F stays.
## 0133 steamcompmgr, wlserver: a pointer constraint is relative only while the game holds the X pointer
A game that hides the system cursor, draws its own and grabs the pointer to its window (Stardew Valley, Balatro) gets a lock
from Xwayland, which emulates the game's warps with a cursor position hint. Upstream kept any hinted lock relative and 0128
added every other constraint, so gamescope held the host's pointer lock and DroidDeck turned every direct touch swipe into
camera motion: dragging in those games only moved their cursor. Without relative mode such a lock still follows gamescope's
relative motion and touches still place the pointer. gamescope now compares the motion it sends (seat movement, or relative
motion while the seat is locked) with how the X pointer moves. A hinted constraint starts relative, as upstream, also when its hint arrives with the game's first warp, and is released
after 30 moving frames on which the X pointer follows. Any constraint goes relative once the X pointer moves less than a quarter
of that motion on three frames within half a second, as it does when the game recentres it or clips it to a small rectangle
(PEAK). `tools/tests/test_gamescope_pointer_hold.py` compiles the rule from this patch.
@@ -1,173 +0,0 @@
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -3165,6 +3165,128 @@
wlserver_unlock();
}
+struct PointerHold
+{
+ int nAwayFrames = 0;
+ int nFreeFrames = 0;
+ uint64_t ulFirstAway = 0;
+ bool bHeld = false;
+};
+
+static bool pointer_hold_step( PointerHold &state, bool bConstrained, double flSeatDX, double flSeatDY, double flPointerDX, double flPointerDY, uint64_t ulNow )
+{
+ if ( !bConstrained )
+ {
+ state = PointerHold{};
+ return false;
+ }
+
+ const double flSeat = std::max( std::abs( flSeatDX ), std::abs( flSeatDY ) );
+ if ( flSeat < 4.0 )
+ return state.bHeld;
+
+ const double flMiss = std::max( std::abs( flSeatDX - flPointerDX ), std::abs( flSeatDY - flPointerDY ) );
+ if ( flMiss > flSeat * 0.75 )
+ {
+ if ( state.nAwayFrames == 0 || ulNow - state.ulFirstAway > 500'000'000ul )
+ {
+ state.nAwayFrames = 0;
+ state.ulFirstAway = ulNow;
+ }
+ state.nFreeFrames = 0;
+ if ( ++state.nAwayFrames >= 3 )
+ state.bHeld = true;
+ }
+ else if ( state.bHeld && ++state.nFreeFrames >= 30 )
+ {
+ state = PointerHold{};
+ }
+
+ return state.bHeld;
+}
+
+static bool s_bPointerHeld = false;
+
+static void update_pointer_hold( global_focus_t *pFocus, bool bConstrained, bool bHinted )
+{
+ static PointerHold s_Hold;
+ static bool s_bTracking = false;
+ static double s_flSeatX = 0.0;
+ static double s_flSeatY = 0.0;
+ static int s_nRootX = 0;
+ static int s_nRootY = 0;
+ static bool s_bHinted = false;
+ static const steamcompmgr_win_t *s_pWindow = nullptr;
+ static const struct wlr_pointer_constraint_v1 *s_pConstraint = nullptr;
+
+ wlserver_lock();
+ const double flX = wlserver.mouse_surface_cursorx;
+ const double flY = wlserver.mouse_surface_cursory;
+ const double flSentX = wlserver.flSentDX;
+ const double flSentY = wlserver.flSentDY;
+ const struct wlr_pointer_constraint_v1 *pConstraint = wlserver.GetCursorConstraint();
+ wlserver.flSentDX = 0.0;
+ wlserver.flSentDY = 0.0;
+ wlserver_unlock();
+
+ if ( !bConstrained || !pFocus->cursor )
+ {
+ s_bTracking = false;
+ s_bHinted = false;
+ s_bPointerHeld = pointer_hold_step( s_Hold, false, 0.0, 0.0, 0.0, 0.0, 0 );
+ return;
+ }
+
+ const steamcompmgr_win_t *pWindow = pFocus->inputFocusWindow;
+ if ( pWindow != s_pWindow || pConstraint != s_pConstraint )
+ {
+ s_bTracking = false;
+ s_bHinted = false;
+ }
+ s_pWindow = pWindow;
+ s_pConstraint = pConstraint;
+
+ if ( s_bTracking && bHinted && !s_bHinted )
+ {
+ s_Hold = PointerHold{};
+ s_Hold.bHeld = true;
+ s_bPointerHeld = true;
+ }
+ s_bHinted = bHinted;
+
+ double flDX = flX - s_flSeatX;
+ double flDY = flY - s_flSeatY;
+ if ( flDX == 0.0 && flDY == 0.0 )
+ {
+ flDX = flSentX;
+ flDY = flSentY;
+ }
+
+ if ( s_bTracking && flDX == 0.0 && flDY == 0.0 )
+ return;
+
+ int nRootX = 0;
+ int nRootY = 0;
+ pFocus->cursor->QueryRootPosition( &nRootX, &nRootY );
+
+ if ( s_bTracking )
+ {
+ s_bPointerHeld = pointer_hold_step( s_Hold, true, flDX, flDY, nRootX - s_nRootX, nRootY - s_nRootY, get_time_in_nanos() );
+ }
+ else
+ {
+ s_Hold = PointerHold{};
+ s_Hold.bHeld = bHinted;
+ s_bPointerHeld = bHinted;
+ }
+
+ s_bTracking = true;
+ s_flSeatX = flX;
+ s_flSeatY = flY;
+ s_nRootX = nRootX;
+ s_nRootY = nRootY;
+}
+
bool ShouldDrawCursor()
{
if ( relative_mouse_forced() )
@@ -11029,9 +11151,15 @@
nRelativeMouse != 2 && !bExcludedAppId && !bHasPointerConstraint && !window_is_steam( pPaintFocus->inputFocusWindow ) );
}
+ if ( vblank )
+ update_pointer_hold( pPaintFocus, bHasPointerConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId,
+ pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained() );
+
+ const bool bGameHoldsPointer = bHasPointerConstraint && s_bPointerHeld;
+
const bool bRelativeMouseMode = nRelativeMouse == 2
? false
- : ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bHasPointerConstraint && !bExcludedAppId ) );
+ : ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );
static bool s_bRelativeMouseMode = false;
if ( s_bRelativeMouseMode && !bRelativeMouseMode && pPaintFocus->cursor &&
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
--- a/src/wlserver.cpp
+++ b/src/wlserver.cpp
@@ -346,6 +346,8 @@
{
assert( wlserver_is_lock_held() );
+ wlserver.flSentDX += unaccel_dx;
+ wlserver.flSentDY += unaccel_dy;
wlr_relative_pointer_manager_v1_send_relative_motion( wlserver.relative_pointer_manager, wlserver.wlr.seat, 0, unaccel_dx, unaccel_dy, unaccel_dx, unaccel_dy );
}
diff --git a/src/wlserver.hpp b/src/wlserver.hpp
--- a/src/wlserver.hpp
+++ b/src/wlserver.hpp
@@ -171,6 +171,8 @@
bool bFollowClientPointer = false;
double flFollowDX = 0.0;
double flFollowDY = 0.0;
+ double flSentDX = 0.0;
+ double flSentDY = 0.0;
bool button_held[ WLSERVER_BUTTON_COUNT ];
std::set <uint32_t> touch_down_ids;
@@ -1078,8 +1078,7 @@ class Helper:
wanted = COMPAT["requested_tool"](request, catalog)
newer = isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0)
waiting = newer and wanted is not None and wanted not in self.known
default, state = COMPAT["choose_default"](top["selected"], state, request, catalog, apply_request=not waiting,
listed=[t["name"] for t in live])
default, state = COMPAT["choose_default"](top["selected"], state, request, live, apply_request=not waiting)
installed = COMPAT["installed_apps"](self.steam_root)
self.installed = installed
picks = COMPAT["valve_picks"](self.steam_root)
@@ -1138,7 +1137,7 @@ class Helper:
state.update(
auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]},
native_choices=sorted(refused_native | {app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]}),
picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True, written=default,
picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True,
games_applied=COMPAT["applied_game_requests"](requests, overrides, state))
state.pop("native", None)
if waiting:
@@ -355,9 +355,8 @@ def main():
"linux_tool": "droiddeck-direct"}
else:
protect = compat.adopt_extras(tools)
owned = [str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe") and not g.get("native")]
used = compat.register_default(cfg, apps=owned + [str(appid) for appid in set(routes.values())],
protect=protect, owned=owned, **own)
used = compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe") and not g.get("native")] + [str(appid) for appid in set(routes.values())],
protect=protect, **own)
print("droiddeck-steam-shortcuts: mapped them to %s" % used)
except Exception as e:
print("droiddeck-steam-shortcuts: compat mapping skipped (%s)" % e)
@@ -1307,13 +1307,9 @@ def now_ms():
return int(time.time() * 1000)
def choose_default(steam_default, state, request, catalog, adopt_steam=True, apply_request=True, now=None, listed=None):
def choose_default(steam_default, state, request, catalog, adopt_steam=True, apply_request=True, now=None):
state = dict(state)
names = [t["name"] for t in catalog]
listed = names if listed is None else listed
if "written" not in state and state.get("source") == "steam":
state["source"] = "fallback"
hidden = state.get("default") in names and state.get("default") not in listed
seq = request.get("seq")
seq = seq if isinstance(seq, int) else 0
if apply_request and seq > int(state.get("applied", 0) or 0):
@@ -1325,12 +1321,11 @@ def choose_default(steam_default, state, request, catalog, adopt_steam=True, app
else:
print("steam-compatibility: the app asked for %s, which is not installed; kept %s" % (
request.get("name") or request.get("dir"), state.get("default") or TOOL))
elif adopt_steam and steam_default in names and not hidden and steam_default not in (
state.get("default"), state.get("written", state.get("default") or TOOL)):
elif adopt_steam and steam_default in names and steam_default != state.get("default"):
state.update(default=steam_default, seq=now or now_ms(), source="steam")
if state.get("default") not in names and state.get("default") != TOOL:
state.update(default=TOOL, source="fallback")
return state["default"] if state["default"] in listed else TOOL, state
if state.get("default") not in names:
state["default"] = TOOL
return state["default"], state
def mapping_block(tokens):
@@ -1487,7 +1482,7 @@ def live_helper_running(directory=None):
def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False, picks=(),
linux_tool=FEX_TOOL, owned=None):
linux_tool=FEX_TOOL):
with locked(directory):
state = read_json(state_path("state.json", directory))
with open(config_path, encoding="utf-8", errors="replace") as f:
@@ -1502,24 +1497,18 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
dict(protect) if isinstance(protect, dict) else {name: name for name in protect})
names = [t["name"] for t in catalog]
newest, gone = own_proton_moves(state, catalog)
previous = state.get("default")
if owned is not None:
auto = followers(state, current)
known = {app for app in state.get("owned", ()) if isinstance(app, str)} if isinstance(state.get("owned"), list) else set()
pinned = {app: current[app] for app in set(owned) - known
if current.get(app) in {TOOL, previous} | gone and app not in auto}
if pinned and isinstance(auto, dict):
state["auto"] = dict(auto, **pinned)
state["owned"] = sorted(set(owned), key=app_order)
previous, applied, chosen = state.get("default"), state.get("applied"), state.get("seq")
if default is None:
if settle:
default, state = choose_default(current.get("0", ""), state, read_json(state_path("request.json", directory)), catalog)
else:
default = state.get("default") if state.get("default") in names else TOOL
if settle and newest and default == TOOL and state.get("source") not in ("app", "steam"):
if settle and newest and default == TOOL and state.get("applied") == applied and state.get("seq") == chosen and (
previous in gone or not state.get("own_proton")):
default = newest
state.update(default=newest, seq=now_ms(), source="update")
state.pop("own_proton", None)
if settle and newest:
state["own_proton"] = True
if newest:
replacements = {app: newest for app, name in current.items() if app != "0" and name in gone}
set_mappings(tokens, mapping, replacements)
@@ -1550,8 +1539,6 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
state.pop("native", None)
state["tools"] = catalog
state.setdefault("default", default)
if settle:
state["written"] = default
write_json(state_path("state.json", directory), state)
return default
+7 -86
View File
@@ -3,7 +3,6 @@ import runpy
import tempfile
import unittest
from pathlib import Path
from unittest import mock
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
@@ -12,8 +11,6 @@ OLD = {"name": "DroidDeck-Proton-11-11.2", "display": "DroidDeck-Proton 11-11.2"
NEW = {"name": "DroidDeck-Proton-11-11.4", "display": "DroidDeck-Proton 11-11.4", "dir": "DroidDeck-Proton 11-11.4", "valve": False}
VALVE = {"name": TOOL, "display": "Proton Experimental ARM64", "dir": "Proton - Experimental (ARM64)", "valve": True}
GE = {"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False}
GE10 = {"name": "GE-Proton10-3", "display": "GE-Proton10-3", "dir": "GE-Proton10-3", "valve": False}
NEWER = {"name": "DroidDeck-Proton-11-12.1", "display": "DroidDeck-Proton 11-12.1", "dir": "DroidDeck-Proton 11-12.1", "valve": False}
def config_text(entries):
@@ -22,19 +19,15 @@ def config_text(entries):
class DroidDeckProtonDefaultTest(unittest.TestCase):
def run_default(self, state, mapping, catalog, apps=(), request=None, settle=True, owned=None, games=None):
def run_default(self, state, mapping, catalog, apps=(), request=None, settle=True):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
(directory / "state.json").write_text(json.dumps(state))
if request is not None:
(directory / "request.json").write_text(json.dumps(request))
if games is not None:
(directory / "games.json").write_text(json.dumps({"version": 1, "games": games}))
config = directory / "config.vdf"
config.write_text(config_text(mapping))
with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False):
default = COMPAT["register_default"](str(config), list(apps), catalog=catalog, directory=str(directory),
settle=settle, owned=owned)
default = COMPAT["register_default"](str(config), list(apps), catalog=catalog, directory=str(directory), settle=settle)
return default, COMPAT["read_config_mapping"](str(config)), json.loads((directory / "state.json").read_text())
def test_a_replacement_seen_while_steam_runs_still_moves_the_default_later(self):
@@ -57,7 +50,8 @@ class DroidDeckProtonDefaultTest(unittest.TestCase):
{"default": TOOL, "auto": {"42": TOOL}}, [("0", TOOL), ("42", TOOL), ("43", TOOL)], [VALVE, NEW], ["42", "43"])
self.assertEqual(default, NEW["name"])
self.assertEqual(mapping, {"0": NEW["name"], "42": NEW["name"], "43": TOOL})
self.assertEqual((state["default"], state["source"], state["written"]), (NEW["name"], "update", NEW["name"]))
self.assertEqual(state["default"], NEW["name"])
self.assertTrue(state["own_proton"])
def test_a_default_picked_after_the_switch_is_kept(self):
default, mapping, state = self.run_default(
@@ -65,7 +59,8 @@ class DroidDeckProtonDefaultTest(unittest.TestCase):
[("0", NEW["name"])], [VALVE, NEW], request={"seq": 2, "valve": True, "dir": VALVE["dir"]})
self.assertEqual(default, TOOL)
self.assertEqual(mapping, {"0": TOOL})
default, mapping, state = self.run_default(state, [("0", TOOL)], [VALVE, NEW])
default, mapping, state = self.run_default(
{"default": TOOL, "own_proton": True, "tools": [VALVE, NEW]}, [("0", TOOL)], [VALVE, NEW])
self.assertEqual(default, TOOL)
self.assertEqual(mapping, {"0": TOOL})
@@ -80,7 +75,7 @@ class DroidDeckProtonDefaultTest(unittest.TestCase):
def test_a_replaced_build_does_not_override_a_valve_default(self):
default, mapping, _ = self.run_default(
{"default": TOOL, "source": "app", "written": TOOL, "tools": [VALVE, OLD]}, [("0", TOOL), ("43", OLD["name"])], [VALVE, NEW], ["43"])
{"default": TOOL, "own_proton": True, "tools": [VALVE, OLD]}, [("0", TOOL), ("43", OLD["name"])], [VALVE, NEW], ["43"])
self.assertEqual(default, TOOL)
self.assertEqual(mapping, {"0": TOOL, "43": NEW["name"]})
@@ -90,80 +85,6 @@ class DroidDeckProtonDefaultTest(unittest.TestCase):
self.assertEqual(mapping, {"0": TOOL})
self.assertNotIn("own_proton", state)
def test_a_fresh_state_with_steam_on_proton_experimental_moves_to_the_newest_build(self):
default, mapping, state = self.run_default({}, [("0", TOOL)], [VALVE, OLD, NEW])
self.assertEqual((default, mapping), (NEW["name"], {"0": NEW["name"]}))
default, mapping, state = self.run_default(state, [("0", NEW["name"])], [VALVE, OLD, NEW])
self.assertEqual((default, state["source"]), (NEW["name"], "update"))
def test_a_default_adopted_from_steam_by_an_earlier_build_recovers(self):
default, mapping, state = self.run_default(
{"default": TOOL, "source": "steam", "seq": 5, "own_proton": True, "tools": [VALVE, NEW]}, [("0", TOOL)], [VALVE, NEW])
self.assertEqual((default, mapping, state["source"]), (NEW["name"], {"0": NEW["name"]}, "update"))
self.assertNotIn("own_proton", state)
def test_a_request_applied_without_a_change_still_moves_to_the_newest_build(self):
for request in ({"seq": 5, "valve": True, "dir": VALVE["dir"]}, {"seq": 5, "valve": False, "dir": GE["dir"]}):
default, mapping, state = self.run_default({"default": TOOL, "applied": 1}, [("0", TOOL)], [VALVE, NEW], request=request)
self.assertEqual((default, mapping, state["applied"]), (NEW["name"], {"0": NEW["name"]}, 5))
def test_a_default_that_is_not_installed_falls_back_without_counting_as_a_pick(self):
default, mapping, state = self.run_default(
{"default": GE["name"], "source": "app", "seq": 9, "written": GE["name"]}, [("0", GE["name"])], [VALVE])
self.assertEqual((default, mapping), (TOOL, {"0": TOOL}))
self.assertEqual((state["source"], state["seq"]), ("fallback", 9))
default, _, state = self.run_default(state, [("0", TOOL)], [VALVE, NEW])
self.assertEqual((default, state["source"]), (NEW["name"], "update"))
def test_proton_experimental_asked_for_in_the_app_stays(self):
default, mapping, state = self.run_default(
{"default": NEW["name"], "written": NEW["name"], "applied": 1}, [("0", NEW["name"])], [VALVE, NEW],
request={"seq": 2, "valve": True, "dir": VALVE["dir"]})
self.assertEqual((default, state["source"]), (TOOL, "app"))
for catalog in ([VALVE, NEW], [VALVE, NEW, NEWER], [VALVE, NEWER]):
default, mapping, state = self.run_default(state, [("0", TOOL)], catalog)
self.assertEqual((default, mapping, state["source"]), (TOOL, {"0": TOOL}, "app"))
def test_proton_experimental_picked_in_steam_stays(self):
default, mapping, state = self.run_default(
{"default": NEW["name"], "written": NEW["name"], "source": "update"}, [("0", TOOL)], [VALVE, NEW])
self.assertEqual((default, state["source"], state["written"]), (TOOL, "steam", TOOL))
for catalog in ([VALVE, NEW], [VALVE, NEW, NEWER]):
default, mapping, state = self.run_default(state, [("0", TOOL)], catalog)
self.assertEqual((default, mapping, state["source"]), (TOOL, {"0": TOOL}, "steam"))
def test_steam_echoing_the_written_default_is_not_a_pick(self):
default, _, state = self.run_default(
{"default": NEW["name"], "written": TOOL, "source": "update"}, [("0", TOOL)], [VALVE, NEW])
self.assertEqual((default, state["source"]), (NEW["name"], "update"))
def test_a_default_the_running_client_does_not_list_is_kept(self):
state = {"default": NEW["name"], "written": NEW["name"], "seq": 1, "source": "update"}
default, state = COMPAT["choose_default"](TOOL, state, {}, [VALVE, NEW], listed=[TOOL])
self.assertEqual((default, state["default"], state["source"], state["seq"]), (TOOL, NEW["name"], "update", 1))
default, state = COMPAT["choose_default"](GE["name"], dict(state, written=TOOL), {}, [VALVE, NEW, GE])
self.assertEqual((default, state["source"]), (GE["name"], "steam"))
def test_owned_shortcuts_follow_the_default_and_explicit_picks_stay(self):
owned = ["3000000001", "3000000002", "3000000003", "3000000004"]
catalog = [VALVE, NEW, GE, GE10]
default, mapping, state = self.run_default(
{"default": NEW["name"], "written": NEW["name"], "source": "update", "auto": {}},
[("0", NEW["name"]), ("3000000001", NEW["name"]), ("3000000002", TOOL), ("3000000003", GE["name"]),
("3000000004", NEW["name"]), ("42", GE["name"]), ("44", NEW["name"])],
catalog, ["42", "44"] + owned, settle=False, owned=owned, games={"44": {"valve": False, "dir": GE["dir"]}})
self.assertEqual(mapping, {"0": NEW["name"], "3000000001": NEW["name"], "3000000002": NEW["name"],
"3000000003": GE["name"], "3000000004": NEW["name"], "42": GE["name"], "44": GE["name"]})
mapping["3000000001"] = TOOL
default, mapping, state = self.run_default(state, sorted(mapping.items()), catalog, ["42", "44"] + owned,
settle=False, owned=owned)
self.assertEqual(mapping["3000000001"], TOOL)
default, mapping, state = self.run_default(state, sorted(mapping.items()), catalog, ["42", "44"] + owned,
request={"seq": 1, "valve": False, "dir": GE10["dir"]})
self.assertEqual(default, GE10["name"])
self.assertEqual(mapping, {"0": GE10["name"], "3000000001": TOOL, "3000000002": GE10["name"],
"3000000003": GE["name"], "3000000004": GE10["name"], "42": GE["name"], "44": GE["name"]})
def test_the_newest_build_is_found_by_version(self):
newest, gone = COMPAT["own_proton_moves"]({"tools": [OLD]}, [dict(NEW, name="a", dir="DroidDeck-Proton 11-9.10"), dict(NEW, name="b", dir="DroidDeck-Proton 11-11.1")])
self.assertEqual(newest, "b")
-147
View File
@@ -1,147 +0,0 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
PATCH = ROOT / "tools/gamescope/patches/0133-constraint-relative-when-held.patch"
def added_source():
return "\n".join(line[1:] for line in PATCH.read_text().splitlines() if line.startswith("+") and not line.startswith("+++"))
def block(text, start):
begin = text.index(start)
return text[begin:text.index("\n}", begin) + 3]
def rules():
text = added_source()
return "\n".join([
block(text, "struct PointerHold"),
block(text, "static bool pointer_hold_step"),
])
PREAMBLE = r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
'''
MAIN = r'''
static const uint64_t FRAME = 16'666'667ul;
static bool run(PointerHold &state, int frames, double seatDX, double seatDY, double pointerDX, double pointerDY, uint64_t &now, bool constrained = true) {
bool held = state.bHeld;
for (int i = 0; i < frames; i++) {
now += FRAME;
held = pointer_hold_step(state, constrained, seatDX, seatDY, pointerDX, pointerDY, now);
}
return held;
}
int main() {
uint64_t now = 1'000'000'000ul;
PointerHold cursorGame;
assert(!run(cursorGame, 600, 12.0, -9.0, 12.0, -9.0, now));
assert(!run(cursorGame, 600, 40.0, 3.0, 39.0, 3.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 30.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 120, 2.0, 1.0, 0.0, 0.0, now));
PointerHold offset;
assert(!run(offset, 600, 25.0, 25.0, 25.0, 25.0, now));
PointerHold recentring;
assert(!run(recentring, 2, -40.0, 12.0, 0.0, 0.0, now));
assert(run(recentring, 1, -40.0, 12.0, 1.0, 0.0, now));
assert(run(recentring, 600, 0.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 29, 10.0, 4.0, 10.0, 4.0, now));
assert(run(recentring, 1, 20.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 29, 10.0, 4.0, 10.0, 4.0, now));
assert(!run(recentring, 1, 10.0, 4.0, 10.0, 4.0, now));
PointerHold clipped;
assert(run(clipped, 3, 0.0, 30.0, 0.0, 2.0, now));
PointerHold spaced;
for (int i = 0; i < 10; i++) {
assert(!run(spaced, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 600'000'000ul;
}
PointerHold hinted;
hinted.bHeld = true;
assert(run(hinted, 29, 0.0, 15.0, 0.0, 15.0, now));
assert(!run(hinted, 1, 0.0, 15.0, 0.0, 15.0, now));
PointerHold hintedCamera;
hintedCamera.bHeld = true;
assert(run(hintedCamera, 600, 30.0, -8.0, 0.0, 0.0, now));
PointerHold chained;
for (int i = 0; i < 6; i++) {
assert(!run(chained, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 400'000'000ul;
}
PointerHold released;
assert(run(released, 3, 0.0, -25.0, 0.0, 0.0, now));
assert(!run(released, 1, 0.0, -25.0, 0.0, 0.0, now, false));
assert(!run(released, 2, 0.0, -25.0, 0.0, 0.0, now));
puts("ok");
return 0;
}
'''
@unittest.skipUnless(shutil.which("c++"), "needs a C++ compiler")
class PointerHoldTest(unittest.TestCase):
def test_only_a_game_that_holds_the_x_pointer_goes_relative(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "hold.cpp"
binary = Path(tmp) / "hold"
source.write_text(PREAMBLE + rules() + MAIN)
subprocess.run(["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", str(source), "-o", str(binary)], check=True)
self.assertEqual(subprocess.run([str(binary)], capture_output=True, text=True, check=True).stdout.strip(), "ok")
def test_a_hinted_constraint_starts_relative_and_others_wait_for_the_hold(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("update_pointer_hold( pPaintFocus, bHasPointerConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId,", added)
self.assertIn("pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained() );", added)
self.assertIn("s_Hold.bHeld = bHinted;", added)
self.assertIn("if ( s_bTracking && bHinted && !s_bHinted )", added)
self.assertIn("const bool bGameHoldsPointer = bHasPointerConstraint && s_bPointerHeld;", added)
self.assertIn(": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );", added)
def test_a_new_window_or_constraint_starts_its_own_hold(self):
tracker = block(added_source(), "static void update_pointer_hold")
reset = tracker.index("if ( pWindow != s_pWindow || pConstraint != s_pConstraint )")
self.assertIn("const steamcompmgr_win_t *pWindow = pFocus->inputFocusWindow;", tracker)
constraint = tracker.index("wlserver.GetCursorConstraint();")
self.assertLess(tracker.index("wlserver_lock();"), constraint)
self.assertLess(constraint, tracker.index("wlserver_unlock();"))
self.assertLess(reset, tracker.index("if ( s_bTracking && bHinted && !s_bHinted )"))
self.assertIn("s_bTracking = false;", tracker[reset:reset + 120])
def test_the_motion_sent_to_clients_is_counted(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("wlserver.flSentDX += unaccel_dx;", added)
self.assertIn("wlserver.flSentDY += unaccel_dy;", added)
def test_the_forced_and_follow_rules_are_unchanged(self):
removed = [line[1:].strip() for line in PATCH.read_text().splitlines() if line.startswith("-") and not line.startswith("---")]
self.assertEqual(removed, [": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bHasPointerConstraint && !bExcludedAppId ) );"])
if __name__ == "__main__":
unittest.main()
-20
View File
@@ -749,26 +749,6 @@ class ReconcileTest(unittest.TestCase):
self.assertIn(entry % ("10", COMPAT["FEX_TOOL"]), " ".join(config.read_text().split()))
self.assertEqual(self.games(), {})
def test_our_default_the_client_does_not_list_survives_to_the_next_start(self):
build = "DroidDeck-Proton 11-11.4"
(self.steam / "compatibilitytools.d" / build).mkdir(parents=True)
(self.steam / "compatibilitytools.d" / build / COMPAT["EXTRA_WRAPPER"]).write_text("")
(self.state / "state.json").write_text(json.dumps({"default": build, "written": build, "source": "update", "auto": {"10": build}}))
page = FakePage({"0": TOOL, "10": build}, [TOOL, TOOL_11])
state = self.reconcile(page)
self.assertEqual((state["default"], state["source"], state["written"]), (build, "update", TOOL))
self.assertEqual(page.sets, [("10", TOOL), ("11", TOOL)])
config = self.steam / "config/config.vdf"
config.parent.mkdir(parents=True)
vdf = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }'
entry = '"%s" { "name" "%s" "config" "" "priority" "250" }'
config.write_text(vdf % "".join(entry % (app, page.mapping[app]) for app in ("0", "10", "11")))
with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False):
default = COMPAT["register_default"](str(config), ["10", "11"], catalog=COMPAT["tool_catalog"](str(self.steam)),
directory=str(self.state), settle=True)
self.assertEqual(default, build)
self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": build, "10": build, "11": build})
def test_file_mode_defers_to_the_live_helper(self):
(self.state / "live.pid").write_text("%d\n" % os.getpid())
self.assertTrue(COMPAT["live_helper_running"]())