Add controller and desktop quality-of-life options (#194)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-04 00:56:09 -04:00
1 parent 336b8851f7
commit 6f1fb462ed
22 files changed
+536 -19

No files matched your search

@@ -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 },
@@ -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<ControllerPrefs.Settings?>(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()
@@ -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<String, String>,
)
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<String, Pair<Float, Float>> {
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) {
@@ -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
@@ -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)
@@ -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",
@@ -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)
@@ -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() }
@@ -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<AnimationSpec<Float>> = 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)
@@ -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() }
@@ -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
@@ -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)
}
@@ -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 {
@@ -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,
@@ -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)
+5
View File
@@ -739,6 +739,11 @@
<string name="ctrl_stick_click">Stick click</string>
<string name="ctrl_stick_click_hint">Double-tap and hold a stick for L3 or R3</string>
<string name="ctrl_adaptive">Adaptive sticks</string>
<string name="ctrl_rumble">Game vibration</string>
<string name="ctrl_steam_button">Steam button</string>
<string name="ctrl_qam_button">QAM button</string>
<string name="ctrl_keyboard_button">Keyboard button</string>
<string name="setup_animations">Animations</string>
<string name="ctrl_adaptive_hint">Sticks appear where you touch and hide on release</string>
<string name="ctrl_layout">Layout</string>
<string name="ctrl_layout_custom">Custom positions saved</string>
@@ -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
@@ -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
}
}
@@ -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)
}
}
@@ -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)
@@ -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
+69
View File
@@ -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()