From 6f1fb462edcb7ef65efa70730e4234655f747e6e Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:56:09 -0400 Subject: [PATCH] Add controller and desktop quality-of-life options (#194) --- .../com/droiddeck/launcher/MainActivity.kt | 12 +++ .../com/droiddeck/launcher/SessionActivity.kt | 25 +++++- .../launcher/input/ControllerPrefs.kt | 31 ++++++- .../launcher/input/OnScreenControls.kt | 55 +++++++++++- .../launcher/session/RumbleComponent.kt | 36 ++++++-- .../launcher/session/SessionFiles.kt | 1 + .../launcher/session/SessionPrefs.kt | 6 ++ .../launcher/ui/ControllerSettings.kt | 8 ++ .../com/droiddeck/launcher/ui/FocusGlide.kt | 16 +++- .../droiddeck/launcher/ui/FrontEndContent.kt | 2 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 11 ++- .../droiddeck/launcher/ui/FrontEndSetup.kt | 2 + .../com/droiddeck/launcher/ui/LaunchFlood.kt | 6 ++ .../com/droiddeck/launcher/ui/PcKeyboard.kt | 2 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 15 ++++ app/src/main/res/values/strings.xml | 5 ++ .../launcher/input/OnScreenControlsTest.kt | 64 ++++++++++++++ .../launcher/session/RumbleComponentTest.kt | 83 +++++++++++++++++++ .../com/droiddeck/launcher/ui/MotionTest.kt | 43 ++++++++++ .../local/bin/bannerlator-desktop-bookmarks | 60 ++++++++++++++ .../overlay/usr/local/bin/bannerlator-session | 3 + tools/tests/test_desktop_bookmarks.py | 69 +++++++++++++++ 22 files changed, 536 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/MotionTest.kt create mode 100755 tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks create mode 100644 tools/tests/test_desktop_bookmarks.py diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 99fc649e..acf25956 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -150,6 +150,7 @@ class MainActivity : ComponentActivity() { private var forceFullscreen by mutableStateOf(true) private var stretch16x9 by mutableStateOf(false) private var launcherFullscreen by mutableStateOf(true) + private var animationsEnabled by mutableStateOf(true) private var storeEnabled by mutableStateOf(false) private var mic by mutableStateOf(false) private var wifiDiscovery by mutableStateOf(false) @@ -476,6 +477,7 @@ class MainActivity : ComponentActivity() { steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this) backActionsInverted = SessionPrefs.backActionsInverted(this) launcherFullscreen = SessionPrefs.launcherFullscreen(this) + animationsEnabled = SessionPrefs.animationsEnabled(this) storeEnabled = SessionPrefs.storeEnabled(this) applyLauncherFullscreen() updates.start() @@ -526,6 +528,7 @@ class MainActivity : ComponentActivity() { phantomProcessStatus = phantomProcessStatus, showPhantomGate = showPhantomGate, launcherFullscreen = launcherFullscreen, + animationsEnabled = animationsEnabled, storeEnabled = storeEnabled, ), FrontEndActions( @@ -641,6 +644,11 @@ class MainActivity : ComponentActivity() { applyLauncherFullscreen() }, onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on }, + onAnimationsEnabled = { on -> + SessionPrefs.setAnimationsEnabled(this, on) + animationsEnabled = on + com.droiddeck.launcher.ui.Motion.refresh(this) + }, onHomeApp = { manageHomeApp() }, onHomeScreen = { on -> HomeApp.setHomeScreenEnabled(this, on) @@ -709,6 +717,10 @@ class MainActivity : ComponentActivity() { onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() }, onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() }, onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, + onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() }, + onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() }, + onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() }, + onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 32abd276..6e354d47 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -43,6 +43,7 @@ import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.input.EvdevKeys import com.droiddeck.launcher.input.KeyboardHost +import com.droiddeck.launcher.input.ControllerPrefs import com.droiddeck.launcher.input.SessionClipboard import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.PadBridge @@ -132,6 +133,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private var onScreenControls: OnScreenControls? = null private var keyboard: KeyboardHost? = null + private var controllerSettings by mutableStateOf(null) private val sessionClipboard by lazy { SessionClipboard(this) { window.decorView.hasWindowFocus() || secondScreenPresentation?.window?.decorView?.hasWindowFocus() == true @@ -332,7 +334,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { uiHandler.removeCallbacks(cursorHide) cursorVisible = false } - onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) } + onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) } keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) // One arrow, ours: Android draws a system pointer for a mouse over any window, and the @@ -490,7 +492,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title))) }, onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() }, - onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, + onHardwareKeyboard = ::togglePcKeyboard, + controller = controllerSettings, + onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } }, + onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } }, + onQamButton = { on -> updateControllerPrefs { ControllerPrefs.setQamButton(this@SessionActivity, on) } }, + onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, backActionsInverted = backActionsInverted, @@ -584,6 +591,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { padBridge?.triggerQam() } + private fun togglePcKeyboard() { + drawerOpen = false + keyboard?.hide() + onScreenControls?.releaseAll() + pcKeyboardOpen = !pcKeyboardOpen + } + + private fun updateControllerPrefs(change: () -> Unit) { + change() + controllerSettings = ControllerPrefs.read(this) + onScreenControls?.reload() + } + private fun routeBackAction() { pendingBackAction?.let { pending -> uiHandler.removeCallbacks(pending) @@ -1808,6 +1828,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (getSystemService(INPUT_SERVICE) as? InputManager) ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) onScreenControls?.reload() + controllerSettings = ControllerPrefs.read(this) updateOnScreenControls() resumed = true if (!pipUi) sessionClipboard.start() diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt index 7bcf21cb..a5a13042 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt @@ -41,11 +41,15 @@ object ControllerPrefs { val size: Int, val stickClick: Boolean, val adaptiveSticks: Boolean, + val rumble: Boolean, + val steamButton: Boolean, + val qamButton: Boolean, + val keyboardButton: Boolean, val customLayout: Boolean, val mapping: Map, ) - private fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE) + internal fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE) fun read(context: Context): Settings { val p = prefs(context) @@ -55,6 +59,10 @@ object ControllerPrefs { size = p.getInt("size", 100).takeIf { it in sizes } ?: 100, stickClick = p.getBoolean("stickClick", true), adaptiveSticks = p.getBoolean("adaptiveSticks", true), + rumble = rumbleEnabled(context), + steamButton = p.getBoolean("steamButton", true), + qamButton = p.getBoolean("qamButton", true), + keyboardButton = p.getBoolean("keyboardButton", true), customLayout = p.all.keys.any { it.startsWith("layout.") }, mapping = mappable.associate { (id, _) -> id to target(context, id) }, ) @@ -92,6 +100,24 @@ object ControllerPrefs { prefs(context).edit().putBoolean("adaptiveSticks", on).apply() } + fun rumbleEnabled(context: Context): Boolean = prefs(context).getBoolean("rumble", true) + + fun setRumble(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("rumble", on).apply() + } + + fun setSteamButton(context: Context, on: Boolean) { + 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() + } + fun layout(context: Context, width: Int, height: Int): Map> { val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap() return raw.split(';').mapNotNull { entry -> @@ -111,7 +137,8 @@ object ControllerPrefs { } fun resetAll(context: Context) { - prefs(context).edit().clear().apply() + // On older Android versions clear() does not notify listeners; restore rumble explicitly. + prefs(context).edit().clear().putBoolean("rumble", true).apply() } fun resetAllLayouts(context: Context) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index 66126cd5..bad6ab6f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -19,7 +19,12 @@ import kotlin.math.sin import kotlin.math.sqrt @SuppressLint("ViewConstructor") -class OnScreenControls(context: Context, private val pad: PadBridge?, private val editing: Boolean = false) : View(context) { +class OnScreenControls( + context: Context, + private val pad: PadBridge?, + private val editing: Boolean = false, + private val onKeyboard: (() -> Unit)? = null, +) : View(context) { private class Control( val id: String, @@ -88,6 +93,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va private var buttonsOnly = false private var quickHidden = false private var quickPressedBy = -1 + private var keyboardPressedBy = -1 private var settings = ControllerPrefs.read(context) private var safe = Rect() private var selected: String? = null @@ -426,10 +432,37 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va text.textSize = dp(22f) canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text) } + if (!editing && settings.keyboardButton && onKeyboard != null) { + val (x, y) = keyboardCenter() + fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill)) + canvas.drawCircle(x, y, dp(18f), fill) + stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke + canvas.drawCircle(x, y, dp(18f), stroke) + box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f)) + canvas.drawRoundRect(box, dp(2f), dp(2f), stroke) + for (row in 0..1) for (col in 0..2) { + val kx = x + dp(-6f + col * 5f) + val ky = y + dp(-3f + row * 4f) + canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke) + } + canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke) + } } private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f)) + private fun keyboardCenter() = if (buttonsOnly) + width / 2f to (height - safe.bottom - dp(44f)) + else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f)) + + private fun keyboardContains(x: Float, y: Float): Boolean { + if (!settings.keyboardButton || onKeyboard == null) return false + val (cx, cy) = keyboardCenter() + val dx = x - cx + val dy = y - cy + return dx * dx + dy * dy <= dp(26f) * dp(26f) + } + private fun quickContains(x: Float, y: Float): Boolean { val (cx, cy) = quickCenter() val radius = dp(26f) @@ -462,6 +495,12 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va val index = event.actionIndex val x = event.getX(index) val y = event.getY(index) + if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) { + keyboardPressedBy = event.getPointerId(index) + invalidate() + return true + } + if (keyboardPressedBy != -1) return true if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) { quickPressedBy = event.getPointerId(index) return true @@ -483,6 +522,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va return true } MotionEvent.ACTION_MOVE -> { + if (keyboardPressedBy != -1) return true if (quickPressedBy != -1) return true var changed = false for (index in 0 until event.pointerCount) { @@ -509,10 +549,16 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { val pointer = event.getPointerId(event.actionIndex) if (event.actionMasked == MotionEvent.ACTION_CANCEL) { - quickPressedBy = -1 releaseAll() return true } + if (keyboardPressedBy == pointer) { + val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y) + keyboardPressedBy = -1 + invalidate() + if (tapped) { releaseAll(); onKeyboard?.invoke() } + return true + } if (quickPressedBy == pointer) { val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y) quickPressedBy = -1 @@ -582,6 +628,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va } private fun isVisible(control: Control): Boolean = when { + !editing && control.id == "guide" && !settings.steamButton -> false + !editing && control.id == "qam" && !settings.qamButton -> false buttonsOnly && !editing -> control.id == "guide" || control.id == "qam" else -> control.target != ControllerPrefs.OFF } @@ -644,6 +692,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va } fun releaseAll() { + quickPressedBy = -1 + keyboardPressedBy = -1 + invalidate() if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return controls.forEach { it.pressedBy = -1 diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt index a533a2f2..fcf79bd6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -1,12 +1,14 @@ package com.droiddeck.launcher.session import android.net.LocalServerSocket +import android.content.SharedPreferences import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.util.Log import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.input.ControllerPrefs import java.io.DataInputStream import kotlin.math.max @@ -21,9 +23,25 @@ import kotlin.math.max */ class RumbleComponent : SessionPart() { @Volatile private var server: LocalServerSocket? = null + private var vibrator: Vibrator? = null + private var preferences: SharedPreferences? = null + private var enabled = false + private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key == "rumble") refreshEnabled() + } + + @Synchronized private fun refreshEnabled() { + enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true + if (!enabled) vibrator?.cancel() + } override fun start() { + val ctx = app() ?: return val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return } + vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator + else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java) + preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) } + refreshEnabled() server = s Thread({ while (server === s) { @@ -43,18 +61,22 @@ class RumbleComponent : SessionPart() { Log.i(TAG, "rumble: listening on @$NAME") } - override fun stop() { - val s = server ?: return + @Synchronized override fun stop() { + preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener) + preferences = null + enabled = false + vibrator?.cancel() + vibrator = null + val s = server server = null - try { s.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ } + try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ } } private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8) - private fun buzz(strength: Int, ms: Int) { - val ctx = app() ?: return - val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return - else @Suppress("DEPRECATION") (ctx.getSystemService(Vibrator::class.java) ?: return) + @Synchronized private fun buzz(strength: Int, ms: Int) { + if (!enabled) return + val vibrator = vibrator ?: return if (!vibrator.hasVibrator()) return if (strength == 0 || ms == 0) { vibrator.cancel(); return } val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index d863ece3..20da7e67 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -43,6 +43,7 @@ object SessionFiles { "usr/local/bin/bannerlator-login1" to "usr/local/bin/bannerlator-login1", "usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch", "usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games", + "usr/local/bin/bannerlator-desktop-bookmarks" to "usr/local/bin/bannerlator-desktop-bookmarks", "usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim", "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", "usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 8ebb20f1..0a7e760a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -30,6 +30,12 @@ object SessionPrefs { prefs(context).edit().putBoolean("launcherFullscreen", on).apply() } + fun animationsEnabled(context: Context): Boolean = prefs(context).getBoolean("animations", true) + + fun setAnimationsEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("animations", on).apply() + } + /** The Flathub Store (beta): its rail item. Off by default. */ fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index 1456e562..db6f1bbc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -24,6 +24,10 @@ class ControllerActions( val onSize: (Int) -> Unit, val onStickClick: (Boolean) -> Unit, val onAdaptiveSticks: (Boolean) -> Unit, + val onRumble: (Boolean) -> Unit, + val onSteamButton: (Boolean) -> Unit, + val onQamButton: (Boolean) -> Unit, + val onKeyboardButton: (Boolean) -> Unit, val onEditLayout: () -> Unit, val onResetLayout: () -> Unit, val onMapping: () -> Unit, @@ -62,6 +66,10 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre ChoiceRow(host, "controller-size", stringResource(R.string.ctrl_size), stringResource(R.string.ctrl_size_hint), ControllerPrefs.sizes.map { it to stringResource(R.string.ctrl_percent, it) }, c.size, onPick = a.onSize) ToggleRow(host, "controller-stick-click", stringResource(R.string.ctrl_stick_click), stringResource(R.string.ctrl_stick_click_hint), c.stickClick, onChange = a.onStickClick) ToggleRow(host, "controller-adaptive", stringResource(R.string.ctrl_adaptive), stringResource(R.string.ctrl_adaptive_hint), c.adaptiveSticks, onChange = a.onAdaptiveSticks) + ToggleRow(host, "controller-rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, onChange = a.onRumble) + ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton) + ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton) + ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton) SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt index 2c4b2da3..eb74f6c2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FocusGlide.kt @@ -179,7 +179,7 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable var width by remember { mutableStateOf(2.dp) } if (cur != null) width = cur.width - LaunchedEffect(glide) { + LaunchedEffect(glide, Motion.scale) { var shown: GlideSource? = null var movedAt = 0L var dirSpecs: List> = List(4) { follow() } @@ -235,6 +235,20 @@ internal fun FocusGlideHost(modifier: Modifier = Modifier, content: @Composable jobs[4] = scope.launch { if (snap) corner.snapTo(r) else corner.animateTo(r, Motion.sp(0.8f, 500f)) } } snapshotFlow { Triple(glide.current, glide.moves, glide.hidden || RingVeil.count > 0) }.collectLatest { (src, _, hidden) -> + if (Motion.scale == 0f) { + jobs.forEach { it?.cancel() } + solid.snapTo(0f) + val b = src?.let { glide.boundsOf(it) } + if (hidden || b == null) alpha.snapTo(0f) + else { + listOf(b.left, b.top, b.right, b.bottom).forEachIndexed { i, v -> edges[i].snapTo(v) } + val size = src.coords?.size?.let { Size(it.width.toFloat(), it.height.toFloat()) } ?: b.size + corner.snapTo(cornerOf(src.shape, size, dir, density)) + alpha.snapTo(1f) + } + shown = src + return@collectLatest + } if (hidden) { jobs.forEach { it?.cancel() } alpha.snapTo(0f) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 7e4c4341..b6028777 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -160,7 +160,7 @@ internal fun Pane( else { // Keeps a leaving page on screen while its flood draws back into the control. transition.animateFloat( - transitionSpec = { if (targetState == EnterExitState.PostExit) tween(PAGE_RETURN_MS) else snap() }, + transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() }, label = "pageReturn", ) { if (it == EnterExitState.PostExit) 1f else 0f } PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index a7ecdf4d..d8f8b227 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -121,6 +121,7 @@ class FrontEndState( val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE, val showPhantomGate: Boolean = false, val launcherFullscreen: Boolean = true, + val animationsEnabled: Boolean = true, /** The Flathub Store, a beta the user turns on in Setup. */ val storeEnabled: Boolean = false, /** The Updates page: DroidDeck's own builds and the channel followed. */ @@ -168,6 +169,7 @@ class FrontEndActions( val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, + val onAnimationsEnabled: (Boolean) -> Unit = {}, val onStoreEnabled: (Boolean) -> Unit = {}, val onHomeApp: () -> Unit = {}, val onHomeScreen: (Boolean) -> Unit = {}, @@ -188,10 +190,13 @@ class FrontEndActions( ) internal object Motion { - var scale = 1f - /** The system's animator scale, re-read on each resume so "Remove animations" applies without a restart. */ + var scale by mutableStateOf(1f) + private set + /** App animations can be disabled independently; Android's "Remove animations" always wins. */ fun refresh(context: android.content.Context) { - scale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + scale = if (SessionPrefs.animationsEnabled(context)) + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + else 0f } val Ease = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f) /** A wall-clock wait (a coroutine delay) scaled as the animations around it are. Compose already diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index dbed8dd2..c404dab6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -337,6 +337,8 @@ internal fun SetupPanel( if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), s.launcherFullscreen, ) { a.onLauncherFullscreen(it) } + ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, + s.animationsEnabled, onChange = a.onAnimationsEnabled) if (s.homeScreenEnabled) { ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt b/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt index 569cc8e1..799f93d7 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LaunchFlood.kt @@ -150,6 +150,12 @@ internal fun LaunchFlood( var page by remember { mutableStateOf(Size.Zero) } LaunchedEffect(page != Size.Zero) { if (page == Size.Zero) return@LaunchedEffect + if (Motion.scale == 0f) { + edges.forEach { it.snapTo(1f) } + progress(1f) + covered() + return@LaunchedEffect + } val travel = listOf(from.left, from.top, page.width - from.right, page.height - from.bottom).map { it.coerceAtLeast(0f) } val far = travel.max().coerceAtLeast(1f) coroutineScope { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt index 945b1c91..8bbc4b2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PcKeyboard.kt @@ -202,7 +202,7 @@ private fun RowScope.KeyCap( down -> pal.signal.copy(alpha = 0.55f) lit -> pal.signal.copy(alpha = if (locked) 0.45f else 0.28f) else -> Color.White.copy(alpha = 0.07f) - }, label = "keyFill", + }, Motion.tw(160), label = "keyFill", ) Box( contentAlignment = Alignment.Center, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 9df8e34e..752485ba 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -186,6 +186,11 @@ class DrawerActions( val onSteamMenu: (() -> Unit)?, val onQam: (() -> Unit)?, val onOsc: (String) -> Unit, + val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, + val onRumble: (Boolean) -> Unit = {}, + val onSteamButton: (Boolean) -> Unit = {}, + val onQamButton: (Boolean) -> Unit = {}, + val onKeyboardButton: (Boolean) -> Unit = {}, val onSuspendPolicy: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit, val onTouch: (String) -> Unit, @@ -375,6 +380,16 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)) else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)), a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc) + a.controller?.let { c -> + ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, + chipModifier = focus.track(page, "rumble"), onChange = a.onRumble) + ToggleRow(host, "steam-button", stringResource(R.string.ctrl_steam_button), null, c.steamButton, + chipModifier = focus.track(page, "steam-button"), onChange = a.onSteamButton) + ToggleRow(host, "qam-button", stringResource(R.string.ctrl_qam_button), null, c.qamButton, + chipModifier = focus.track(page, "qam-button"), onChange = a.onQamButton) + ToggleRow(host, "keyboard-button", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, + chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton) + } if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null, listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU), a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa3a04fd..8f50aa99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -739,6 +739,11 @@ Stick click Double-tap and hold a stick for L3 or R3 Adaptive sticks + Game vibration + Steam button + QAM button + Keyboard button + Animations Sticks appear where you touch and hide on release Layout Custom positions saved diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt index 1064ab2b..39821bb5 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt @@ -171,6 +171,70 @@ class OnScreenControlsTest { } private fun control(id: String): Any = (field(view, "controls") as List<*>).first { field(it!!, "id") == id }!! + + @Test fun keyboardShortcutWorksWithHiddenPadAndButtonsOnly() { + var opened = 0 + view = OnScreenControls(context, null, onKeyboard = { opened++ }) + view.layout(0, 0, 1200, 800) + view.setQuickHidden(true) + assertNotEquals(0, pixel(90f, 766f)) + touch(MotionEvent.ACTION_DOWN, 1 to (90f to 766f)) + assertEquals(0, opened) + touch(MotionEvent.ACTION_UP, 1 to (90f to 766f)) + assertEquals(1, opened) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 400f))) + view.setButtonsOnly(true) + touch(MotionEvent.ACTION_DOWN, 1 to (600f to 756f)) + touch(MotionEvent.ACTION_UP, 1 to (600f to 756f)) + assertEquals(2, opened) + assertEquals(-1, field(control("guide"), "pressedBy")) + } + + @Test fun cancelledDraggedOrDisabledKeyboardShortcutDoesNotOpen() { + var opened = 0 + view = OnScreenControls(context, null, onKeyboard = { opened++ }) + view.layout(0, 0, 1200, 800) + touch(MotionEvent.ACTION_DOWN, 1 to (90f to 766f)) + touch(MotionEvent.ACTION_CANCEL, 1 to (90f to 766f)) + touch(MotionEvent.ACTION_UP, 1 to (90f to 766f)) + assertEquals(0, opened) + touch(MotionEvent.ACTION_DOWN, 1 to (90f to 766f)) + touch(MotionEvent.ACTION_MOVE, 1 to (180f to 766f)) + touch(MotionEvent.ACTION_UP, 1 to (180f to 766f)) + assertEquals(0, opened) + ControllerPrefs.setKeyboardButton(context, false) + view.reload() + view.setQuickHidden(true) + assertEquals(0, pixel(90f, 766f)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (90f to 766f))) + } + + @Test fun hidingSystemButtonsRemovesHitTargetsInBothOverlayModes() { + for (buttonsOnly in listOf(false, true)) { + ControllerPrefs.setSteamButton(context, true) + ControllerPrefs.setQamButton(context, true) + view.reload() + view.setButtonsOnly(buttonsOnly) + val guide = control("guide") + val qam = control("qam") + val gx = value(guide, "cx"); val gy = value(guide, "cy") + val qx = value(qam, "cx"); val qy = value(qam, "cy") + assertNotEquals(0, pixel(gx, gy)) + assertNotEquals(0, pixel(qx, qy)) + touch(MotionEvent.ACTION_DOWN, 1 to (gx to gy)) + ControllerPrefs.setSteamButton(context, false) + view.reload() + assertEquals(-1, field(guide, "pressedBy")) + assertEquals(0, pixel(gx, gy)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (gx to gy))) + assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (qx to qy))) + touch(MotionEvent.ACTION_UP, 1 to (qx to qy)) + ControllerPrefs.setQamButton(context, false) + view.reload() + assertEquals(0, pixel(qx, qy)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (qx to qy))) + } + } private fun field(owner: Any, name: String): Any = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner)!! private fun value(owner: Any, name: String) = field(owner, name) as Float diff --git a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt new file mode 100644 index 00000000..f61e8605 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt @@ -0,0 +1,83 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.os.Build +import android.os.Looper +import android.os.Vibrator +import android.os.VibratorManager +import com.droiddeck.launcher.input.ControllerPrefs +import java.io.IOException +import org.junit.After +import org.junit.Before +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28, 31], manifest = Config.NONE, shadows = [RumbleComponentTest.NoPacketsSocket::class]) +class RumbleComponentTest { + private lateinit var context: Context + private lateinit var vibrator: Vibrator + private lateinit var rumble: RumbleComponent + + @Before fun startListener() { + context = RuntimeEnvironment.getApplication() + ControllerPrefs.prefs(context).edit().clear().commit() + vibrator = if (Build.VERSION.SDK_INT >= 31) context.getSystemService(VibratorManager::class.java).defaultVibrator + else context.getSystemService(Vibrator::class.java) + shadowOf(vibrator).setHasVibrator(true) + rumble = RumbleComponent().also { it.attach(context); it.start() } + } + + @After fun stopListener() { rumble.stop() } + + @Test fun disablingCancelsCurrentEffectAndDropsSubsequentEffectsUntilEnabled() { + effect() + assertTrue(shadowOf(vibrator).isVibrating) + ControllerPrefs.setRumble(context, false) + shadowOf(Looper.getMainLooper()).idle() + assertFalse(shadowOf(vibrator).isVibrating) + effect() + assertFalse(shadowOf(vibrator).isVibrating) + ControllerPrefs.setRumble(context, true) + shadowOf(Looper.getMainLooper()).idle() + effect() + assertTrue(shadowOf(vibrator).isVibrating) + rumble.stop() + assertFalse(shadowOf(vibrator).isVibrating) + effect() + assertFalse(shadowOf(vibrator).isVibrating) + } + + @Test fun resettingControllerRestoresRumbleInAnExistingSession() { + ControllerPrefs.setRumble(context, false) + shadowOf(Looper.getMainLooper()).idle() + effect() + assertFalse(shadowOf(vibrator).isVibrating) + ControllerPrefs.resetAll(context) + shadowOf(Looper.getMainLooper()).idle() + effect() + assertTrue(shadowOf(vibrator).isVibrating) + } + + private fun effect() { + // Inject a decoded force-feedback packet; transport is outside this preference test. + RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(rumble, 65535, 5000) + } + + @Implements(LocalServerSocket::class) + class NoPacketsSocket { + @Implementation fun __constructor__(name: String) = Unit + @Implementation fun accept(): LocalSocket = throw IOException("No incoming packets") + @Implementation fun close() = Unit + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/MotionTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/MotionTest.kt new file mode 100644 index 00000000..b16d89ef --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/MotionTest.kt @@ -0,0 +1,43 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import android.provider.Settings +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.* +import org.junit.Test +import org.junit.After +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class MotionTest { + @After fun restoreMotion() { + val context = RuntimeEnvironment.getApplication() as Context + SessionPrefs.setAnimationsEnabled(context, true) + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + Motion.refresh(context) + } + + @Test fun appToggleAppliesImmediatelyAndCannotOverrideSystemReducedMotion() { + val context = RuntimeEnvironment.getApplication() as Context + context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit() + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.5f) + Motion.refresh(context) + assertEquals(1.5f, Motion.scale, 0f) + SessionPrefs.setAnimationsEnabled(context, false) + Motion.refresh(context) + assertEquals(0f, Motion.scale, 0f) + assertEquals(0, Motion.ms(300)) + // The app's toggle never writes Android's preference. + assertEquals(1.5f, Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE), 0f) + SessionPrefs.setAnimationsEnabled(context, true) + Motion.refresh(context) + assertEquals(1.5f, Motion.scale, 0f) + Settings.Global.putFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + Motion.refresh(context) + assertEquals(0f, Motion.scale, 0f) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks new file mode 100755 index 00000000..0172e12f --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Expose the selected secondary library in PCManFM's Places sidebar.""" +import os +from pathlib import Path +import sys +import tempfile + + +LIBRARY = Path("/mnt/droiddeck-sd") +URI = "file:///mnt/droiddeck-sd" + + +def bookmarks_file(home, config): + # libfm-qt reads GTK3 bookmarks first, falling back to the legacy file when empty. + current = config / "gtk-3.0/bookmarks" + legacy = home / ".gtk-bookmarks" + if current.is_file() and current.read_text(errors="surrogateescape").strip(): + return current + return legacy if legacy.is_file() else current + + +def update_bookmark(path, label): + original = path.read_text(errors="surrogateescape") if path.is_file() else "" + lines = [line for line in original.splitlines(keepends=True) + if line.strip().split(" ", 1)[0] != URI] + content = "".join(lines) + if label is not None: + label = " ".join(label.split()) or "Secondary library" + if content and not content.endswith("\n"): + content += "\n" + content += URI + " " + label + "\n" + if content == original: + return + path.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", errors="surrogateescape", + dir=path.parent, delete=False) as stream: + temporary = stream.name + stream.write(content) + os.replace(temporary, path) + finally: + if temporary and os.path.exists(temporary): + os.unlink(temporary) + + +def main(): + home = Path.home() + config = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") + label = os.environ.get("BL_LIBRARY_LABEL") + # An unbound mount-point directory may remain from a previous session. + available = bool(label) and (LIBRARY / "steamapps").is_dir() + update_bookmark(bookmarks_file(home, config), label if available else None) + + +if __name__ == "__main__": + try: + main() + except OSError as error: + print("== desktop library bookmark: " + str(error), file=sys.stderr) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 3f036e91..d3f93b8e 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -92,6 +92,9 @@ export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" +if [ "$mode" = lxqt ] || [ "$mode" = desktop ]; then + /usr/local/bin/bannerlator-desktop-bookmarks || true +fi # Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the # user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop. export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap diff --git a/tools/tests/test_desktop_bookmarks.py b/tools/tests/test_desktop_bookmarks.py new file mode 100644 index 00000000..366a8939 --- /dev/null +++ b/tools/tests/test_desktop_bookmarks.py @@ -0,0 +1,69 @@ +import os +from pathlib import Path +import runpy +import tempfile +import unittest +from unittest.mock import patch + + +HELPER = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks" +MODULE = runpy.run_path(str(HELPER)) + + +class DesktopBookmarksTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.home = Path(temporary.name) + self.config = self.home / ".config" + self.bookmarks = self.config / "gtk-3.0/bookmarks" + + def test_add_and_update_preserve_user_bookmarks_without_duplicates(self): + self.bookmarks.parent.mkdir(parents=True) + self.bookmarks.write_text("file:///root/Documents My Documents\nfile:///root/Downloads Downloads") + MODULE["update_bookmark"](self.bookmarks, "SD Card") + expected = "file:///root/Documents My Documents\nfile:///root/Downloads Downloads\nfile:///mnt/droiddeck-sd SD Card\n" + self.assertEqual(self.bookmarks.read_text(), expected) + before = self.bookmarks.stat().st_mtime_ns + MODULE["update_bookmark"](self.bookmarks, "SD Card") + self.assertEqual(self.bookmarks.stat().st_mtime_ns, before) + MODULE["update_bookmark"](self.bookmarks, "Games\nfile:///unexpected Injected") + self.assertEqual(len(self.bookmarks.read_text().splitlines()), 3) + self.assertIn("file:///mnt/droiddeck-sd Games file:///unexpected Injected\n", self.bookmarks.read_text()) + + def test_removing_unavailable_library_keeps_personal_bookmarks(self): + MODULE["update_bookmark"](self.bookmarks, "SD Card") + with self.bookmarks.open("a") as stream: + stream.write("file:///root/Documents My Documents\n") + MODULE["update_bookmark"](self.bookmarks, None) + self.assertEqual(self.bookmarks.read_text(), "file:///root/Documents My Documents\n") + absent = self.home / "missing/bookmarks" + MODULE["update_bookmark"](absent, None) + self.assertFalse(absent.exists()) + + def test_file_selection_matches_libfm_legacy_fallback(self): + legacy = self.home / ".gtk-bookmarks" + legacy.write_text("file:///root/Documents My Documents\n") + self.assertEqual(MODULE["bookmarks_file"](self.home, self.config), legacy) + self.bookmarks.parent.mkdir(parents=True) + self.bookmarks.touch() + self.assertEqual(MODULE["bookmarks_file"](self.home, self.config), legacy) + self.bookmarks.write_text("file:///root/Downloads Downloads\n") + self.assertEqual(MODULE["bookmarks_file"](self.home, self.config), self.bookmarks) + + def test_main_requires_selected_library_and_usable_bind(self): + library = self.home / "library" + library.mkdir() + with patch.object(Path, "home", return_value=self.home), patch.dict(os.environ, {"XDG_CONFIG_HOME": str(self.config), "BL_LIBRARY_LABEL": "SD Card"}), patch.dict(MODULE["main"].__globals__, {"LIBRARY": library}): + MODULE["main"]() + self.assertFalse(self.bookmarks.exists()) + (library / "steamapps").mkdir() + MODULE["main"]() + self.assertEqual(self.bookmarks.read_text(), "file:///mnt/droiddeck-sd SD Card\n") + del os.environ["BL_LIBRARY_LABEL"] + MODULE["main"]() + self.assertEqual(self.bookmarks.read_text(), "") + + +if __name__ == "__main__": + unittest.main()