Merge branch 'main' into add-non-steam-games

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-05 11:33:49 -04:00
commit e76b49f64a
26 files changed
+735 -92

No files matched your search

+2 -2
View File
@@ -79,8 +79,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 10
versionName "0.3.0"
versionCode 11
versionName "0.3.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -319,6 +319,7 @@ class MainActivity : ComponentActivity() {
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
@@ -492,6 +493,7 @@ class MainActivity : ComponentActivity() {
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
@@ -501,7 +503,7 @@ class MainActivity : ComponentActivity() {
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme) {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
@@ -528,6 +530,7 @@ class MainActivity : ComponentActivity() {
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
defaultHomeLabel = defaultHomeLabel,
@@ -668,6 +671,10 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
@@ -161,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
private var cursorSerial = 0
private var clientHidesCursor = false
/**
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
* at the screen edge, and so did the camera (#153).
*/
private var pointerLocked = false
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
updatePointerCapture()
}
}
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
private var cursorHotX by mutableStateOf(0)
private var cursorHotY by mutableStateOf(0)
@@ -270,10 +285,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
@@ -324,7 +341,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// below) and the session starts when it is in.
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
val root = FrameLayout(this)
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
// the root sees every one of them whichever view that is.
val root = object : FrameLayout(this) {
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
super.dispatchPointerCaptureChanged(hasCapture)
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
@@ -387,6 +413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener(firstFrameListener)
WaylandCompositor.setPointerLockListener(pointerLockListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
@@ -417,6 +444,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// this full-screen host and remain as a white veil after the drawer closes.
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -1327,6 +1356,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchGenericMotionEvent(event)
}
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
@@ -1677,6 +1710,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
val d = InputDevice.getDevice(id)
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
}
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
/**
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
*/
private fun onCapturedPointer(event: MotionEvent): Boolean {
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = event.x
var dy = event.y
for (i in 0 until event.historySize) {
dx += event.getHistoricalX(i)
dy += event.getHistoricalY(i)
}
WaylandCompositor.sendPointerDelta(dx, dy)
}
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
val button = when (event.actionButton) {
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
else -> PointerGestures.BTN_LEFT
}
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
}
MotionEvent.ACTION_SCROLL -> {
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
}
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
else -> return false
}
return true
}
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
/** Which controller the session has, so a pad report says what was plugged in and when. */
private fun logInputDevice(what: String, deviceId: Int) {
@@ -1848,6 +1930,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
@@ -1867,6 +1950,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePointerCapture()
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
@@ -1887,6 +1971,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
goFullscreen()
refreshClipboard()
}
// Android drops a capture with the focus; a lock still held takes it again on return.
updatePointerCapture()
}
/** The keyboard/trackpad presentation can own focus instead of the main display. */
@@ -1937,6 +2023,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
WaylandCompositor.clearPointerLockListener(pointerLockListener)
super.onDestroy()
}
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.core
import android.content.Context
object AppUiPrefs {
const val DEFAULT_SCALE = 100
val scales = listOf(75, 85, 100, 115, 125, 150)
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
fun scale(context: Context): Int = normalizeScale(
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
)
fun setScale(context: Context, percent: Int) {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
.putInt("scale", normalizeScale(percent)).apply()
}
}
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(initialDir = initialDir)
}
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
?.let { File(it) }?.takeIf { it.isDirectory }
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(
pickMode = true,
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.input;
import java.io.File;
/**
* Where the fake input rings live and how the guest is told about them. Kept apart from
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
*/
final class FakeInputRingPaths {
static final String RING_DIR_NAME = "fakeinput-rings";
private FakeInputRingPaths() {}
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
static File ringDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
static File ringFile(File fakeInputDir, int slot) {
File ringDir = ringDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
/**
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
* the export has to use that same spelling. A canonical path is not that: where
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
*/
static String exportPath(File ringFile) {
return ringFile.getAbsolutePath();
}
}
@@ -94,7 +94,6 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
@@ -198,26 +197,8 @@ public class FakeInputWriter {
return builder.toString();
}
private static File getRingDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
private static File getRingFile(File fakeInputDir, int slot) {
File ringDir = getRingDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
private static String getCanonicalOrAbsolutePath(File file) {
try {
return file.getCanonicalPath();
} catch (IOException e) {
return file.getAbsolutePath();
}
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
@@ -325,7 +306,7 @@ public class FakeInputWriter {
ringSlot.ringFile = ringFile.getAbsoluteFile();
ringSlot.ringRaf = raf;
ringSlot.ringChannel = channel;
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
return ringSlot;
} catch (IOException e) {
@@ -76,6 +76,17 @@ public final class PadBridge {
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
/**
* The Android input device id of the physical controller that last drove the pad, or
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
* on that slot finds the motors in the player's hands (RumbleComponent).
*/
private static volatile int activeControllerId = NO_CONTROLLER;
public static int activeControllerId() { return activeControllerId; }
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -90,6 +101,7 @@ public final class PadBridge {
}
public synchronized void stop() {
activeControllerId = NO_CONTROLLER;
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
@@ -218,6 +230,7 @@ public final class PadBridge {
*/
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statOnScreen++;
scheduleStats();
@@ -299,6 +312,7 @@ public final class PadBridge {
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
activeControllerId = device.getId();
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
@@ -14,7 +14,7 @@ import kotlin.math.abs
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
* names its supply `battery` and lays it out differently, and on many phones an app may not read
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
private var thread: Thread? = null
override fun start() {
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
File(dir, "BAT0").deleteRecursively()
running = true
write()
thread = Thread({
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
if (running) write()
}
}, "battery-sysfs").apply { isDaemon = true; start() }
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
}
override fun stop() {
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
pct <= 15 -> "Low"
else -> "Normal"
}
for (name in listOf("BAT0", "BAT1")) {
for (name in listOf("BAT1")) {
val bat = File(dir, name).apply { mkdirs() }
val attrs = linkedMapOf(
"type" to "Battery",
@@ -3,43 +3,58 @@ package com.droiddeck.launcher.session
import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.InputDevice
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.DataInputStream
import kotlin.math.max
/**
* Rumble for the on-screen pad, on the phone itself.
* Rumble for the pad, on the motors in the player's hands.
*
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
* a missing listener costs the guest nothing, and a bad packet is dropped.
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var vibrator: Vibrator? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == null || key == "rumble") refreshEnabled()
}
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
internal var controllerMotors: (Int) -> Motors? = ::lookupController
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) vibrator?.cancel()
if (!enabled) { playing?.cancel(); playing = null }
}
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
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
refreshEnabled()
server = s
@@ -49,8 +64,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
buzz(max(strong, weak), ms)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -65,8 +79,11 @@ class RumbleComponent : SessionPart() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
vibrator?.cancel()
vibrator = null
playing?.cancel()
playing = null
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
val s = server
server = null
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
@@ -74,24 +91,91 @@ class RumbleComponent : SessionPart() {
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
@Synchronized private fun buzz(strength: Int, ms: Int) {
@Synchronized private fun buzz(strong: Int, weak: 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)
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} catch (e: Exception) {
Log.w(TAG, "rumble: $e")
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
}
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
fun play(strong: Int, weak: Int, ms: Long)
fun cancel()
}
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
override val name = "the device's vibrator"
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
@RequiresApi(31)
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) {
val combined = CombinedVibration.startParallel()
var any = false
fun add(id: Int, strength: Int) {
if (strength == 0) return
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
any = true
}
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
if (any) manager.vibrate(combined.combine()) else manager.cancel()
}
override fun cancel() = manager.cancel()
}
/** Before Android 12 a pad has one vibrator. */
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
companion object {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
else VibrationEffect.DEFAULT_AMPLITUDE
return VibrationEffect.createOneShot(ms, amplitude)
}
private fun lookupController(id: Int): Motors? {
val device = InputDevice.getDevice(id) ?: return null
val name = "\"${device.name}\""
if (Build.VERSION.SDK_INT >= 31) {
val manager = device.vibratorManager
val ids = manager.vibratorIds.sortedArray()
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
}
@Suppress("DEPRECATION")
val vibrator = device.vibrator
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
}
}
}
@@ -0,0 +1,23 @@
package com.droiddeck.launcher.ui
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import com.droiddeck.launcher.core.AppUiPrefs
import kotlin.math.roundToInt
internal fun scaledAppDensity(base: Density, percent: Int): Density {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
return if (scale == 1f) base else Density(base.density * scale, base.fontScale)
}
/** Responsive layouts and popup height limits must see the same dp space as the scaled UI. */
internal fun scaledAppConfiguration(base: Configuration, percent: Int): Configuration {
val scale = AppUiPrefs.normalizeScale(percent) / 100f
if (scale == 1f) return base
return Configuration(base).apply {
if (densityDpi > 0) densityDpi = (densityDpi * scale).roundToInt()
if (screenWidthDp > 0) screenWidthDp = (screenWidthDp / scale).roundToInt()
if (screenHeightDp > 0) screenHeightDp = (screenHeightDp / scale).roundToInt()
if (smallestScreenWidthDp > 0) smallestScreenWidthDp = (smallestScreenWidthDp / scale).roundToInt()
}
}
@@ -303,7 +303,7 @@ private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Uni
}
}
/** A page's title, with room at its right for a status chip. */
/** A page's title, with room at its right for controls or status. */
@Composable
internal fun PageHeader(title: String, trailing: @Composable RowScope.() -> Unit = {}) {
Row(
@@ -101,6 +101,7 @@ class FrontEndState(
val gameSyncFolder: String? = null,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val appScale: Int = com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE,
val isHomeApp: Boolean = false,
val homeScreenEnabled: Boolean = false,
val defaultHomeLabel: String? = null,
@@ -171,6 +172,7 @@ class FrontEndActions(
val onOffline: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onAppScale: (Int) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
val onAnimationsEnabled: (Boolean) -> Unit = {},
val onStoreEnabled: (Boolean) -> Unit = {},
@@ -162,8 +162,6 @@ internal fun SetupPanel(
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
@@ -187,9 +185,8 @@ internal fun SetupPanel(
),
) {
PageHeader(stringResource(R.string.setup_title)) {
Chip(if (readyCount == checks) stringResource(R.string.setup_all_set) else stringResource(R.string.setup_n_ready, readyCount, checks), ok = readyCount == checks)
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
}
TabStrip(tabs, tab, pick, Modifier.padding(bottom = 4.dp), tabFocus)
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
when (tab) {
0 -> {
@@ -314,6 +311,15 @@ internal fun SetupPanel(
}
3 -> {
SettingsGroup(stringResource(R.string.setup_launcher)) {
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
percent to stringResource(
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
else R.string.ctrl_percent, percent,
)
}, s.appScale, onPick = a.onAppScale,
)
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
Box {
ValueChip(Themes.byId(s.theme).label, host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
@@ -155,7 +155,7 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
private enum class SteamSettingsTab(val label: Int) {
private enum class ModeSettingsTab(val label: Int) {
DISPLAY(R.string.drawer_page_display),
CONTROLS(R.string.drawer_page_controls),
GAMES(R.string.game_settings_title),
@@ -171,34 +171,36 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
val tabs = SteamSettingsTab.entries
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
val tab = tabs[tabIndex]
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
val tabFocus = remember { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
fun selectTab(index: Int) { host.open = null; tabIndex = index }
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
androidx.compose.runtime.withFrameNanos { }
runCatching {
if (!steam || tab == SteamSettingsTab.DISPLAY) firstChip.requestFocus()
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
else tabFocus[tabIndex].requestFocus()
}
}
SettingsPage(
host,
title = if (steam) stringResource(R.string.mode_steam_title) else stringResource(R.string.mode_desktop_title),
title = null,
onBack = a.onDismiss,
scroll = scrolls[tabIndex],
modifier = if (steam) Modifier.bumpers(
modifier = Modifier.bumpers(
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
onNext = { selectTab((tabIndex + 1) % tabs.size) },
) else Modifier,
header = if (steam) ({
),
header = {
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), focusRequesters = tabFocus)
}) else null,
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
},
) {
if (!steam || tab == SteamSettingsTab.DISPLAY) {
if (tab == ModeSettingsTab.DISPLAY) {
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
SettingsGroup(stringResource(R.string.display_session)) {
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
@@ -241,7 +243,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if (!steam || tab == SteamSettingsTab.CONTROLS) {
if (tab == ModeSettingsTab.CONTROLS) {
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
ChoiceRow(
host, "touch", stringResource(R.string.mode_touch), null,
@@ -277,7 +279,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (!steam || tab == SteamSettingsTab.SESSION) {
if (tab == ModeSettingsTab.SESSION) {
SettingsGroup(stringResource(R.string.mode_session)) {
ChoiceRow(
host, "suspend", stringResource(R.string.mode_suspend),
@@ -304,7 +306,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && tab == SteamSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
s.deckyInstalled != s.deckyLatestRelease.tag
val status = when {
@@ -363,7 +365,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && tab == SteamSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
@@ -389,7 +391,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
}
if (steam && tab == SteamSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
@@ -408,11 +410,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
}
}
}
if ((!steam || tab == SteamSettingsTab.GAMES) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
if ((tab == ModeSettingsTab.GAMES || (!steam && tab == ModeSettingsTab.DISPLAY)) && s.forceFullscreen != null) SettingsGroup(stringResource(R.string.display_window_compatibility)) {
ToggleRow(host, "fill", stringResource(R.string.display_force_fullscreen),
stringResource(R.string.display_force_fullscreen_hint), s.forceFullscreen, onChange = a.onForceFullscreen)
}
if (steam && tab == SteamSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
if (steam && tab == ModeSettingsTab.GAMES && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
@@ -437,7 +439,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && tab == SteamSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
if (s.syncBackend != null) SettingsRow(
stringResource(R.string.sync_backend_title),
stringResource(R.string.sync_backend_hint),
@@ -460,7 +462,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
GameEnvironmentRow()
}
if (steam && tab == SteamSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
ChoiceRow(
host, "clientAudio", stringResource(R.string.mode_client_audio), stringResource(R.string.mode_client_audio_hint),
@@ -469,7 +471,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
}
if (steam && tab == SteamSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.second == s.gameStorage }
val options = buildList {
add("" to (if (s.storageOptions.isEmpty()) stringResource(R.string.mode_storage_auto_none) else stringResource(R.string.mode_storage_auto)))
@@ -505,7 +507,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onChange = a.onStorageDiagnostics,
)
}
if (!steam && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
if (!steam && tab == ModeSettingsTab.DISPLAY && s.renderer != null) SettingsGroup(stringResource(R.string.mode_renderer)) {
ChoiceRow(
host, "renderer", stringResource(R.string.mode_desktop_renderer), stringResource(R.string.mode_renderer_hint),
listOf("vulkan" to stringResource(R.string.mode_renderer_vulkan), "gles2" to stringResource(R.string.mode_renderer_gles2), "pixman" to stringResource(R.string.mode_renderer_pixman)), s.renderer,
@@ -308,7 +308,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
}
if (open || veil > 0.01f) Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
modifier = Modifier.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { host.open = null; a.onClose() },
)
androidx.compose.foundation.layout.BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
@@ -598,7 +598,7 @@ fun ActionRow(label: String, hint: String?, button: String, onClick: () -> Unit)
@Composable
fun SettingsPage(
host: MenuHost,
title: String,
title: String?,
onBack: () -> Unit,
eyebrow: String? = null,
lede: String? = null,
@@ -608,7 +608,7 @@ fun SettingsPage(
scroll: androidx.compose.foundation.ScrollState? = null,
scrollContent: Boolean = true,
compactLayout: Boolean = false,
/** Controls that remain below the title while the settings scroll, such as a tab strip. */
/** Fixed controls below the title, or beside Back when the title is omitted. */
header: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
@@ -624,7 +624,18 @@ fun SettingsPage(
),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 2.dp else 0.dp),
) {
if (narrow) {
if (title == null) {
Rise(0) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
BackLink(stringResource(R.string.mode_back), compact = true, onClick = onBack)
if (header != null) Box(Modifier.weight(1f)) { header() }
}
}
} else if (narrow) {
// Back and the title share one line: a 4:3 screen has no height to spare.
Rise(0) {
Row(
@@ -683,7 +694,7 @@ fun SettingsPage(
}
}
}
if (header != null) header()
if (header != null && title != null) header()
Rise(3, Modifier.weight(1f).fillMaxWidth()) {
Column(
modifier = Modifier
@@ -5,9 +5,13 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import com.droiddeck.launcher.core.AppUiPrefs
import com.droiddeck.launcher.session.SessionPrefs
class Palette(
@@ -67,7 +71,7 @@ val LocalPalette = staticCompositionLocalOf { Themes.byId(Themes.GRAPHITE) }
val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03111F) else Color.White
@Composable
fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
val id = theme ?: SessionPrefs.theme(LocalContext.current)
val p = Themes.byId(id)
val scheme = darkColorScheme(
@@ -77,5 +81,13 @@ fun DroidDeckTheme(theme: String? = null, content: @Composable () -> Unit) {
surfaceVariant = p.surfaceVariant, onSurfaceVariant = p.onSurfaceVariant,
error = p.error,
)
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, content = content) }
val baseDensity = LocalDensity.current
val baseConfiguration = LocalConfiguration.current
val density = remember(baseDensity, appScale) { scaledAppDensity(baseDensity, appScale) }
val configuration = remember(baseConfiguration, appScale) { scaledAppConfiguration(baseConfiguration, appScale) }
CompositionLocalProvider(
LocalPalette provides p,
LocalDensity provides density,
LocalConfiguration provides configuration,
) { MaterialTheme(colorScheme = scheme, content = content) }
}
@@ -115,6 +115,11 @@ public final class WaylandCompositor {
public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; }
/** Unhooks {@code l} only if it is still the listener, so a finishing activity never unhooks its successor's. */
public static void clearPointerLockListener(PointerLockListener l) {
if (pointerLockListener == l) pointerLockListener = null;
}
/** Invoked from native (droiddeck_on_pointer_lock). */
@SuppressWarnings("unused")
static void onPointerLock(boolean locked, int x, int y) {
@@ -500,4 +505,11 @@ public final class WaylandCompositor {
if (dx == 0 && dy == 0) return;
nativeSendSceneInput(6, dx * 256, dy * 256);
}
/** As {@link #sendPointerDelta(int, int)}, keeping a captured mouse's fractions of a pixel. */
public static void sendPointerDelta(float dx, float dy) {
int x = Math.round(dx * 256f), y = Math.round(dy * 256f);
if (x == 0 && y == 0) return;
nativeSendSceneInput(6, x, y);
}
}
+3
View File
@@ -595,6 +595,9 @@
<string name="setup_offline">Offline mode</string>
<string name="setup_sign_in_first">Sign in to Steam first</string>
<string name="setup_launcher">Launcher</string>
<string name="setup_app_scale">App scale</string>
<string name="setup_app_scale_hint">DroidDeck text and controls</string>
<string name="setup_app_scale_default">%1$d%% (Default)</string>
<string name="setup_theme">Theme</string>
<string name="setup_theme_hint">Launcher appearance</string>
<string name="setup_home">Use as Home screen</string>
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/**
* The ring paths handed to the guest (FAKE_EVDEV_MEMFD_PATHS) must keep the spelling the session
* tree is bound under. On ColorOS 16 /data/user/0 resolves to /data/data, the guest only has the
* first bound, and a canonical export left every pad dead (#166). A symlinked directory stands in
* for that here.
*/
class FakeInputRingPathTest {
private lateinit var base: File
private lateinit var real: File
private lateinit var link: File
@Before fun setUp() {
base = Files.createTempDirectory("ring-path").toFile()
real = File(base, "data-data").apply { mkdirs() }
link = File(base, "data-user-0")
Files.createSymbolicLink(link.toPath(), real.toPath())
}
@After fun tearDown() {
base.deleteRecursively()
}
@Test fun ringsSitBesideTheFakeInputNodes() {
val fakeInputDir = File(link, "files/session/dev/input")
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring2"), FakeInputRingPaths.ringFile(fakeInputDir, 2))
}
@Test fun theExportKeepsTheBoundSpellingThroughALinkedDataDirectory() {
val fakeInputDir = File(link, "files/session/dev/input").apply { mkdirs() }
val ring = FakeInputRingPaths.ringFile(fakeInputDir, 0)!!.apply { parentFile!!.mkdirs(); writeText("") }
// The link really does resolve elsewhere, as /data/user/0 does on the affected devices.
assertTrue(ring.canonicalPath.startsWith(real.canonicalPath + "/"))
val exported = FakeInputRingPaths.exportPath(ring)
assertEquals(File(link, "files/session/dev/fakeinput-rings/ring0").path, exported)
assertFalse("$exported resolved through the link", exported.startsWith(real.path + "/"))
assertTrue("$exported does not open", File(exported).isFile)
}
@Test fun aRelativeInputDirIsExportedAbsolute() {
val exported = FakeInputRingPaths.exportPath(FakeInputRingPaths.ringFile(File("session/dev/input"), 1)!!)
assertTrue(exported, File(exported).isAbsolute)
assertTrue(exported, exported.endsWith("/session/dev/fakeinput-rings/ring1"))
}
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.session
import android.content.Intent
import android.os.BatteryManager
import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class BatteryComponentTest {
@get:Rule val tmp = TemporaryFolder()
private var battery: BatteryComponent? = null
@After fun stop() { battery?.stop() }
@Test fun oneAggregateBatteryKeepsOverlayPowerAndLifetimeConsistentWithQam() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
start(supplies, vpower, -2_000_000L)
assertTelemetry(supplies, vpower)
}
@Test fun upgradingRemovesTheDuplicateRatherThanLeavingItForOverlayDiscovery() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
val legacy = File(supplies, "BAT0").apply { mkdirs() }
File(legacy, "power_now").writeText("8000000\n")
File(legacy, "charge_now").writeText("4000000\n")
start(supplies, vpower, 2_000_000L)
assertFalse(legacy.exists())
assertTelemetry(supplies, vpower)
}
private fun start(supplies: File, vpower: File, currentUa: Long) {
val context = RuntimeEnvironment.getApplication()
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
putExtra(BatteryManager.EXTRA_LEVEL, 50)
putExtra(BatteryManager.EXTRA_SCALE, 100)
putExtra(BatteryManager.EXTRA_VOLTAGE, 4000)
})
shadowOf(context.getSystemService(BatteryManager::class.java)).apply {
setLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER, 4_000_000L)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW, currentUa)
setLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE, currentUa)
}
battery = BatteryComponent(supplies, vpower).also { it.attach(context); it.start() }
}
private fun assertTelemetry(supplies: File, vpower: File) {
val discovered = supplies.listFiles()!!.filter { it.name.contains("BAT") }
assertEquals(listOf("BAT1"), discovered.map { it.name })
fun read(name: String) = File(discovered.single(), name).readText().trim().toLong()
// One 4 Ah battery at 4 V drawing 2 A: 8 W and two hours, regardless of vendor current sign.
assertEquals(8_000_000L, read("power_now"))
assertEquals(16_000_000L, read("energy_now"))
assertEquals(7200L, read("time_to_empty_now"))
assertEquals("7200", File(vpower, "secs_until_shutdown_request").readText().trim())
assertEquals("50.00", File(vpower, "battery_percent").readText().trim())
}
}
@@ -8,6 +8,7 @@ import android.os.Looper
import android.os.Vibrator
import android.os.VibratorManager
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.IOException
import org.junit.After
import org.junit.Before
@@ -68,12 +69,74 @@ class RumbleComponentTest {
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)
@Test fun aControllerWithMotorsGetsTheEffectInsteadOfThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { id -> if (id == PAD_ID) pad else null }
activeController(PAD_ID)
effect(strong = 40000, weak = 1000)
assertEquals(listOf(Triple(40000, 1000, 5000L)), pad.played)
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
}
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
rumble.controllerMotors = { null }
activeController(PAD_ID)
effect()
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun theOnScreenPadTakingOverMovesTheEffectBackToThePhone() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
assertTrue(pad.vibrating)
activeController(PadBridge.NO_CONTROLLER)
effect()
assertFalse("the controller kept rumbling after the switch", pad.vibrating)
assertTrue(shadowOf(vibrator).isVibrating)
}
@Test fun aStopPacketDisablingAndStoppingAllEndTheControllersEffect() {
val pad = FakeMotors()
rumble.controllerMotors = { pad }
activeController(PAD_ID)
effect()
effect(strong = 0, weak = 0)
assertFalse(pad.vibrating)
effect()
ControllerPrefs.setRumble(context, false)
shadowOf(Looper.getMainLooper()).idle()
assertFalse(pad.vibrating)
ControllerPrefs.setRumble(context, true)
shadowOf(Looper.getMainLooper()).idle()
effect()
rumble.stop()
assertFalse(pad.vibrating)
}
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
private fun activeController(id: Int) {
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
}
private fun effect(strong: Int = 65535, weak: Int = 65535) {
// Inject a decoded force-feedback packet; transport is outside these tests.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
}
private class FakeMotors : RumbleComponent.Motors {
override val name = "fake pad"
val played = mutableListOf<Triple<Int, Int, Long>>()
var vibrating = false
override fun play(strong: Int, weak: Int, ms: Long) { played += Triple(strong, weak, ms); vibrating = true }
override fun cancel() { vibrating = false }
}
private companion object { const val PAD_ID = 7 }
@Implements(LocalServerSocket::class)
class NoPacketsSocket {
@Implementation fun __constructor__(name: String) = Unit
@@ -0,0 +1,90 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.AppUiPrefs
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
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 AppScaleTest {
private val context get() = RuntimeEnvironment.getApplication()
@Before fun clearScale() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun defaultsToSystemDensityAndCanRestoreItAfterChoosingAnotherScale() {
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 75)
assertEquals(75, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 100)
assertEquals(100, AppUiPrefs.scale(context))
val density = Density(2f, 1.3f)
val configuration = Configuration(context.resources.configuration)
assertSame(density, scaledAppDensity(density, 100))
assertSame(configuration, scaledAppConfiguration(configuration, 100))
}
@Test fun invalidStoredOrRequestedScaleFallsBackToTheDefault() {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit().putInt("scale", 0).commit()
assertEquals(100, AppUiPrefs.scale(context))
AppUiPrefs.setScale(context, 1000)
assertEquals(100, AppUiPrefs.scale(context))
}
@Test fun scalingDoesNotModifySessionPreferencesOrAndroidDisplayMetrics() {
val session = context.getSharedPreferences("session", Context.MODE_PRIVATE)
session.edit().putString("resolution.steam", "1280x720").putInt("upscaler", 2).commit()
val before = session.all.toMap()
val density = context.resources.displayMetrics.density
val configuration = Configuration(context.resources.configuration)
AppUiPrefs.setScale(context, 150)
scaledAppConfiguration(context.resources.configuration, AppUiPrefs.scale(context))
assertEquals(before, session.all)
assertEquals(density, context.resources.displayMetrics.density, 0f)
assertEquals(configuration, context.resources.configuration)
}
@Test fun textAndControlsScaleTogetherWhilePreservingAccessibilityFontScale() {
val base = Density(2f, 1.3f)
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppDensity(base, percent)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.run { 44.dp.toPx() } * percent / 100f, scaled.run { 44.dp.toPx() }, 0.001f)
assertEquals(base.run { 16.sp.toPx() } * percent / 100f, scaled.run { 16.sp.toPx() }, 0.001f)
}
}
@Test fun responsiveWidthAndPopupHeightFitTheSamePhysicalWindowAtEveryScale() {
val base = Configuration().apply {
densityDpi = 320
screenWidthDp = 960
screenHeightDp = 540
smallestScreenWidthDp = 540
fontScale = 1.3f
orientation = Configuration.ORIENTATION_LANDSCAPE
}
for (percent in AppUiPrefs.scales) {
val scaled = scaledAppConfiguration(base, percent)
val density = scaledAppDensity(Density(2f, 1.3f), percent).density
assertEquals(1920f, scaled.screenWidthDp * density, 2f)
assertEquals(1080f, scaled.screenHeightDp * density, 2f)
assertEquals(1080f, scaled.smallestScreenWidthDp * density, 2f)
assertEquals(1.3f, scaled.fontScale, 0f)
assertEquals(base.orientation, scaled.orientation)
}
assertEquals(960, base.screenWidthDp)
assertEquals(540, base.screenHeightDp)
assertEquals(320, base.densityDpi)
}
}
+62
View File
@@ -0,0 +1,62 @@
# DroidDeck 0.3.1
### Steam
- Decky plugins can be imported from ZIP files, with remote binaries verified and installed copies of the same plugin replaced. (@xXJSONDeruloXx, [#79](https://github.com/Droid-Deck/DroidDeck/pull/79))
- Steam's Internet page shows Android Wi-Fi and cellular connections. Optional Wi-Fi discovery adds network names and nearby networks with Android's Location permission. (@xXJSONDeruloXx, [#150](https://github.com/Droid-Deck/DroidDeck/pull/150))
- Paste text from the Android clipboard into Steam sessions. (@xXJSONDeruloXx, [#151](https://github.com/Droid-Deck/DroidDeck/pull/151))
- The Proton selected in Components stays in sync with Steam's default compatibility tool, and runnable builds are marked **Compatible**. Windows titles with a Valve recommendation use that default unless you choose another compatible tool for the game. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#224](https://github.com/Droid-Deck/DroidDeck/pull/224))
### Compatibility and performance
- Choose **esync**, **fsync**, experimental **ntsync**, or **wineserver** for Proton games. These sync options work around Android's missing kernel support, with esync as the default for fresh installs and existing choices preserved. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197), [#223](https://github.com/Droid-Deck/DroidDeck/pull/223))
- droiddeck-esync packs are matched to the exact Valve, GE-Proton or CachyOS Proton build and fetched automatically. droiddeck-fsync stands in when a pack is not available. (@maxjivi05, [#189](https://github.com/Droid-Deck/DroidDeck/pull/189), [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Linux-only x86 Steam titles can run through FEX using Steam's FEX and Steam Linux Runtime, with controller input and the device's graphics drivers. Missing dependencies are requested through Steam. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- .NET and Godot games get automatic launch fixes under FEX, including the settings needed for Slay the Spire 2. Per-game overrides are kept. (@magarto, [#168](https://github.com/Droid-Deck/DroidDeck/pull/168))
### Display and effects
- Live image scaling with AMD FSR 1, Snapdragon GSR and GSR quality, NVIDIA NIS, linear and nearest filtering, plus sharpening. (@magarto, @The412Banner, [#128](https://github.com/Droid-Deck/DroidDeck/pull/128), [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- An **Effects** page adds presets, FXAA, Toon, CRT, NTSC and Fake HDR. Advanced controls cover sharpening, debanding, brightness, contrast, gamma and saturation, with choices saved between sessions. (@The412Banner, @xXJSONDeruloXx, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198), [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Anisotropic filtering and texture sharpness for DirectX 9–11 games, applied on the next game launch. (@The412Banner, [#198](https://github.com/Droid-Deck/DroidDeck/pull/198))
- One **Resolution** menu before starting a session, with preset dimensions, Custom and **Match screen**. Resolution, image scaling and fullscreen compatibility have separate controls. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
### Controls and sessions
- Standard Android picture-in-picture, opened manually or automatically when leaving a ready session, with second-screen controls restored when you return. (@xXJSONDeruloXx, [#156](https://github.com/Droid-Deck/DroidDeck/pull/156), [#195](https://github.com/Droid-Deck/DroidDeck/pull/195))
- A quick toggle hides or restores on-screen controls. **Touch Off** ignores touches on the game picture while keeping Android controls usable. (@xXJSONDeruloXx, [#147](https://github.com/Droid-Deck/DroidDeck/pull/147), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Optional game vibration and on-screen Steam, Quick Access and keyboard buttons. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- Steam session settings use tabs for Display, Controls, Games, Audio, Session and Steam. The session menu starts with Controller, gives second-screen modes their own page, and groups live display changes separately from settings for the next game launch. (@xXJSONDeruloXx, [#247](https://github.com/Droid-Deck/DroidDeck/pull/247))
- Compact Steam Home and Quick Access shortcuts beside Stop. Switching to Desktop stays in Steam's own menu. (@xXJSONDeruloXx, [#188](https://github.com/Droid-Deck/DroidDeck/pull/188), [#196](https://github.com/Droid-Deck/DroidDeck/pull/196))
- Back's double-press window is back to 500 ms, making Quick Access easier to open. (@xXJSONDeruloXx, [#193](https://github.com/Droid-Deck/DroidDeck/pull/193))
### Apps and launcher
- Android home-screen shortcuts for installed Steam games and added games. (@xXJSONDeruloXx, [#157](https://github.com/Droid-Deck/DroidDeck/pull/157))
- Export per-game `.droiddeck` launch files and Steam icons for Android frontends, with optional automatic folder sync as the library changes. (@xXJSONDeruloXx, [#179](https://github.com/Droid-Deck/DroidDeck/pull/179))
- Import x86 AppImages and run x86 Linux scripts and programs through FEX. Added apps have a **Use FEX** Auto/On/Off choice, and GitHub imports can use an x86_64 AppImage when no ARM64 build is available. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Vulkan Qt Quick AppImages such as OpenNOW use Wayland to avoid X11 startup failures. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
- Preview updates show recent published changes and how many builds behind you are when the installed Preview can be matched. (@xXJSONDeruloXx, [#159](https://github.com/Droid-Deck/DroidDeck/pull/159))
- A new empty-library intro and DroidDeck wordmark on the Steam page, plus an option to reduce app animations. (@xXJSONDeruloXx, [#182](https://github.com/Droid-Deck/DroidDeck/pull/182), [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
- The Linux file manager bookmarks the selected game library automatically. (@xXJSONDeruloXx, [#194](https://github.com/Droid-Deck/DroidDeck/pull/194))
### Languages and logs
- Simplified Chinese. (@Dantalian-dev, [#202](https://github.com/Droid-Deck/DroidDeck/pull/202))
- Japanese, Korean and Traditional Chinese for Taiwan, Hong Kong and Macau. (@starwishHi, [#216](https://github.com/Droid-Deck/DroidDeck/pull/216))
- Optional **Storage diagnostics** include allocation and storage timing in shared session logs to help investigate slow Steam installs. (@xXJSONDeruloXx, [#158](https://github.com/Droid-Deck/DroidDeck/pull/158))
- Session logs retain their final output and include more detail when an AppImage or FEX program fails to start or crashes. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
### Fixed
- Steam's Deck controller not being found on handhelds with older kernels. (@xXJSONDeruloXx, [#146](https://github.com/Droid-Deck/DroidDeck/pull/146))
- Physical and on-screen controllers doing nothing on ColorOS devices with linked app data directories. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Game vibration going to the phone instead of the active physical controller. The device vibrator remains the fallback for on-screen controls or controllers without motors. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- A physical mouse stopping camera movement at the screen edge. Games that request a pointer lock now use Android's relative pointer capture, released while menus or PiP are open. (@maxjivi05, [#242](https://github.com/Droid-Deck/DroidDeck/pull/242))
- Steam remaining on a black screen after idle sleep or Android session resume. (@xXJSONDeruloXx, @jmarti326, [#155](https://github.com/Droid-Deck/DroidDeck/pull/155), [#172](https://github.com/Droid-Deck/DroidDeck/pull/172))
- Steam stuck at **Waiting for Steam** after an interrupted client update. After two starts without a frame, DroidDeck fetches the client again while preserving games, settings and user data; **Repair Steam client** also queues a manual repair. (@maxjivi05, [#225](https://github.com/Droid-Deck/DroidDeck/pull/225))
- Play racing runtime removal. Removal now shows progress and verifies completion, and first-run setup respects the selected Steam channel and saved UI scaling. (@xXJSONDeruloXx, [#185](https://github.com/Droid-Deck/DroidDeck/pull/185))
- GE-Proton and CachyOS Proton failing their protonfixes checks because Android blocks `/proc/sys/fs/file-max`. (@magarto, [#169](https://github.com/Droid-Deck/DroidDeck/pull/169))
- DirectAudio not being selected correctly, mismatched FEX helpers surviving component swaps, and FEX or its runtime not being executable from an SD-card Steam library. (@maxjivi05, [#197](https://github.com/Droid-Deck/DroidDeck/pull/197))
[Full changelog](https://github.com/Droid-Deck/DroidDeck/compare/0.3.0...0.3.1)