mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Add controller and desktop quality-of-life options (#194)
This commit is contained in:
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user