mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Merge branch 'main' into add-non-steam-games
This commit is contained in:
26 files changed
+735
-92
No files matched your search
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user