From 9c5f1e4df9e2693007c6c3bf69ee22383cc256c8 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:15:36 -0500
Subject: [PATCH 01/17] feat: HUD shrinks into its tab as it is dragged off an
edge, and snaps to the sides (#1937)
---
.../launcher/hud/DroidDeckHudConfig.kt | 2 +-
.../launcher/hud/DroidDeckHudHost.kt | 253 ++++++++++++++++--
.../launcher/hud/DroidDeckHudView.kt | 114 ++++++--
.../launcher/hud/HudLockController.java | 11 +
.../launcher/ui/DroidDeckHudSettings.kt | 2 +
app/src/main/res/values-ar/strings.xml | 2 +
app/src/main/res/values-b+es+419/strings.xml | 2 +
app/src/main/res/values-bg/strings.xml | 2 +
app/src/main/res/values-cs/strings.xml | 2 +
app/src/main/res/values-da/strings.xml | 2 +
app/src/main/res/values-de/strings.xml | 2 +
app/src/main/res/values-el/strings.xml | 2 +
app/src/main/res/values-es/strings.xml | 2 +
app/src/main/res/values-fi/strings.xml | 2 +
app/src/main/res/values-fr/strings.xml | 2 +
app/src/main/res/values-hi/strings.xml | 2 +
app/src/main/res/values-hu/strings.xml | 2 +
app/src/main/res/values-in/strings.xml | 2 +
app/src/main/res/values-it/strings.xml | 2 +
app/src/main/res/values-ja/strings.xml | 2 +
app/src/main/res/values-ko/strings.xml | 2 +
app/src/main/res/values-ms/strings.xml | 2 +
app/src/main/res/values-nb/strings.xml | 2 +
app/src/main/res/values-nl/strings.xml | 2 +
app/src/main/res/values-pl/strings.xml | 2 +
app/src/main/res/values-pt-rPT/strings.xml | 2 +
app/src/main/res/values-pt/strings.xml | 2 +
app/src/main/res/values-ro/strings.xml | 2 +
app/src/main/res/values-ru/strings.xml | 2 +
app/src/main/res/values-sv/strings.xml | 2 +
app/src/main/res/values-th/strings.xml | 2 +
app/src/main/res/values-tr/strings.xml | 2 +
app/src/main/res/values-uk/strings.xml | 2 +
app/src/main/res/values-vi/strings.xml | 2 +
app/src/main/res/values-zh-rCN/strings.xml | 2 +
app/src/main/res/values-zh-rHK/strings.xml | 2 +
app/src/main/res/values-zh-rTW/strings.xml | 2 +
app/src/main/res/values/strings.xml | 2 +
38 files changed, 400 insertions(+), 48 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
index e9c8f8be4..7a565cb95 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
@@ -10,7 +10,7 @@ internal object DroidDeckHudConfig {
* locked, so like the old HUD it lets touches through until it is unlocked to move it.
*/
val DEFAULTS: Map = linkedMapOf(
- "hudSize" to "minimal", "hudLocked" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid",
+ "hudSize" to "minimal", "hudLocked" to "1", "hudSnap" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid",
"hudOutline" to "40", "hudOutlineAccent" to "1",
"showFPS" to "1", "showFPSGraph" to "1", "showCPUUsage" to "1", "showCPULoad" to "1", "showGPULoad" to "1",
"showVram" to "1", "showRAM" to "1", "showPower" to "1", "showTemp" to "1", "showBatteryTemp" to "1",
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
index a43878ece..2784f4fa8 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
+import android.animation.ValueAnimator
import android.view.Choreographer
import android.view.View
import android.view.animation.PathInterpolator
@@ -35,6 +36,13 @@ class DroidDeckHudHost(context: Context) {
private var polling = false
private var wantX = Float.NaN
private var wantY = Float.NaN
+ // With snapping on, the side the HUD keeps to (0 = left, 1 = right), so it stays against that side when its
+ // size changes. NaN = not snapped.
+ private var anchorX = Float.NaN
+ private var snaps = true
+ private var snapping = false
+ private var previewFade: ValueAnimator? = null
+ private var flingX = 0f
private var engine = ""
private var gpuName = ""
private var protonPid = 0
@@ -86,12 +94,29 @@ class DroidDeckHudHost(context: Context) {
hud.setMangoReadings(readings)
hud.setGpuModel(cleanGpuName(GpuInfo.detect().displayName(context)))
hud.onRefresh = Runnable { refreshLabels(hud) }
+ hud.setOnDraggedListener { rawX, rawY ->
+ anchorX = Float.NaN; snapping = false
+ previewFade?.cancel(); previewFade = null
+ stopMotion(hud)
+ shrinkPastEdge(hud, rawX, rawY)
+ }
+ hud.setOnFlungListener { vx, _ -> flingX = vx }
+ hud.setOnTabDraggedListener { x, y -> dragTab(hud, x, y) }
+ hud.setOnTabDroppedListener { dropTab(hud) }
+ hud.setOnTabDragCancelledListener { snapping = false; keepOnScreen(hud) }
hud.setOnMovedListener { x, y ->
wantX = x; wantY = y
- if (!hud.isStrip) dockEdgeFor(hud)?.let { edge -> setDock(hud, edge); return@setOnMovedListener }
+ val vx = flingX
+ flingX = 0f
+ dockEdgeFor(hud)?.let { edge -> setDock(hud, edge); return@setOnMovedListener }
+ stopMotion(hud)
+ fadeDockPreview(hud)
+ if (snaps && !hud.isStrip) { snapToAnchor(hud, vx); return@setOnMovedListener }
+ val fromX = hud.x; val fromY = hud.y
keepOnScreen(hud)
wantX = hud.x; wantY = hud.y
SessionPrefs.setDroidDeckHudPosition(context, hud.x, hud.y)
+ glideFrom(hud, fromX, fromY)
}
hud.setOnSizeCycledListener { size -> DroidDeckHudConfig.set(context, "hudSize", size); onConfigChanged?.invoke() }
hud.setOnDockChangedListener { token -> setDock(hud, DroidDeckHudDock.from(token)) }
@@ -118,7 +143,10 @@ class DroidDeckHudHost(context: Context) {
}
fun applyConfig() {
- view?.applyConfig(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))
+ val config = DroidDeckHudConfig.load(context)
+ snaps = config["hudSnap"] != "0"
+ if (!snaps) anchorX = Float.NaN
+ view?.applyConfig(DroidDeckHudConfig.encode(config))
}
fun refresh() {
@@ -216,15 +244,141 @@ class DroidDeckHudHost(context: Context) {
/** The edge the HUD was dropped over, once a good part of it hangs past that edge; null when it stays. */
private fun dockEdgeFor(v: View): DroidDeckHudDock? {
+ val over = overhang(v) ?: return null
+ return over.key.takeIf { over.value >= DOCK_OVERHANG }
+ }
+
+ /** The edge the HUD hangs furthest past, with how much of it does, as a share of its own size. */
+ private fun overhang(v: View): Map.Entry? {
val p = v.parent as? View ?: return null
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return null
- val over = mapOf(
- DroidDeckHudDock.LEFT to -v.x / v.width,
- DroidDeckHudDock.RIGHT to (v.x + v.width - p.width) / v.width,
- DroidDeckHudDock.TOP to -v.y / v.height,
- DroidDeckHudDock.BOTTOM to (v.y + v.height - p.height) / v.height,
- ).maxByOrNull { it.value } ?: return null
- return over.key.takeIf { over.value >= DOCK_OVERHANG }
+ // The Strip spans the screen and is short: measure its pull in a set distance rather than its own size.
+ val strip = (v as? DroidDeckHudView)?.isStrip == true
+ val density = v.resources.displayMetrics.density
+ val w = if (strip) minOf(v.width.toFloat(), STRIP_PULL_ACROSS_DP * density) else v.width.toFloat()
+ val h = if (strip) maxOf(v.height.toFloat(), STRIP_PULL_OFF_DP * density) else v.height.toFloat()
+ return mapOf(
+ DroidDeckHudDock.LEFT to -v.x / w,
+ DroidDeckHudDock.RIGHT to (v.x + v.width - p.width) / w,
+ DroidDeckHudDock.TOP to -v.y / h,
+ DroidDeckHudDock.BOTTOM to (v.y + v.height - p.height) / h,
+ ).maxByOrNull { it.value }
+ }
+
+ /** The tab follows the finger along its edge and away from it, staying on screen. */
+ private fun dragTab(hud: DroidDeckHudView, x: Float, y: Float) {
+ val p = hud.parent as? View ?: return
+ stopMotion(hud)
+ snapping = false
+ hud.x = x.coerceIn(0f, (p.width - hud.width).toFloat().coerceAtLeast(0f))
+ hud.y = y.coerceIn(0f, (p.height - hud.height).toFloat().coerceAtLeast(0f))
+ }
+
+ /** A tab pulled out by more than its own size comes out as the HUD there; a shorter pull slides it back onto its edge. */
+ private fun dropTab(hud: DroidDeckHudView) {
+ val p = hud.parent as? View ?: return
+ val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
+ val maxY = (p.height - hud.height).toFloat().coerceAtLeast(0f)
+ val pulled = when (hud.dock) {
+ DroidDeckHudDock.LEFT -> hud.x / hud.width
+ DroidDeckHudDock.RIGHT -> (maxX - hud.x) / hud.width
+ DroidDeckHudDock.TOP -> hud.y / hud.height
+ DroidDeckHudDock.BOTTOM -> (maxY - hud.y) / hud.height
+ DroidDeckHudDock.NONE -> 0f
+ }
+ if (pulled >= 1f) { setDock(hud, DroidDeckHudDock.NONE); return }
+ wantX = hud.x; wantY = hud.y
+ val (x, y) = settled(p, hud.dock, hud.width.toFloat(), hud.height.toFloat())
+ wantX = x; wantY = y
+ SessionPrefs.setDroidDeckHudPosition(context, x, y)
+ if (Motion.scale == 0f) { hud.x = x; hud.y = y; return }
+ snapping = true
+ hud.animate().x(x).y(y).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE)
+ .withEndAction { snapping = false }.start()
+ }
+
+ /** Stops the HUD's running move, and the per-frame listener a dock animation hangs on it, which outlives a cancel. */
+ private fun stopMotion(hud: DroidDeckHudView) {
+ hud.animate().setUpdateListener(null)
+ hud.animate().cancel()
+ }
+
+ /** Glides the HUD from (fromX, fromY) to where it now is, back at full size. */
+ private fun glideFrom(hud: DroidDeckHudView, fromX: Float, fromY: Float) {
+ if (Motion.scale == 0f || (fromX == hud.x && fromY == hud.y && hud.scaleX == 1f && hud.scaleY == 1f)) {
+ hud.scaleX = 1f; hud.scaleY = 1f
+ return
+ }
+ val toX = hud.x; val toY = hud.y
+ hud.x = fromX; hud.y = fromY
+ snapping = true
+ hud.animate().x(toX).y(toY).scaleX(1f).scaleY(1f).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE)
+ .withEndAction { snapping = false; keepOnScreen(hud) }.start()
+ }
+
+ /** A drop short of docking fades the tab preview back out. */
+ private fun fadeDockPreview(hud: DroidDeckHudView) {
+ val from = hud.dockPreviewBlend
+ if (from <= 0f) return
+ val edge = overhang(hud)?.key ?: DroidDeckHudDock.NONE
+ if (Motion.scale == 0f) { hud.setDockPreview(DroidDeckHudDock.NONE, 0f); return }
+ previewFade?.cancel()
+ previewFade = ValueAnimator.ofFloat(from, 0f).apply {
+ duration = Motion.ms(200).toLong()
+ addUpdateListener { hud.setDockPreview(edge, it.animatedValue as Float) }
+ start()
+ }
+ }
+
+ /**
+ * Dropped, the HUD glides to the nearer side of the screen at the height it was let go, as a picture-in-picture
+ * window does, carried to the side a fling throws it toward.
+ */
+ private fun snapToAnchor(hud: DroidDeckHudView, vx: Float) {
+ val p = hud.parent as? View ?: return
+ val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
+ val maxY = (p.height - hud.height).toFloat().coerceAtLeast(0f)
+ anchorX = if (hud.x + vx * FLING_REACH_S < maxX / 2f) 0f else 1f
+ wantX = anchorX * maxX
+ wantY = hud.y.coerceIn(0f, maxY)
+ SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY)
+ stopMotion(hud)
+ if (Motion.scale == 0f) {
+ hud.scaleX = 1f; hud.scaleY = 1f
+ hud.x = wantX; hud.y = wantY
+ return
+ }
+ snapping = true
+ hud.animate().x(wantX).y(wantY).scaleX(1f).scaleY(1f).setDuration(Motion.ms(260).toLong()).setInterpolator(EASE)
+ .withEndAction { snapping = false; keepOnScreen(hud) }.start()
+ }
+
+ /**
+ * Dragged past an edge, the HUD shrinks around the finger toward the tab it docks as, reaching the tab's
+ * size where a drop would dock it, so the drop finishes a contraction the drag already started.
+ */
+ private fun shrinkPastEdge(hud: DroidDeckHudView, rawX: Float, rawY: Float) {
+ if (hud.dock != DroidDeckHudDock.NONE) return
+ val p = hud.parent as? View ?: return
+ val edge = overhang(hud) ?: return
+ val over = edge.value
+ val t = (over / DOCK_OVERHANG).coerceIn(0f, 1f)
+ val fade = ((t - PREVIEW_FROM) / (1f - PREVIEW_FROM)).coerceIn(0f, 1f)
+ hud.setDockPreview(edge.key, fade * fade * (3f - 2f * fade))
+ val (tabW, tabH) = hud.dockTabSize()
+ val e = t * t * (3f - 2f * t)
+ val at = IntArray(2).also { p.getLocationOnScreen(it) }
+ hud.pivotX = rawX - at[0] - hud.x
+ hud.pivotY = rawY - at[1] - hud.y
+ if (hud.isStrip) {
+ // The bar draws in sideways toward the tab's width; scaled evenly it would thin to a line.
+ hud.scaleX = 1f + (tabW / hud.width - 1f) * e
+ hud.scaleY = 1f
+ return
+ }
+ val tabScale = maxOf(tabW / hud.width, tabH / hud.height).coerceAtMost(1f)
+ val s = 1f + (tabScale - 1f) * e
+ hud.scaleX = s; hud.scaleY = s
}
/** Tucks the HUD against [edge] as its FPS tab, or with [DroidDeckHudDock.NONE] brings it back out from where it was docked. */
@@ -234,15 +388,8 @@ class DroidDeckHudHost(context: Context) {
}
private fun commitDock(hud: DroidDeckHudView, edge: DroidDeckHudDock) {
- if (edge == DroidDeckHudDock.NONE) {
- when (hud.dock) {
- DroidDeckHudDock.LEFT -> wantX = 0f
- DroidDeckHudDock.RIGHT -> wantX = Float.MAX_VALUE
- DroidDeckHudDock.TOP -> wantY = 0f
- DroidDeckHudDock.BOTTOM -> wantY = Float.MAX_VALUE
- DroidDeckHudDock.NONE -> {}
- }
- } else {
+ val tabX = hud.x; val tabY = hud.y
+ if (edge != DroidDeckHudDock.NONE) {
val p = hud.parent as? View
if (p != null) {
wantX = wantX.coerceIn(0f, p.width.toFloat())
@@ -252,35 +399,65 @@ class DroidDeckHudHost(context: Context) {
}
DroidDeckHudConfig.set(context, "hudDock", edge.token)
applyConfig()
+ if (edge == DroidDeckHudDock.NONE) settleOutOfTab(hud, tabX, tabY)
hud.post { keepOnScreen(hud) }
onConfigChanged?.invoke()
}
+ /**
+ * Where the HUD comes out of its tab, decided before it expands so it grows straight there: where the tab is
+ * (against its edge, or wherever it was pulled to), and with snapping on, against the nearer side.
+ */
+ private fun settleOutOfTab(hud: DroidDeckHudView, tabX: Float, tabY: Float) {
+ val p = hud.parent as? View
+ val maxX = if (p == null) Float.MAX_VALUE else (p.width - hud.contentWidth).coerceAtLeast(0f)
+ val maxY = if (p == null) Float.MAX_VALUE else (p.height - hud.contentHeight).coerceAtLeast(0f)
+ wantX = tabX.coerceIn(0f, maxX); wantY = tabY.coerceIn(0f, maxY)
+ anchorX = if (snaps && !hud.isStrip && p != null) (if (wantX < maxX / 2f) 0f else 1f) else Float.NaN
+ if (!anchorX.isNaN()) wantX = anchorX * maxX
+ SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY)
+ }
+
/**
* One shape contracts into the tab, and the tab expands back out to the HUD: the view is scaled onto the
* tab's exact rectangle, and the layouts swap at that same size and place. Timing and curve are the
* launcher's own ([Motion]), and off when animations are off.
*/
private fun animateDock(hud: DroidDeckHudView, edge: DroidDeckHudDock, commit: () -> Unit) {
- hud.animate().cancel()
- hud.scaleX = 1f; hud.scaleY = 1f; hud.alpha = 1f
+ stopMotion(hud)
+ previewFade?.cancel(); previewFade = null
+ snapping = false
+ hud.alpha = 1f
val p = hud.parent as? View
- if (Motion.scale == 0f || p == null || !hud.isLaidOut || hud.width <= 0) { commit(); return }
+ if (Motion.scale == 0f || p == null || !hud.isLaidOut || hud.width <= 0) {
+ hud.scaleX = 1f; hud.scaleY = 1f
+ hud.setDockPreview(DroidDeckHudDock.NONE, 0f)
+ commit(); return
+ }
if (edge != DroidDeckHudDock.NONE) {
+ // Continue from what the drag left on screen: the shrunk HUD, and the tab where it is along the edge.
+ val left = hud.x + hud.pivotX * (1f - hud.scaleX); val top = hud.y + hud.pivotY * (1f - hud.scaleY)
+ val shownW = hud.width * hud.scaleX; val shownH = hud.height * hud.scaleY
+ hud.pivotX = 0f; hud.pivotY = 0f
+ hud.x = left; hud.y = top
val (tabW, tabH) = hud.dockTabSize()
+ wantX = left + (shownW - tabW) / 2f
+ wantY = top + (shownH - tabH) / 2f
val (tabX, tabY) = settled(p, edge, tabW, tabH)
val sx = tabW / hud.width; val sy = tabH / hud.height
- hud.pivotX = pivotFor(hud.x, tabX, sx) - hud.x
- hud.pivotY = pivotFor(hud.y, tabY, sy) - hud.y
- hud.animate().scaleX(sx).scaleY(sy).setDuration(Motion.ms(300).toLong()).setInterpolator(EASE)
+ val from = hud.dockPreviewBlend
+ hud.animate().x(tabX).y(tabY).scaleX(sx).scaleY(sy).setDuration(Motion.ms(220).toLong()).setInterpolator(EASE)
+ .setUpdateListener { hud.setDockPreview(edge, from + (1f - from) * it.animatedFraction) }
.withEndAction {
+ hud.animate().setUpdateListener(null)
commit()
+ hud.setDockPreview(DroidDeckHudDock.NONE, 0f)
hud.scaleX = 1f; hud.scaleY = 1f
hud.x = tabX; hud.y = tabY
- hud.alpha = 0.5f
- hud.animate().alpha(1f).setDuration(Motion.ms(120).toLong()).start()
}.start()
} else {
+ hud.scaleX = 1f; hud.scaleY = 1f
+ val was = hud.dock
val tabX = hud.x; val tabY = hud.y
val tabW = hud.width.toFloat(); val tabH = hud.height.toFloat()
commit()
@@ -291,7 +468,14 @@ class DroidDeckHudHost(context: Context) {
hud.pivotX = pivotFor(x, tabX, sx) - x
hud.pivotY = pivotFor(y, tabY, sy) - y
hud.scaleX = sx; hud.scaleY = sy
- hud.animate().scaleX(1f).scaleY(1f).setDuration(Motion.ms(360).toLong()).setInterpolator(EASE).start()
+ hud.setDockPreview(was, 1f)
+ hud.animate().scaleX(1f).scaleY(1f).setDuration(Motion.ms(360).toLong()).setInterpolator(EASE)
+ .setUpdateListener {
+ val f = (1f - it.animatedFraction / EXPAND_FADE).coerceIn(0f, 1f)
+ hud.setDockPreview(was, f * f * (3f - 2f * f))
+ }
+ .withEndAction { hud.animate().setUpdateListener(null); hud.setDockPreview(DroidDeckHudDock.NONE, 0f) }
+ .start()
}
}
@@ -317,11 +501,17 @@ class DroidDeckHudHost(context: Context) {
// it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the
// corner the small window pushed it to.
private fun keepOnScreen(v: View) {
+ if (snapping) return
val p = v.parent as? View ?: return
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return
if (wantX.isNaN()) { wantX = v.x; wantY = v.y }
val maxX = (p.width - v.width).toFloat().coerceAtLeast(0f)
val maxY = (p.height - v.height).toFloat().coerceAtLeast(0f)
+ val anchored = (v as? DroidDeckHudView)?.let { it.dock == DroidDeckHudDock.NONE && !it.isStrip } == true
+ if (snaps && anchored && anchorX.isNaN()) {
+ anchorX = if (wantX.coerceIn(0f, maxX) < maxX / 2f) 0f else 1f
+ }
+ if (snaps && anchored && !anchorX.isNaN()) wantX = anchorX * maxX
var x = wantX.coerceIn(0f, maxX)
var y = wantY.coerceIn(0f, maxY)
if ((v as? DroidDeckHudView)?.isStrip == true) y = if (y + v.height / 2f < p.height / 2f) 0f else maxY
@@ -339,6 +529,15 @@ class DroidDeckHudHost(context: Context) {
companion object {
/** How much of the HUD, as a share of its own size, must hang past a screen edge for it to dock there. */
private const val DOCK_OVERHANG = 0.4f
+ /** How far ahead a fling carries the HUD when picking the spot it snaps to, as seconds of its speed. */
+ private const val FLING_REACH_S = 0.18f
+ /** How far toward docking a drag goes, as a share of the way, before the tab starts to fade in over the HUD. */
+ private const val PREVIEW_FROM = 0.55f
+ /** How far the Strip is pulled sideways, or off its edge, for its overhang to count as its whole size. */
+ private const val STRIP_PULL_ACROSS_DP = 600f
+ private const val STRIP_PULL_OFF_DP = 150f
+ /** The share of the expand animation over which the tab fades out to the HUD. */
+ private const val EXPAND_FADE = 0.5f
private val EASE = PathInterpolator(0.2f, 0.8f, 0.2f, 1f)
/** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
index c92ac6c87..0d0b7b816 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
@@ -24,9 +24,9 @@ import android.graphics.RectF
import android.graphics.Typeface
import android.os.SystemClock
import android.text.format.DateFormat
-import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
+import android.view.ViewConfiguration
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.session.SessionPrefs
@@ -231,6 +231,12 @@ class DroidDeckHudView(context: Context) : View(context) {
fun setOnLockChangedListener(l: Consumer?) { onLockChangedListener = l }
private var onDockChangedListener: Consumer? = null
private var onScaledListener: IntConsumer? = null
+ private var onDraggedListener: BiConsumer? = null
+ /** The HUD moved under the finger, at that screen point. */
+ fun setOnDraggedListener(l: BiConsumer?) { onDraggedListener = l }
+ private var onFlungListener: BiConsumer? = null
+ /** A drag ended in a fling, at this velocity (pixels per second), just before the move is reported. */
+ fun setOnFlungListener(l: BiConsumer?) { onFlungListener = l }
fun setOnDockChangedListener(l: Consumer?) { onDockChangedListener = l }
/** A pinch settled on this size, in percent. */
fun setOnScaledListener(l: IntConsumer?) { onScaledListener = l }
@@ -238,6 +244,8 @@ class DroidDeckHudView(context: Context) : View(context) {
private val lockController = HudLockController(context, this, object : HudLockController.Callbacks {
override fun onTap() { cycleSize() }
override fun onMoved(x: Float, y: Float) { onMovedListener?.accept(x, y) }
+ override fun onDragged(rawX: Float, rawY: Float) { onDraggedListener?.accept(rawX, rawY) }
+ override fun onFlung(velocityX: Float, velocityY: Float) { onFlungListener?.accept(velocityX, velocityY) }
override fun onLockChanged(locked: Boolean) { onLockChangedListener?.accept(locked) }
override fun onPinch(factor: Float) {
val next = (scale * factor).coerceIn(MIN_SCALE, MAX_SCALE)
@@ -248,23 +256,56 @@ class DroidDeckHudView(context: Context) : View(context) {
override fun onPinchEnd() { onScaledListener?.accept((scale * 100f).roundToInt()) }
})
- // A docked tab always takes touches, locked or not: a tap or a pull away from the edge brings the HUD back.
- private val dockDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
- override fun onDown(e: MotionEvent): Boolean = true
- override fun onSingleTapUp(e: MotionEvent): Boolean { onDockChangedListener?.accept(DroidDeckHudDock.NONE.token); return true }
- override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
- if (e1 == null) return false
- val inward = when (dock) {
- DroidDeckHudDock.LEFT -> e2.rawX - e1.rawX
- DroidDeckHudDock.RIGHT -> e1.rawX - e2.rawX
- DroidDeckHudDock.TOP -> e2.rawY - e1.rawY
- DroidDeckHudDock.BOTTOM -> e1.rawY - e2.rawY
- DroidDeckHudDock.NONE -> 0f
+ // A docked tab always takes touches, locked or not: a tap brings the HUD back, a drag moves the tab.
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+ private var tabDownRawX = 0f
+ private var tabDownRawY = 0f
+ private var tabStartX = 0f
+ private var tabStartY = 0f
+ private var tabDragging = false
+ private var onTabDraggedListener: BiConsumer? = null
+ private var onTabDroppedListener: Runnable? = null
+ private var onTabDragCancelledListener: Runnable? = null
+ /** The docked tab is dragged: where the finger would put it (x, y in the parent). */
+ fun setOnTabDraggedListener(l: BiConsumer?) { onTabDraggedListener = l }
+ fun setOnTabDroppedListener(l: Runnable?) { onTabDroppedListener = l }
+ /** The system took the tab's drag away: the tab goes back to where it was docked. */
+ fun setOnTabDragCancelledListener(l: Runnable?) { onTabDragCancelledListener = l }
+
+ private fun onTabTouch(event: MotionEvent): Boolean {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ tabDownRawX = event.rawX; tabDownRawY = event.rawY
+ tabStartX = x; tabStartY = y
+ tabDragging = false
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val dx = event.rawX - tabDownRawX; val dy = event.rawY - tabDownRawY
+ if (!tabDragging && dx * dx + dy * dy > touchSlop * touchSlop) tabDragging = true
+ if (tabDragging) onTabDraggedListener?.accept(tabStartX + dx, tabStartY + dy)
+ }
+ MotionEvent.ACTION_UP -> {
+ if (tabDragging) { tabDragging = false; onTabDroppedListener?.run() }
+ else onDockChangedListener?.accept(DroidDeckHudDock.NONE.token)
+ }
+ MotionEvent.ACTION_CANCEL -> if (tabDragging) {
+ tabDragging = false
+ onTabDragCancelledListener?.run()
}
- if (inward > 12f * density) onDockChangedListener?.accept(DroidDeckHudDock.NONE.token)
- return true
}
- })
+ return true
+ }
+
+ // On the way into the tab or out of it, the tab fades in over the HUD at the tab's true size on screen.
+ private var previewEdge = DroidDeckHudDock.NONE
+ private var previewBlend = 0f
+ internal fun setDockPreview(edge: DroidDeckHudDock, blend: Float) {
+ val b = blend.coerceIn(0f, 1f)
+ if (edge == previewEdge && b == previewBlend) return
+ previewEdge = edge; previewBlend = b
+ invalidate()
+ }
+ internal val dockPreviewBlend: Float get() = previewBlend
// ---- Public surface (symmetric with the other overlays) ---------------
fun setEngineLabel(s: String?) { if ((s ?: "") != engineLabel) { engineLabel = s ?: ""; post { rebuildAndInvalidate() } } }
@@ -612,10 +653,10 @@ class DroidDeckHudView(context: Context) : View(context) {
internal fun dockTabSize(): Pair =
Pair(runWidth(tabSpans()) + 2f * sp(9f), capHeight(sp(20f)) + 2f * sp(7f))
- private fun drawTab(canvas: Canvas, rect: RectF) {
+ private fun drawTab(canvas: Canvas, rect: RectF, edgeOf: DroidDeckHudDock = dock) {
val r = sp(8f); val stroke = max(outlineWidth(), sp(1.5f)); val h = stroke / 2f
val round = floatArrayOf(r, r, r, r, r, r, r, r)
- val edge = when (dock) {
+ val edge = when (edgeOf) {
DroidDeckHudDock.LEFT -> intArrayOf(0, 3)
DroidDeckHudDock.RIGHT -> intArrayOf(1, 2)
DroidDeckHudDock.TOP -> intArrayOf(0, 1)
@@ -1409,6 +1450,39 @@ class DroidDeckHudView(context: Context) : View(context) {
override fun onDraw(canvas: Canvas) {
if (contentW <= 0f || contentH <= 0f) { lockController.drawBadge(canvas); return }
+ val blend = if (dock == DroidDeckHudDock.NONE && previewEdge != DroidDeckHudDock.NONE) previewBlend else 0f
+ if (blend <= 0f) { drawContent(canvas); return }
+ if (blend < 1f) {
+ canvas.saveLayerAlpha(0f, 0f, width.toFloat(), height.toFloat(), ((1f - blend) * 255f).roundToInt())
+ drawContent(canvas)
+ canvas.restore()
+ }
+ drawTabPreview(canvas, blend)
+ }
+
+ /** The docked tab drawn at its own size in the middle of the view, undoing the view's scale. */
+ private fun drawTabPreview(canvas: Canvas, blend: Float) {
+ val (w, h) = dockTabSize()
+ if (scaleX <= 0f || scaleY <= 0f) return
+ canvas.save()
+ canvas.translate(width / 2f, height / 2f)
+ canvas.scale(1f / scaleX, 1f / scaleY)
+ canvas.translate(-w / 2f, -h / 2f)
+ canvas.saveLayerAlpha(0f, 0f, w, h, (blend * 255f).roundToInt())
+ drawTab(canvas, RectF(0f, 0f, w, h), previewEdge)
+ var x = sp(9f)
+ val baseline = sp(7f) + capHeight(sp(20f))
+ for (span in tabSpans()) {
+ drawPaint.textSize = span.sizePx
+ drawPaint.color = blend(span.color)
+ canvas.drawText(span.text, x, baseline, drawPaint)
+ x += measure(span.text, span.sizePx)
+ }
+ canvas.restore()
+ canvas.restore()
+ }
+
+ private fun drawContent(canvas: Canvas) {
dPanel?.let { panel -> panelFrame(panel)?.let { canvas.drawBitmap(it, 0f, 0f, null) } }
@@ -1587,7 +1661,7 @@ class DroidDeckHudView(context: Context) : View(context) {
// Declining the down event hands the whole gesture to the views under the HUD.
override fun onTouchEvent(event: MotionEvent): Boolean {
- if (dock != DroidDeckHudDock.NONE) return dockDetector.onTouchEvent(event)
+ if (dock != DroidDeckHudDock.NONE) return onTabTouch(event)
if (event.actionMasked == MotionEvent.ACTION_DOWN && lockController.isLocked() &&
passesTouchAt?.invoke(event.rawX, event.rawY) == true) return false
return lockController.onTouchEvent(event)
@@ -1598,7 +1672,7 @@ class DroidDeckHudView(context: Context) : View(context) {
internal val contentWidth: Float get() = contentW
internal val contentHeight: Float get() = contentH
- internal val isDragging: Boolean get() = lockController.isDragging()
+ internal val isDragging: Boolean get() = lockController.isDragging() || tabDragging
private companion object {
const val REFRESH_MS = 500L
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
index 2e3dc0d72..f739ec8b6 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
+++ b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
@@ -57,6 +57,10 @@ public class HudLockController {
void onTap();
/** Drag released (unlocked): persist the new (x, y). */
void onMoved(float x, float y);
+ /** The view moved under a drag (unlocked), with the finger at screen point (rawX, rawY). */
+ default void onDragged(float rawX, float rawY) {}
+ /** The drag ended in a fling (unlocked), in pixels per second; comes just before {@link #onMoved}. */
+ default void onFlung(float velocityX, float velocityY) {}
/** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */
void onLockChanged(boolean locked);
/** Two-finger pinch (unlocked): the change since the previous event, above 1 when the fingers spread. */
@@ -156,6 +160,12 @@ public class HudLockController {
return true;
}
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ if (dragging && !locked && !pinched) cb.onFlung(velocityX, velocityY);
+ return true;
+ }
+
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (pinched) return true;
@@ -167,6 +177,7 @@ public class HudLockController {
view.setX(startX + (e2.getRawX() - downRawX));
view.setY(startY + (e2.getRawY() - downRawY));
dragging = true;
+ cb.onDragged(e2.getRawX(), e2.getRawY());
return true;
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt
index 7a90ea33d..d1e3069f1 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt
@@ -29,6 +29,8 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri
size, chipModifier = track("hud-size")) { a.onHudSetting("hudSize", it.token) }
ToggleRow(host, "hud-locked", stringResource(R.string.hud_locked), stringResource(R.string.hud_locked_hint), on("hudLocked"),
chipModifier = track("hud-locked")) { a.onHudSetting("hudLocked", flag(it)) }
+ ToggleRow(host, "hud-snap", stringResource(R.string.hud_snap), stringResource(R.string.hud_snap_hint), on("hudSnap"),
+ chipModifier = track("hud-snap")) { a.onHudSetting("hudSnap", flag(it)) }
}
if (!a.hudOn) return
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index e01b13dc1..7ce8449e4 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -662,6 +662,8 @@
شريط
تثبيت في مكانه
اسحب HUD لتحريكه؛ والضغط مطولًا يثبّته أو يلغي تثبيته. أثناء تثبيته، تظل عناصر التحكم على الشاشة الموجودة تحته تعمل.
+ الالتصاق بالجانبين
+ اترك الواجهة لتنتقل إلى أقرب جانب من الشاشة. اسحبها بسرعة لترميها في ذلك الاتجاه.
المظهر
الحجم على الشاشة
هادئ
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index b3b6182c5..6419444b0 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -656,6 +656,8 @@
Barra
Fijar posición
Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando.
+ Ajustar a los lados
+ Al soltar el HUD, se mueve al lado más cercano de la pantalla. Un gesto rápido lo lanza en esa dirección.
Aspecto
Tamaño en pantalla
Suave
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 11d3ea0a2..c77ba12ec 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -654,6 +654,8 @@
Лента
Заключване на място
Плъзнете HUD, за да го преместите; продължително натискане го заключва или отключва. Докато е заключен, екранните контроли под него продължават да работят.
+ Залепване отстрани
+ Пуснете HUD и той отива към по-близката страна на екрана. Бързо плъзване го хвърля в тази посока.
Визия
Размер на екрана
Мек
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index e9d4f0e1b..6399cbd72 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -658,6 +658,8 @@
Lišta
Zamknout na místě
Přetažením HUD přesunete; dlouhým stiskem ho zamknete nebo odemknete. Když je zamknutý, ovládací prvky na obrazovce pod ním dál fungují.
+ Přichytit ke stranám
+ Pusťte HUD a přesune se k bližší straně obrazovky. Rychlým švihnutím ho hodíte tím směrem.
Vzhled
Velikost na obrazovce
Jemná
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 84b82baf1..2897aef86 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -654,6 +654,8 @@
Bjælke
Lås fast
Træk i HUD for at flytte det; et langt tryk låser eller frigør det. Mens det er låst, virker skærmknapperne under det stadig.
+ Fastgør til siderne
+ Slip HUD\'en, så flytter den sig til skærmens nærmeste side. Et hurtigt stryg kaster den i den retning.
Udseende
Størrelse på skærmen
Blød
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 3885cf4ad..961f3af58 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -654,6 +654,8 @@
Leiste
Position sperren
Ziehe das HUD, um es zu verschieben; langes Drücken sperrt oder entsperrt es. Solange es gesperrt ist, funktionieren die Bildschirmsteuerungen darunter weiter.
+ An Seiten einrasten
+ Lass das HUD los, und es springt an die nähere Bildschirmseite. Ein Wischen wirft es in diese Richtung.
Aussehen
Größe auf dem Bildschirm
Dezent
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 5739417ac..f9397e9da 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -654,6 +654,8 @@
Λωρίδα
Κλείδωμα θέσης
Σύρετε το HUD για να το μετακινήσετε· ένα παρατεταμένο πάτημα το κλειδώνει ή το ξεκλειδώνει. Όσο είναι κλειδωμένο, τα χειριστήρια οθόνης από κάτω του εξακολουθούν να λειτουργούν.
+ Προσκόλληση στα πλάγια
+ Αφήστε το HUD και μετακινείται στην πιο κοντινή πλευρά της οθόνης. Μια γρήγορη κίνηση το πετάει προς εκείνη την κατεύθυνση.
Εμφάνιση
Μέγεθος στην οθόνη
Απαλό
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 994b130f8..9a6e5ebb2 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -656,6 +656,8 @@
Barra
Fijar posición
Arrastra el HUD para moverlo; una pulsación larga lo bloquea o lo desbloquea. Mientras está bloqueado, los controles en pantalla que tiene debajo siguen funcionando.
+ Ajustar a los lados
+ Al soltar el HUD, se mueve al lado más cercano de la pantalla. Un gesto rápido lo lanza en esa dirección.
Aspecto
Tamaño en pantalla
Suave
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 2730dd290..fa12dea8e 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -654,6 +654,8 @@
Palkki
Lukitse paikalleen
Siirrä HUD:ia vetämällä; pitkä painallus lukitsee tai vapauttaa sen. Lukittuna sen alla olevat näytön ohjaimet toimivat edelleen.
+ Kiinnitä sivuille
+ Kun päästät HUDista irti, se siirtyy näytön lähempään reunaan. Nopea pyyhkäisy heittää sen siihen suuntaan.
Ulkoasu
Koko näytöllä
Pehmeä
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 6e78ba055..7c6d888e7 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -656,6 +656,8 @@
Bandeau
Verrouiller la position
Faites glisser le HUD pour le déplacer ; un appui long le verrouille ou le déverrouille. Tant qu\'il est verrouillé, les commandes à l\'écran situées dessous restent utilisables.
+ Aimanter sur les côtés
+ Relâchez le HUD et il rejoint le côté de l’écran le plus proche. Un geste rapide le lance dans cette direction.
Apparence
Taille à l\'écran
Douce
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 060d657ea..72c0e39e2 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -654,6 +654,8 @@
पट्टी
जगह पर लॉक करें
HUD को खिसकाने के लिए उसे ड्रैग करें; देर तक दबाने से वह लॉक या अनलॉक होता है। लॉक रहने पर भी उसके नीचे के ऑन-स्क्रीन कंट्रोल काम करते रहते हैं।
+ किनारों पर चिपकाएँ
+ HUD छोड़ने पर यह स्क्रीन की पास वाली ओर चला जाता है। तेज़ी से झटकने पर यह उसी दिशा में जाता है।
लुक
स्क्रीन पर आकार
हल्का
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index c4c76a01f..a0fd81a0f 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -654,6 +654,8 @@
Sáv
Rögzítés a helyén
Húzd a HUD-ot az áthelyezéshez; hosszú nyomással rögzítheted vagy feloldhatod. Rögzítve is működnek az alatta lévő képernyős vezérlők.
+ Igazítás az oldalakhoz
+ Engedd el a HUD-ot, és a képernyő közelebbi oldalára ugrik. Egy gyors pöccintés abba az irányba dobja.
Megjelenés
Méret a képernyőn
Lágy
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 59830bff3..a6a239978 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -652,6 +652,8 @@
Bilah
Kunci posisi
Seret HUD untuk memindahkannya; tekan lama untuk mengunci atau membuka kuncinya. Saat terkunci, kontrol layar di bawahnya tetap berfungsi.
+ Tempel ke sisi
+ Lepaskan HUD dan HUD akan pindah ke sisi layar yang lebih dekat. Jentikan cepat melemparnya ke arah itu.
Tampilan
Ukuran di layar
Lembut
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 85557daf8..659a5363e 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -656,6 +656,8 @@
Barra
Blocca in posizione
Trascina l\'HUD per spostarlo; una pressione prolungata lo blocca o lo sblocca. Mentre è bloccato, i controlli su schermo sotto di esso continuano a funzionare.
+ Aggancia ai lati
+ Rilascia l\'HUD e si sposta sul lato più vicino dello schermo. Un gesto rapido lo lancia in quella direzione.
Aspetto
Dimensione sullo schermo
Tenue
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index e3d31e278..7f8aeee5d 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -641,6 +641,8 @@
バー
位置を固定
HUDはドラッグで移動できます。長押しで固定または解除します。固定中も、下にある画面上のコントロールは使えます。
+ 左右に吸着
+ HUD を離すと、画面の近い方の端に移動します。はじくとその方向へ飛びます。
外観
画面上のサイズ
ソフト
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 68c7876fa..19b9e2230 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -652,6 +652,8 @@
바
위치 고정
HUD를 드래그하여 이동하고, 길게 눌러 고정하거나 해제합니다. 고정된 동안에도 아래에 있는 화면 컨트롤은 계속 작동합니다.
+ 양옆에 맞추기
+ HUD를 놓으면 화면에서 더 가까운 쪽으로 이동합니다. 튕기면 그 방향으로 이동합니다.
외관
화면상 크기
은은함
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 6646eba05..f79fa91d1 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -652,6 +652,8 @@
Jalur
Kunci di tempat
Seret HUD untuk mengalihkannya; tekan lama untuk mengunci atau membuka kuncinya. Semasa dikunci, kawalan skrin di bawahnya masih berfungsi.
+ Lekap ke sisi
+ Lepaskan HUD dan ia akan beralih ke sisi skrin yang lebih dekat. Kuis pantas melemparkannya ke arah itu.
Rupa
Saiz pada skrin
Lembut
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index fffb4958e..4afe5f21d 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -654,6 +654,8 @@
Linje
Lås på plass
Dra HUD-en for å flytte den; et langt trykk låser eller låser den opp. Mens den er låst, fungerer skjermkontrollene under den fortsatt.
+ Fest til sidene
+ Slipp HUD-en, så flytter den seg til nærmeste side av skjermen. Et raskt sveip kaster den den veien.
Utseende
Størrelse på skjermen
Myk
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 29753d0b8..ca6bc0d39 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -654,6 +654,8 @@
Balk
Vastzetten
Sleep de HUD om hem te verplaatsen; lang drukken zet hem vast of los. Zolang hij vastzit, blijven de schermknoppen eronder werken.
+ Vastklikken aan zijkanten
+ Laat de HUD los en hij gaat naar de dichtstbijzijnde kant van het scherm. Met een snelle veeg gooi je hem die kant op.
Weergave
Grootte op scherm
Zacht
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 2e426238e..8773fbe90 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -658,6 +658,8 @@
Pasek
Zablokuj w miejscu
Przeciągnij HUD, aby go przesunąć; długie naciśnięcie blokuje go lub odblokowuje. Gdy jest zablokowany, sterowanie ekranowe pod nim nadal działa.
+ Przyciągaj do boków
+ Puść HUD, a przesunie się do bliższego boku ekranu. Szybki ruch rzuca go w tę stronę.
Wygląd
Rozmiar na ekranie
Stonowany
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 30174c1fb..97b26b91a 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -656,6 +656,8 @@
Barra
Bloquear posição
Arraste o HUD para o mover; um toque longo bloqueia-o ou desbloqueia-o. Enquanto estiver bloqueado, os controlos no ecrã por baixo dele continuam a funcionar.
+ Encaixar nas laterais
+ Solte o HUD e ele vai para a lateral mais próxima do ecrã. Um gesto rápido lança-o nessa direção.
Aspeto
Tamanho no ecrã
Suave
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 299c61ef0..506d5dd48 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -656,6 +656,8 @@
Barra
Travar posição
Arraste o HUD para movê-lo; um toque longo o trava ou destrava. Enquanto estiver travado, os controles na tela embaixo dele continuam funcionando.
+ Encaixar nas laterais
+ Solte o HUD e ele vai para a lateral mais próxima da tela. Um gesto rápido o lança nessa direção.
Aparência
Tamanho na tela
Suave
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 3451398da..4b17c3343 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -656,6 +656,8 @@
Bară
Blochează pe loc
Trage HUD-ul pentru a-l muta; o apăsare lungă îl blochează sau îl deblochează. Cât timp e blocat, comenzile de pe ecran de sub el funcționează în continuare.
+ Fixare în lateral
+ Eliberează HUD-ul și se mută în partea mai apropiată a ecranului. Un gest rapid îl aruncă în acea direcție.
Aspect
Dimensiune pe ecran
Discretă
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 5e6084f34..3cd968dbc 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -658,6 +658,8 @@
Полоса
Закрепить на месте
Перетащите HUD, чтобы переместить его; долгое нажатие закрепляет или открепляет его. Пока он закреплён, экранные элементы управления под ним продолжают работать.
+ Прилипание к бокам
+ Отпустите HUD, и он переместится к ближайшей стороне экрана. Резкий жест отбросит его в ту сторону.
Внешний вид
Размер на экране
Мягкий
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index d4b1a4970..5cb4d9d35 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -654,6 +654,8 @@
List
Lås på plats
Dra HUD:en för att flytta den; ett långt tryck låser eller låser upp den. När den är låst fungerar skärmkontrollerna under den fortfarande.
+ Fäst mot sidorna
+ Släpp HUD:en så flyttas den till skärmens närmaste sida. En snabb svepning kastar den åt det hållet.
Utseende
Storlek på skärmen
Mjuk
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 402fef534..74c3612c7 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -652,6 +652,8 @@
แถบ
ล็อกตำแหน่ง
ลาก HUD เพื่อย้าย กดค้างเพื่อล็อกหรือปลดล็อก ขณะล็อก ปุ่มควบคุมบนหน้าจอที่อยู่ด้านล่างยังใช้งานได้
+ ยึดติดด้านข้าง
+ ปล่อย HUD แล้วจะย้ายไปด้านที่ใกล้กว่าของหน้าจอ ปัดเร็วๆ เพื่อเหวี่ยงไปทางนั้น
รูปลักษณ์
ขนาดบนหน้าจอ
อ่อน
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 7281c0abc..0c328650d 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -654,6 +654,8 @@
Şerit
Yerinde kilitle
Taşımak için HUD\'u sürükleyin; uzun basmak onu kilitler veya kilidini açar. Kilitliyken altındaki ekran kontrolleri çalışmaya devam eder.
+ Yanlara yapıştır
+ HUD\'u bıraktığınızda ekranın daha yakın tarafına gider. Hızlı bir kaydırma onu o yöne fırlatır.
Görünüm
Ekrandaki boyut
Yumuşak
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 0bc9dd50c..66abef06d 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -658,6 +658,8 @@
Смуга
Закріпити на місці
Перетягніть HUD, щоб перемістити його; довге натискання закріплює або відкріплює його. Поки його закріплено, екранні елементи керування під ним працюють і далі.
+ Прилипання до боків
+ Відпустіть HUD, і він переміститься до ближчого боку екрана. Різкий жест відкине його в той бік.
Вигляд
Розмір на екрані
М\'який
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 22b16a39b..288c72c95 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -652,6 +652,8 @@
Thanh
Khóa vị trí
Kéo HUD để di chuyển; nhấn giữ để khóa hoặc mở khóa. Khi đã khóa, các nút điều khiển trên màn hình bên dưới vẫn hoạt động.
+ Bám vào cạnh trái/phải
+ Thả HUD ra và nó sẽ di chuyển sang cạnh màn hình gần hơn. Vuốt nhanh để ném nó theo hướng đó.
Giao diện
Kích thước trên màn hình
Dịu
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index cf3b14000..f41754afd 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -641,6 +641,8 @@
横条
锁定位置
拖动 HUD 可移动位置;长按可锁定或解锁。锁定时,下方的屏幕控件仍可使用。
+ 吸附到两侧
+ 松开 HUD 后,它会移到屏幕较近的一侧。轻甩可将它甩向该方向。
外观
屏幕显示大小
柔和
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 196062110..203c7348f 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -641,6 +641,8 @@
橫條
鎖定位置
拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。
+ 貼齊兩側
+ 放開 HUD 後,它會移到螢幕較近的一側。輕甩可將它甩向該方向。
外觀
螢幕顯示大小
柔和
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 2b7470534..5573328d7 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -641,6 +641,8 @@
橫條
鎖定位置
拖曳 HUD 可移動位置;長按可鎖定或解鎖。鎖定時,下方的螢幕控制項仍可使用。
+ 貼齊兩側
+ 放開 HUD 後,它會移到螢幕較近的一側。輕甩可將它甩向該方向。
外觀
螢幕顯示大小
柔和
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cdba72bf3..203d5b0f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -678,6 +678,8 @@
Strip
Lock in place
Drag the HUD to move it; a long press locks or unlocks it. While it is locked, on-screen controls under it still work.
+ Snap to sides
+ Let go of the HUD and it moves to the nearer side of the screen. A flick throws it that way.
Look
Size on screen
Soft
From 356677989529d48f6882bd7c076777757be16ce3 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 11:15:59 -0500
Subject: [PATCH 02/17] feat: setting to turn off held-Back Steam shortcuts
(#1955)
---
.../droiddeck/launcher/FrontEndActivity.kt | 4 ++++
.../com/droiddeck/launcher/SessionActivity.kt | 8 +++++++
.../droiddeck/launcher/input/BackQamInput.kt | 9 ++++++-
.../launcher/session/SessionPrefs.kt | 6 +++++
.../launcher/session/SettingsReset.kt | 2 +-
.../launcher/ui/ModeSettingsDialog.kt | 8 ++++++-
.../droiddeck/launcher/ui/SessionOverlay.kt | 5 ++++
app/src/main/res/values-ar/strings.xml | 2 ++
app/src/main/res/values-b+es+419/strings.xml | 2 ++
app/src/main/res/values-bg/strings.xml | 2 ++
app/src/main/res/values-cs/strings.xml | 2 ++
app/src/main/res/values-da/strings.xml | 2 ++
app/src/main/res/values-de/strings.xml | 2 ++
app/src/main/res/values-el/strings.xml | 2 ++
app/src/main/res/values-es/strings.xml | 2 ++
app/src/main/res/values-fi/strings.xml | 2 ++
app/src/main/res/values-fr/strings.xml | 2 ++
app/src/main/res/values-hi/strings.xml | 2 ++
app/src/main/res/values-hu/strings.xml | 2 ++
app/src/main/res/values-in/strings.xml | 2 ++
app/src/main/res/values-it/strings.xml | 2 ++
app/src/main/res/values-ja/strings.xml | 2 ++
app/src/main/res/values-ko/strings.xml | 2 ++
app/src/main/res/values-ms/strings.xml | 2 ++
app/src/main/res/values-nb/strings.xml | 2 ++
app/src/main/res/values-nl/strings.xml | 2 ++
app/src/main/res/values-pl/strings.xml | 2 ++
app/src/main/res/values-pt-rPT/strings.xml | 2 ++
app/src/main/res/values-pt/strings.xml | 2 ++
app/src/main/res/values-ro/strings.xml | 2 ++
app/src/main/res/values-ru/strings.xml | 2 ++
app/src/main/res/values-sv/strings.xml | 2 ++
app/src/main/res/values-th/strings.xml | 2 ++
app/src/main/res/values-tr/strings.xml | 2 ++
app/src/main/res/values-uk/strings.xml | 2 ++
app/src/main/res/values-vi/strings.xml | 2 ++
app/src/main/res/values-zh-rCN/strings.xml | 2 ++
app/src/main/res/values-zh-rHK/strings.xml | 2 ++
app/src/main/res/values-zh-rTW/strings.xml | 2 ++
app/src/main/res/values/strings.xml | 2 ++
.../launcher/input/BackQamInputTest.kt | 24 +++++++++++++++++++
41 files changed, 129 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index e60d00785..ec4c069f4 100644
--- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -476,6 +476,7 @@ class FrontEndActivity : ComponentActivity() {
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
+ private var backHold by mutableStateOf(true)
private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("")
private var slowUsb by mutableStateOf(null)
@@ -2110,6 +2111,7 @@ class FrontEndActivity : ComponentActivity() {
unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null,
unfoldedControlsWithController = unfoldedControlsWithController,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
+ backHold = backHold,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
@@ -2162,6 +2164,7 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
},
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
+ onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted
@@ -2406,6 +2409,7 @@ class FrontEndActivity : ComponentActivity() {
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
+ backHold = SessionPrefs.backHold(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
picturePosition = SessionPrefs.picturePosition(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index bc0452256..dd1a4cd5f 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -310,6 +310,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onTap = { performBackAction(double = it) },
doubleTapMs = { backDoubleTapMs.toLong() },
onCancel = { padBridge?.cancelBackQam() },
+ holds = { backHold },
)
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
private var drawerDirectionDownTime = 0L
@@ -388,6 +389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val drawerGameProfileRefreshes = LatestRequestCoordinator()
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
+ private var backHold by mutableStateOf(true)
private var backActionsInverted by mutableStateOf(false)
private var backDoubleTapMs by mutableStateOf(500)
private var lowLatency by mutableStateOf(true)
@@ -1264,6 +1266,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id
},
+ backHold = backHold,
+ onBackHold = { on ->
+ SessionPrefs.setBackHold(this@SessionActivity, on)
+ backHold = on
+ },
backActionsInverted = backActionsInverted,
backDoubleTapMs = backDoubleTapMs,
onBackDoubleTapMs = { ms ->
@@ -1873,6 +1880,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
+ backHold = SessionPrefs.backHold(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
physicalShortcuts.bindings = PhysicalShortcuts.read(this)
backDoubleTapMs = SessionPrefs.backDoubleTapMs(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt
index 65f9e5b83..54ae373ae 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/BackQamInput.kt
@@ -5,7 +5,8 @@ import android.os.Handler
import android.os.Looper
class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap: (Boolean) -> Unit,
- private val doubleTapMs: () -> Long = { 500 }, private val onCancel: () -> Unit = {}) {
+ private val doubleTapMs: () -> Long = { 500 }, private val onCancel: () -> Unit = {},
+ private val holds: () -> Boolean = { true }) {
private class Press(val hold: Runnable, var held: Boolean = false, var used: Boolean = false)
private val handler = Handler(Looper.getMainLooper())
private val presses = mutableMapOf()
@@ -20,6 +21,11 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
fun onChordInput() {
if (presses.isEmpty()) return
+ if (!holds()) {
+ presses.values.forEach { handler.removeCallbacks(it.hold); it.used = true }
+ cancelTap()
+ return
+ }
val alreadyHeld = presses.values.any { it.held }
presses.values.forEach {
handler.removeCallbacks(it.hold)
@@ -43,6 +49,7 @@ class BackQamInput(private val setPressed: (Boolean) -> Unit, private val onTap:
handler.removeCallbacks(singleTap)
val hold = Runnable {
val press = presses[device] ?: return@Runnable
+ if (!holds()) { press.used = true; cancelTap(); return@Runnable }
val alreadyHeld = presses.values.any { it.held }
press.held = true
presses.values.forEach { it.used = true }
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index 38cd6c41e..fafd7202c 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -360,6 +360,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directCursor", on).apply()
}
+ fun backHold(context: Context): Boolean = prefs(context).getBoolean("backHold", true)
+
+ fun setBackHold(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean("backHold", on).apply()
+ }
+
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
fun setBackActionsInverted(context: Context, inverted: Boolean) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
index bce07bba5..73d53d57d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
@@ -15,7 +15,7 @@ object SettingsReset {
"dualScreenMode", "dualScreenLauncher", "dualScreenSessionControls",
"gameStoresEnabled", "gameStoresSpeedTier", "gameStoresParallel", "storesOpenTab", "storesShowMature",
"storesInstallLanguage",
- "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
+ "hud", "hudDeck", "hudFps", "hudBattery", "hudGraph", "hudColor", "hudOpacity", "__KEEP_droidDeckHud__", "__KEEP_droidDeckHudPos__", "backHold", "backActionsInverted", "backDoubleTapMs", "touch", "osc", "shape", "steamController",
"gamepadLeft", "gamepadRight", "pipAutoEnter", "unfoldedControls", "unfoldedControlsWithController",
"stretch16x9", "picturePosition", "railLabels",
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
index d81015d61..cd37ebc08 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
@@ -68,6 +68,7 @@ class ModeSettings(
val pipSupported: Boolean = false,
val pipAutoEnter: Boolean = false,
val oscMode: String?,
+ val backHold: Boolean = true,
val backActionsInverted: Boolean = false,
val directAudio: Boolean?,
val mic: Boolean?,
@@ -118,6 +119,7 @@ class ModeSettingsActions(
val onSteamDownloadsInBackground: (Boolean) -> Unit = {},
val onPipAutoEnter: (Boolean) -> Unit = {},
val onOsc: (String) -> Unit,
+ val onBackHold: (Boolean) -> Unit = {},
val onBackActionsInverted: (Boolean) -> Unit = {},
val onDirectAudio: (Boolean) -> Unit,
val onMic: (Boolean) -> Unit,
@@ -296,6 +298,10 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
stringResource(R.string.unfolded_controls_with_controller_hint),
s.unfoldedControlsWithController, onChange = a.onUnfoldedControlsWithController,
)
+ if (steam) ToggleRow(
+ host, "back-hold", stringResource(R.string.back_hold),
+ stringResource(R.string.back_hold_hint), s.backHold, onChange = a.onBackHold,
+ )
if (steam) ChoiceRow(
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
listOf(
@@ -303,7 +309,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
), s.backActionsInverted, onPick = a.onBackActionsInverted,
)
- if (steam && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
+ if (steam && s.backHold && s.steamController in listOf(SessionPrefs.CONTROLLER_DECK, SessionPrefs.CONTROLLER_STEAM)) {
Note(stringResource(R.string.back_native_qam_hint))
}
PhysicalShortcutRows()
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
index 6ff0ee910..fad54cf81 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
@@ -221,6 +221,8 @@ class DrawerActions(
val micAvailable: Boolean = false,
val micEnabled: Boolean = false,
val onMicEnabled: (Boolean) -> Unit = {},
+ val backHold: Boolean = true,
+ val onBackHold: (Boolean) -> Unit = {},
val backActionsInverted: Boolean,
val backDoubleTapMs: Int = 500,
val onBackDoubleTapMs: (Int) -> Unit = {},
@@ -641,6 +643,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
chipModifier = focus.track(page, "keyboard-button"), onChange = a.onKeyboardButton)
}
}
+ if (a.steam) ToggleRow(host, "back-hold", stringResource(R.string.back_hold),
+ stringResource(R.string.back_hold_hint), a.backHold,
+ chipModifier = focus.track(page, "back-hold"), onChange = a.onBackHold)
if (a.steam) ChoiceRow(host, "back-actions", stringResource(R.string.mode_back), null,
listOf(false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU)),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 7ce8449e4..9713cd463 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -299,6 +299,8 @@
وحدة تحكم Xbox 360
Deck: زر «الوصول السريع» والجيروسكوب. Steam Controller: الشيء نفسه، مع استشعار القبضة على الأزرار الخلفية. DualShock 4 وDualSense: الجيروسكوب، ولوحتا التتبع كلوحة اللمس الخاصة بها. Xbox 360: افتح «الوصول السريع» باستخدام Guide+A.
زر الرجوع
+ الضغط المطوّل على الرجوع لاختصارات Steam
+ الضغط المطوّل على الرجوع يفتح الوصول السريع أو ينشئ اختصارات Steam مع أزرار أخرى. أوقفه لتجاهل الضغط المطوّل؛ النقر والنقر المزدوج يبقيان يعملان.
اضغط مطوّلًا على زر الرجوع لمدة 700 ملّي ثانية، أو اضغط عليه مطوّلًا مع زر آخر لاختصارات Steam.
مهلة النقر المزدوج
%1$d ملّي ثانية
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 6419444b0..588972b08 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -299,6 +299,8 @@
Control de Xbox 360
Deck: botón de Quick Access y giroscopio. Steam Controller: lo mismo, con detección de agarre en los botones traseros. DualShock 4 y DualSense: giroscopio, y los trackpads como su panel táctil. Xbox 360: abre Quick Access con Guide+A.
Atrás
+ Mantener Atrás para atajos de Steam
+ Mantener Atrás abre Acceso rápido o crea combinaciones de Steam con otros botones. Desactívalo para ignorar Atrás mantenido; tocar y tocar dos veces siguen funcionando.
Mantén Atrás 700 ms, o mantén Atrás y pulsa otro botón para los atajos de Steam.
Intervalo de doble toque
%1$d ms
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index c77ba12ec..6ed8e2b25 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -299,6 +299,8 @@
Контролер Xbox 360
Deck: бутон Quick Access и гироскоп. Steam Controller: същото, с усещане за захват на задните бутони. DualShock 4 и DualSense: гироскоп, а тракпадовете служат като тъчпад. Xbox 360: отворете Quick Access с Guide+A.
Back
+ Задържане на Назад за преки пътища в Steam
+ Задържането на Назад отваря Бърз достъп или прави комбинации в Steam с други бутони. Изключете, за да се пренебрегва задържането; докосване и двойно докосване още работят.
Задръжте Back за 700 ms или задръжте Back и натиснете друг бутон за преки пътища на Steam.
Интервал за двойно докосване
%1$d ms
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 6399cbd72..e089858a8 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -299,6 +299,8 @@
Ovladač Xbox 360
Deck: tlačítko Quick Access a gyroskop. Steam Controller: totéž, navíc snímání úchopu na zadních tlačítkách. DualShock 4 a DualSense: gyroskop, trackpad slouží jako touchpad. Xbox 360: Quick Access otevřete kombinací Guide+A.
Zpět
+ Podržení Zpět pro zkratky Steam
+ Podržení Zpět otevře Rychlý přístup nebo vytváří kombinace Steam s dalšími tlačítky. Vypnutím se podržení Zpět ignoruje; klepnutí a dvojité klepnutí fungují dál.
Podržte Zpět 700 ms, nebo podržte Zpět a stiskněte jiné tlačítko pro zkratky Steamu.
Interval dvojitého klepnutí
%1$d ms
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 2897aef86..db489b5c7 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -299,6 +299,8 @@
Xbox 360-controller
Deck: Hurtig adgang-knap og gyro. Steam Controller: det samme, med gribefølsomme bagknapper. DualShock 4 og DualSense: gyro, og touchpaddene som deres touchpad. Xbox 360: åbn Hurtig adgang med Guide+A.
Tilbage
+ Hold Tilbage for Steam-genveje
+ Hold Tilbage for at åbne Hurtig adgang eller lave Steam-kombinationer med andre knapper. Slå fra for at ignorere holdt Tilbage; tryk og dobbelttryk virker stadig.
Hold Tilbage i 700 ms, eller hold Tilbage og tryk på en anden knap for Steam-genveje.
Tidsrum for dobbelttryk
%1$d ms
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 961f3af58..5f6ea9a06 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -299,6 +299,8 @@
Xbox-360-Controller
Deck: Schnellzugriffstaste und Gyro. Steam Controller: dasselbe, mit Griffsensor an den Rücktasten. DualShock 4 und DualSense: Gyro, die Trackpads dienen als deren Touchpad. Xbox 360: Schnellzugriff mit Guide+A öffnen.
Zurück
+ Zurück halten für Steam-Kürzel
+ Zurück halten öffnet den Schnellzugriff oder bildet mit anderen Tasten Steam-Kombinationen. Ausschalten, um gehaltenes Zurück zu ignorieren; Tippen und Doppeltippen funktionieren weiter.
Halte Zurück 700 ms lang gedrückt oder halte Zurück und drücke eine weitere Taste für Steam-Tastenkürzel.
Zeitfenster für Doppeltippen
%1$d ms
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index f9397e9da..0ed66d718 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -299,6 +299,8 @@
Χειριστήριο Xbox 360
Deck: κουμπί Quick Access και γυροσκόπιο. Steam Controller: το ίδιο, με αίσθηση λαβής στα πίσω κουμπιά. DualShock 4 και DualSense: γυροσκόπιο, και τα trackpad ως πινακίδα αφής τους. Xbox 360: ανοίξτε το Quick Access με Guide+A.
Πίσω
+ Παρατεταμένο Πίσω για συντομεύσεις Steam
+ Το παρατεταμένο Πίσω ανοίγει τη Γρήγορη πρόσβαση ή κάνει συνδυασμούς Steam με άλλα κουμπιά. Απενεργοποιήστε για να αγνοείται· το πάτημα και το διπλό πάτημα λειτουργούν.
Κρατήστε πατημένο το Πίσω για 700 ms ή κρατήστε το Πίσω και πατήστε άλλο κουμπί για συντομεύσεις Steam.
Διάστημα διπλού πατήματος
%1$d ms
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 9a6e5ebb2..353f4b71d 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -299,6 +299,8 @@
Mando Xbox 360
Deck: botón de Quick Access y giroscopio. Steam Controller: lo mismo, con detección de agarre en los botones traseros. DualShock 4 y DualSense: giroscopio, y los trackpads como su panel táctil. Xbox 360: abre Quick Access con Guide+A.
Atrás
+ Mantener Atrás para atajos de Steam
+ Mantener Atrás abre Acceso rápido o crea combinaciones de Steam con otros botones. Desactívalo para ignorar Atrás mantenido; pulsar y pulsar dos veces siguen funcionando.
Mantén Atrás 700 ms, o mantén Atrás y pulsa otro botón para los atajos de Steam.
Intervalo de doble toque
%1$d ms
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index fa12dea8e..341923973 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -299,6 +299,8 @@
Xbox 360 -ohjain
Deck: pikavalikkopainike ja gyroskooppi. Steam Controller: samat, ja takapainikkeissa puristustunnistus. DualShock 4 ja DualSense: gyroskooppi, ja kosketuslevyt toimivat kosketuslevynä. Xbox 360: avaa pikavalikko Guide+A:lla.
Back
+ Pidä Takaisin pohjassa Steam-pikanäppäimille
+ Takaisin pohjassa pitäminen avaa pikavalikon tai tekee Steam-yhdistelmiä muiden painikkeiden kanssa. Poista käytöstä ohittaaksesi pitkän painalluksen; napautus ja kaksoisnapautus toimivat yhä.
Pidä Back-painiketta 700 ms, tai pidä Back-painiketta ja paina toista painiketta Steam-pikanäppäimiä varten.
Kaksoisnapautuksen aikaikkuna
%1$d ms
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 7c6d888e7..5c2a78005 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -299,6 +299,8 @@
Manette Xbox 360
Deck : bouton d\'accès rapide et gyroscope. Steam Controller : idem, avec détection de la prise en main sur les boutons arrière. DualShock 4 et DualSense : gyroscope, et les trackpads servent de pavé tactile. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide.
Retour
+ Maintenir Retour pour les raccourcis Steam
+ Maintenir Retour ouvre l’accès rapide ou crée des combinaisons Steam avec d’autres boutons. Désactivez pour ignorer l’appui long ; l’appui simple et double fonctionnent toujours.
Maintenez Retour pendant 700 ms, ou maintenez Retour et appuyez sur un autre bouton pour les raccourcis Steam.
Délai du double appui
%1$d ms
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 72c0e39e2..ab6577ded 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -299,6 +299,8 @@
Xbox 360 कंट्रोलर
Deck: Quick Access बटन और gyro। Steam Controller: वही, साथ में पीछे के बटन पर ग्रिप सेंस। DualShock 4 और DualSense: gyro, और ट्रैकपैड उनके टचपैड के तौर पर। Xbox 360: Guide+A से Quick Access खोलें।
बैक
+ Steam शॉर्टकट के लिए बैक दबाकर रखें
+ बैक दबाकर रखने से क्विक एक्सेस खुलता है या दूसरे बटनों के साथ Steam कॉम्बो बनते हैं। दबाकर रखने को अनदेखा करने के लिए बंद करें; टैप और डबल-टैप काम करते रहेंगे।
Steam शॉर्टकट के लिए Back को 700 ms तक दबाए रखें, या Back दबाए रखकर कोई दूसरा बटन दबाएं।
डबल-टैप विंडो
%1$d ms
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index a0fd81a0f..9a783c5c6 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -299,6 +299,8 @@
Xbox 360 kontroller
Deck: Gyors hozzáférés gomb és gyroszkóp. Steam Controller: ugyanez, a hátsó gombok fogásérzékelésével. DualShock 4 és DualSense: gyroszkóp, és az érintőpadok mint touchpad. Xbox 360: a Gyors hozzáférés megnyitása Guide+A-val.
Vissza gomb
+ Vissza nyomva tartása Steam parancsokhoz
+ A Vissza nyomva tartása megnyitja a Gyors elérést, vagy más gombokkal Steam kombinációkat ad. Kapcsold ki, hogy a nyomva tartást figyelmen kívül hagyja; a koppintás és dupla koppintás továbbra is működik.
Tartsa lenyomva a Vissza gombot 700 ms-ig, vagy tartsa lenyomva a Vissza gombot és nyomjon meg egy másik gombot a Steam-parancsikonokhoz.
Dupla koppintás időablaka
%1$d ms
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index a6a239978..e63446f2c 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -299,6 +299,8 @@
Pengontrol Xbox 360
Deck: tombol Quick Access dan giroskop. Steam Controller: sama, dengan grip sense pada tombol belakang. DualShock 4 dan DualSense: giroskop, dan trackpad sebagai touchpad-nya. Xbox 360: buka Quick Access dengan Guide+A.
Kembali
+ Tahan Kembali untuk pintasan Steam
+ Menahan Kembali membuka Akses Cepat atau membuat kombinasi Steam dengan tombol lain. Matikan untuk mengabaikan Kembali yang ditahan; ketuk dan ketuk dua kali tetap berfungsi.
Tahan Back selama 700 ms, atau tahan Back dan tekan tombol lain untuk pintasan Steam.
Jeda ketuk dua kali
%1$d ms
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 659a5363e..59998204b 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -299,6 +299,8 @@
Controller Xbox 360
Deck: tasto Accesso rapido e giroscopio. Steam Controller: lo stesso, con il rilevamento della presa sui tasti posteriori. DualShock 4 e DualSense: giroscopio, con i trackpad usati come touchpad. Xbox 360: apri Accesso rapido con Guide+A.
Indietro
+ Tieni premuto Indietro per le scorciatoie Steam
+ Tenere premuto Indietro apre l’Accesso rapido o crea combinazioni Steam con altri tasti. Disattiva per ignorare la pressione prolungata; tocco e doppio tocco funzionano ancora.
Tieni premuto Indietro per 700 ms, oppure tieni premuto Indietro e premi un altro tasto per le scorciatoie di Steam.
Intervallo doppio tocco
%1$d ms
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 7f8aeee5d..7a836a27b 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -298,6 +298,8 @@
Xbox 360コントローラー
Deck:クイックアクセスボタンとジャイロを提供します。Xbox 360:Guide+Aでクイックアクセスを開きます。
戻る
+ 戻る長押しでSteamショートカット
+ 戻るを長押しするとクイックアクセスを開くか、他のボタンとSteamの同時押しになります。オフにすると長押しを無視します。タップとダブルタップは引き続き使えます。
戻るを700ミリ秒長押しするか、戻るを押したまま別のボタンを押すとSteamのショートカットを使用できます。
ダブルタップの間隔
%1$dミリ秒
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 19b9e2230..3c0461dfe 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -299,6 +299,8 @@
Xbox 360 컨트롤러
Deck: 빠른 액세스 버튼과 자이로스코프를 제공합니다. Xbox 360: Guide+A로 빠른 액세스를 엽니다.
뒤로
+ 뒤로 길게 눌러 Steam 단축키
+ 뒤로를 길게 누르면 빠른 실행이 열리거나 다른 버튼과 Steam 조합이 됩니다. 끄면 길게 누르기를 무시합니다. 탭과 두 번 탭은 계속 작동합니다.
뒤로 버튼을 700ms 동안 누르거나, 뒤로 버튼을 누른 채 다른 버튼을 누르면 Steam 단축키를 사용할 수 있습니다.
두 번 탭 간격
%1$dms
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index f79fa91d1..fa214eb0e 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -299,6 +299,8 @@
Pengawal Xbox 360
Deck: butang Quick Access dan giro. Steam Controller: sama, dengan grip sense pada butang belakang. DualShock 4 dan DualSense: giro, dan trackpad sebagai pad sentuhnya. Xbox 360: buka Quick Access dengan Guide+A.
Kembali
+ Tahan Kembali untuk pintasan Steam
+ Menahan Kembali membuka Akses Pantas atau membuat kombinasi Steam dengan butang lain. Matikan untuk mengabaikan Kembali yang ditahan; ketik dan ketik dua kali masih berfungsi.
Tahan Kembali selama 700 ms, atau tahan Kembali dan tekan butang lain untuk pintasan Steam.
Tempoh ketik dua kali
%1$d ms
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index 4afe5f21d..d4ac2bf8d 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -299,6 +299,8 @@
Xbox 360-kontroller
Deck: Hurtigmeny-knapp og gyro. Steam Controller: det samme, med gripesensor på bakknappene. DualShock 4 og DualSense: gyro, og styreflatene som styreflate. Xbox 360: åpne Hurtigmenyen med Guide+A.
Tilbake
+ Hold Tilbake for Steam-snarveier
+ Hold Tilbake for å åpne Hurtigtilgang eller lage Steam-kombinasjoner med andre knapper. Slå av for å ignorere holdt Tilbake; trykk og dobbelttrykk virker fortsatt.
Hold Tilbake i 700 ms, eller hold Tilbake og trykk en annen knapp for Steam-snarveier.
Tidsvindu for dobbelttrykk
%1$d ms
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index ca6bc0d39..3a572a5cd 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -299,6 +299,8 @@
Xbox 360-controller
Deck: knop Snelle toegang en gyro. Steam Controller: hetzelfde, met gripsensor op de achterknoppen. DualShock 4 en DualSense: gyro, en de trackpads als hun touchpad. Xbox 360: open Snelle toegang met Guide+A.
Terug
+ Terug ingedrukt houden voor Steam-sneltoetsen
+ Terug ingedrukt houden opent Snelle toegang of maakt Steam-combinaties met andere knoppen. Zet uit om ingedrukt houden te negeren; tikken en dubbeltikken blijven werken.
Houd Terug 700 ms ingedrukt, of houd Terug ingedrukt en druk op een andere knop voor Steam-sneltoetsen.
Tijdvenster voor dubbeltikken
%1$d ms
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 8773fbe90..a74569a2c 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -299,6 +299,8 @@
Kontroler Xbox 360
Deck: przycisk szybkiego dostępu i żyroskop. Steam Controller: to samo oraz wykrywanie chwytu na tylnych przyciskach. DualShock 4 i DualSense: żyroskop, a gładziki działają jako ich touchpad. Xbox 360: szybki dostęp otwiera Guide+A.
Wstecz
+ Przytrzymaj Wstecz dla skrótów Steam
+ Przytrzymanie Wstecz otwiera Szybki dostęp lub tworzy skróty Steam z innymi przyciskami. Wyłącz, aby ignorować przytrzymanie; dotknięcie i podwójne dotknięcie nadal działają.
Przytrzymaj Wstecz przez 700 ms lub przytrzymaj Wstecz i naciśnij inny przycisk, aby użyć skrótów Steam.
Czas podwójnego dotknięcia
%1$d ms
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 97b26b91a..b681280f6 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -299,6 +299,8 @@
Comando Xbox 360
Deck: botão de Acesso rápido e giroscópio. Steam Controller: o mesmo, com sensor de pega nos botões traseiros. DualShock 4 e DualSense: giroscópio, e os trackpads como o painel tátil do comando. Xbox 360: abra o Acesso rápido com Guide+A.
Voltar
+ Manter Voltar premido para atalhos do Steam
+ Manter Voltar premido abre o Acesso rápido ou cria combinações do Steam com outros botões. Desative para ignorar Voltar premido; toque e toque duplo continuam a funcionar.
Mantenha Voltar premido durante 700 ms, ou mantenha Voltar premido e prima outro botão para atalhos do Steam.
Intervalo do toque duplo
%1$d ms
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 506d5dd48..55174b6a8 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -299,6 +299,8 @@
Controle de Xbox 360
Deck: botão de Acesso rápido e giroscópio. Steam Controller: o mesmo, com detecção de pegada nos botões traseiros. DualShock 4 e DualSense: giroscópio, e os trackpads como o touchpad deles. Xbox 360: abra o Acesso rápido com Guide+A.
Voltar
+ Segurar Voltar para atalhos do Steam
+ Segurar Voltar abre o Acesso rápido ou cria combinações do Steam com outros botões. Desative para ignorar Voltar segurado; toque e toque duplo continuam funcionando.
Segure Voltar por 700 ms, ou segure Voltar e pressione outro botão para atalhos da Steam.
Intervalo do toque duplo
%1$d ms
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 4b17c3343..d089650b6 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -299,6 +299,8 @@
Controller Xbox 360
Deck: butonul Acces rapid și giroscopul. Steam Controller: la fel, cu senzori de strângere pe butoanele din spate. DualShock 4 și DualSense: giroscop, și trackpadurile ca touchpad propriu. Xbox 360: deschide Acces rapid cu Guide+A.
Înapoi
+ Ține apăsat Înapoi pentru comenzi Steam
+ Dacă ții apăsat Înapoi se deschide Acces rapid sau se fac combinații Steam cu alte butoane. Dezactivează pentru a ignora apăsarea lungă; atingerea și dubla atingere funcționează în continuare.
Ține apăsat Înapoi 700 ms sau ține apăsat Înapoi și apasă alt buton pentru comenzile rapide Steam.
Interval pentru atingere dublă
%1$d ms
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 3cd968dbc..0464dd558 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -299,6 +299,8 @@
Контроллер Xbox 360
Deck: кнопка быстрого доступа и гироскоп. Steam Controller: то же самое, с датчиком хвата на задних кнопках. DualShock 4 и DualSense: гироскоп, а трекпады работают как их тачпад. Xbox 360: используйте Guide+A для быстрого доступа.
Назад
+ Удержание «Назад» для сочетаний Steam
+ Удержание «Назад» открывает быстрый доступ или создаёт сочетания Steam с другими кнопками. Выключите, чтобы удержание игнорировалось; нажатие и двойное нажатие работают.
Удерживайте «Назад» 700 мс или удерживайте «Назад» и нажмите другую кнопку для сочетаний Steam.
Интервал двойного нажатия
%1$d мс
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 5cb4d9d35..bf5fd29ae 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -299,6 +299,8 @@
Xbox 360-kontroll
Deck: snabbåtkomstknappen och gyro. Steam Controller: samma, med greppkänsla på bakknapparna. DualShock 4 och DualSense: gyro, och styrplattorna som deras pekplatta. Xbox 360: öppna snabbåtkomst med Guide+A.
Back
+ Håll Bakåt för Steam-genvägar
+ Håll Bakåt för att öppna Snabbåtkomst eller göra Steam-kombinationer med andra knappar. Stäng av för att ignorera hållen Bakåt; tryck och dubbeltryck fungerar fortfarande.
Håll Back i 700 ms, eller håll Back och tryck på en annan knapp för Steam-genvägar.
Tidsfönster för dubbeltryck
%1$d ms
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 74c3612c7..fe417b62d 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -299,6 +299,8 @@
คอนโทรลเลอร์ Xbox 360
Deck: ปุ่ม Quick Access และไจโร Steam Controller: เหมือนกัน พร้อมเซนเซอร์แรงจับที่ปุ่มด้านหลัง DualShock 4 และ DualSense: ไจโร และแทร็กแพดทำหน้าที่เป็นทัชแพด Xbox 360: เปิด Quick Access ด้วย Guide+A
กลับ
+ กดค้างย้อนกลับสำหรับทางลัด Steam
+ กดค้างย้อนกลับเพื่อเปิดการเข้าถึงด่วนหรือใช้คอมโบ Steam กับปุ่มอื่น ปิดเพื่อไม่สนใจการกดค้าง การแตะและแตะสองครั้งยังใช้ได้
กดปุ่ม Back ค้างไว้ 700 ms หรือกด Back ค้างแล้วกดปุ่มอื่นเพื่อใช้ทางลัดของ Steam
ช่วงเวลาแตะสองครั้ง
%1$d ms
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 0c328650d..09255f6ee 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -299,6 +299,8 @@
Xbox 360 kumandası
Deck: Hızlı Erişim düğmesi ve jiroskop. Steam Controller: aynısı, ayrıca arka düğmelerde kavrama algılama. DualShock 4 ve DualSense: jiroskop; dokunmatik yüzeyler kumandanın dokunmatik paneli olur. Xbox 360: Hızlı Erişim\'i Guide+A ile açın.
Geri
+ Steam kısayolları için Geri\'yi basılı tut
+ Geri\'yi basılı tutmak Hızlı Erişim\'i açar veya diğer düğmelerle Steam kombinasyonları yapar. Basılı tutmayı yok saymak için kapatın; dokunma ve çift dokunma çalışmaya devam eder.
Steam kısayolları için Geri\'yi 700 ms basılı tutun veya Geri\'yi basılı tutup başka bir düğmeye basın.
Çift dokunma aralığı
%1$d ms
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 66abef06d..66462283e 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -299,6 +299,8 @@
Контролер Xbox 360
Deck: кнопка швидкого доступу й гіроскоп. Steam Controller: те саме плюс датчики хвату на задніх кнопках. DualShock 4 і DualSense: гіроскоп, а трекпади працюють як їхня сенсорна панель. Xbox 360: меню швидкого доступу відкривається комбінацією Guide+A.
Кнопка Back
+ Утримання «Назад» для комбінацій Steam
+ Утримання «Назад» відкриває швидкий доступ або створює комбінації Steam з іншими кнопками. Вимкніть, щоб утримання ігнорувалося; натискання й подвійне натискання працюють.
Утримуйте «Назад» 700 мс або утримуйте «Назад» і натисніть іншу кнопку для комбінацій Steam.
Інтервал подвійного натискання
%1$d мс
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 288c72c95..4a9ad4a12 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -299,6 +299,8 @@
Tay cầm Xbox 360
Deck: nút Quick Access và con quay hồi chuyển. Steam Controller: tương tự, cộng với cảm biến nắm ở các nút phía sau. DualShock 4 và DualSense: con quay hồi chuyển, và touchpad dùng làm bàn di chuột. Xbox 360: mở Quick Access bằng Guide+A.
Quay lại
+ Giữ Quay lại cho phím tắt Steam
+ Giữ Quay lại để mở Truy cập nhanh hoặc tạo tổ hợp Steam với nút khác. Tắt để bỏ qua thao tác giữ; nhấn và nhấn đúp vẫn hoạt động.
Giữ Back 700 ms, hoặc giữ Back và nhấn nút khác để dùng phím tắt Steam.
Khoảng thời gian nhấn đúp
%1$d ms
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index f41754afd..bca2f369d 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -298,6 +298,8 @@
Xbox 360 手柄
Deck:快捷访问按钮与陀螺仪。Xbox 360:用 Guide+A 打开快捷访问。
返回
+ 长按返回键使用 Steam 快捷操作
+ 长按返回键打开快速访问,或与其他按键组成 Steam 组合键。关闭后忽略长按;单击和双击仍然有效。
按住返回键 700 毫秒,或按住返回键再按另一个按钮以使用 Steam 快捷键。
双击间隔
%1$d 毫秒
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 203c7348f..8b95bd486 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -298,6 +298,8 @@
Xbox 360 控制器
Deck:提供快速存取按鈕和陀螺儀。Xbox 360:按 Guide+A 開啟快速存取。
返回
+ 長按返回鍵使用 Steam 快捷操作
+ 長按返回鍵開啟快速存取,或與其他按鍵組成 Steam 組合鍵。關閉後忽略長按;輕觸和輕觸兩下仍然有效。
按住返回鍵 700 毫秒,或按住返回鍵並按下其他按鈕以使用 Steam 快速鍵。
連按兩下間隔
%1$d 毫秒
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 5573328d7..8329c167f 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -298,6 +298,8 @@
Xbox 360 控制器
Deck:提供快速存取按鈕和陀螺儀。Xbox 360:使用 Guide+A 開啟快速存取。
返回
+ 長按返回鍵使用 Steam 快捷操作
+ 長按返回鍵開啟快速存取,或與其他按鍵組成 Steam 組合鍵。關閉後忽略長按;輕觸和輕觸兩下仍然有效。
按住返回鍵 700 毫秒,或按住返回鍵並按下其他按鈕以使用 Steam 快速鍵。
連按兩下間隔
%1$d 毫秒
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 203d5b0f9..d04f8f96c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -323,6 +323,8 @@
Xbox 360 controller
Deck: Quick Access button and gyro. Steam Controller: the same, with grip sense on the back buttons. DualShock 4 and DualSense: gyro, and the trackpads as their touchpad. Xbox 360: open Quick Access with Guide+A.
Back
+ Hold Back for Steam shortcuts
+ Holding Back opens Quick Access or makes Steam chords with other buttons. Turn off to ignore held Back; tap and double-tap still work.
Hold Back for 700 ms, or hold Back and press another button for Steam shortcuts.
Double-tap window
%1$d ms
diff --git a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt
index 5759556ae..db813c62d 100644
--- a/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/input/BackQamInputTest.kt
@@ -240,6 +240,30 @@ class BackQamInputTest {
assertTrue(taps.isEmpty())
}
+ @Test fun withHoldsOffTapsStillWorkButHoldsAndChordsDoNothing() {
+ val noHold = BackQamInput(changes::add, taps::add, holds = { false })
+ fun press(action: Int) = noHold.onKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_BACK), true)
+ press(KeyEvent.ACTION_DOWN)
+ press(KeyEvent.ACTION_UP)
+ advance(500)
+ assertEquals(listOf(false), taps)
+ press(KeyEvent.ACTION_DOWN)
+ press(KeyEvent.ACTION_UP)
+ press(KeyEvent.ACTION_DOWN)
+ press(KeyEvent.ACTION_UP)
+ assertEquals(listOf(false, true), taps)
+ taps.clear()
+ press(KeyEvent.ACTION_DOWN)
+ advance(2000)
+ assertTrue(press(KeyEvent.ACTION_UP))
+ press(KeyEvent.ACTION_DOWN)
+ noHold.onChordInput()
+ press(KeyEvent.ACTION_UP)
+ advance(1000)
+ assertTrue(changes.isEmpty())
+ assertTrue(taps.isEmpty())
+ }
+
private fun advance(millis: Long) = shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis))
private fun send(action: Int, repeat: Int = 0, device: Int = 4, enabled: Boolean = true) =
From 5b7e401b11505a922e68731d27b7d8727425b4b1 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 12:56:33 -0500
Subject: [PATCH 03/17] fix(ci): fork PR builds no longer fail pinning the
source tree (#1963)
---
.github/workflows/build.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f8d3a7527..f32d38f6a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -63,6 +63,8 @@ jobs:
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
+ # Partial clone: a merged blob that only exists in a fork's history can't be lazily fetched (GitHub: "not our ref"); merge-tree writes it locally first.
+ git merge-tree --write-tree HEAD origin/main >/dev/null || true
git merge --no-ff --no-edit origin/main
- name: Resolve trusted manifest base
id: trusted
@@ -100,6 +102,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
+ git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree
env:
@@ -158,6 +161,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
+ git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree
env:
@@ -367,6 +371,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
+ git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
@@ -669,6 +674,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
+ git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match
env:
@@ -722,6 +728,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
+ git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match
env:
From 8c09b2c6a8d4c2dfd869075e5f61420d06881b55 Mon Sep 17 00:00:00 2001
From: superboo07 <39241538+superboo07@users.noreply.github.com>
Date: Sun, 1 Nov 2026 12:58:09 -0500
Subject: [PATCH 04/17] Intergrated syncthing (#1442)
---
.github/workflows/build.yml | 25 +
.gitignore | 2 +
.gitmodules | 3 +
app/build.gradle | 1 +
app/src/main/AndroidManifest.xml | 11 +
.../main/java/com/droiddeck/launcher/App.kt | 1 +
.../launcher/session/OrphanReaper.kt | 3 +-
.../com/droiddeck/launcher/sync/SyncPrefs.kt | 43 ++
.../com/droiddeck/launcher/sync/Syncthing.kt | 545 ++++++++++++++++++
.../droiddeck/launcher/sync/SyncthingApi.kt | 58 ++
.../launcher/sync/SyncthingConfig.kt | 82 +++
.../droiddeck/launcher/sync/SyncthingGuard.kt | 30 +
.../droiddeck/launcher/sync/SyncthingHttp.kt | 82 +++
.../droiddeck/launcher/sync/SyncthingPorts.kt | 30 +
.../droiddeck/launcher/sync/SyncthingProxy.kt | 128 ++++
.../launcher/sync/SyncthingService.kt | 105 ++++
.../launcher/sync/SyncthingStatus.kt | 73 +++
.../launcher/sync/SyncthingWebActivity.kt | 139 +++++
.../com/droiddeck/launcher/ui/FrontEndRail.kt | 2 +-
.../droiddeck/launcher/ui/FrontEndScreen.kt | 4 +
.../droiddeck/launcher/ui/FrontEndSetup.kt | 20 +-
.../droiddeck/launcher/ui/SettingsWidgets.kt | 3 +-
.../droiddeck/launcher/ui/SyncAdvancedPage.kt | 109 ++++
.../droiddeck/launcher/ui/SyncFolderPicker.kt | 132 +++++
.../com/droiddeck/launcher/ui/SyncPanel.kt | 437 ++++++++++++++
app/src/main/res/values/strings.xml | 107 ++++
.../launcher/sync/SyncSnapshotTest.kt | 52 ++
.../launcher/sync/SyncthingConfigTest.kt | 83 +++
.../launcher/sync/SyncthingGuardTest.kt | 69 +++
.../launcher/sync/SyncthingHttpTest.kt | 30 +
.../launcher/sync/SyncthingPortsTest.kt | 18 +
tools/build_local.sh | 23 +-
tools/syncthing/build.sh | 48 ++
tools/syncthing/syncthing-android | 1 +
34 files changed, 2492 insertions(+), 7 deletions(-)
create mode 100644 .gitmodules
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt
create mode 100755 tools/syncthing/build.sh
create mode 160000 tools/syncthing/syncthing-android
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f32d38f6a..7f78c0ab4 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -311,6 +311,7 @@ jobs:
!/docs/releases/media/
sparse-checkout-cone-mode: false
persist-credentials: false
+ submodules: recursive
- name: Check human authorship
env:
@@ -524,6 +525,30 @@ jobs:
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
+ - name: Pin the Syncthing source
+ id: syncthing-src
+ run: |
+ set -euo pipefail
+ git submodule update --init --recursive tools/syncthing/syncthing-android
+ echo "commit=$(git -C tools/syncthing/syncthing-android/syncthing/src/github.com/syncthing/syncthing rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ - name: Cache Syncthing
+ id: syncthing
+ uses: actions/cache@v4
+ with:
+ path: app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so
+ key: syncthing-${{ env.NDK_VERSION }}-${{ hashFiles('tools/syncthing/build.sh', 'tools/syncthing/syncthing-android/gradle/libs.versions.toml') }}-${{ steps.syncthing-src.outputs.commit }}
+ - uses: actions/setup-go@v5
+ if: steps.syncthing.outputs.cache-hit != 'true'
+ with:
+ go-version: stable
+ cache: false
+ - name: Build Syncthing
+ if: steps.syncthing.outputs.cache-hit != 'true'
+ run: |
+ set -euo pipefail
+ export NDK="$ANDROID_HOME/ndk/$NDK_VERSION"
+ tools/syncthing/build.sh app/src/main/jniLibs/arm64-v8a
+
- name: Note when the proot pack pin is behind this build
run: |
set -euo pipefail
diff --git a/.gitignore b/.gitignore
index e5bdbfffc..bfeee7641 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,8 @@ app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/.proot-outputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
+app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so
+app/src/main/jniLibs/arm64-v8a/.syncthing-inputs
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
app/src/main/rust/stores/target/
app/src/main/assets/droiddeck-esync/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..50f7a038b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "tools/syncthing/syncthing-android"]
+ path = tools/syncthing/syncthing-android
+ url = https://github.com/researchxxl/syncthing-android
diff --git a/app/build.gradle b/app/build.gradle
index 92b5a906e..7459cc637 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -379,6 +379,7 @@ dependencies {
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
+ implementation 'com.google.zxing:core:3.5.3'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cc9fd6688..7d064b198 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -194,6 +194,13 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
+
+
+
Boolean = { true }): Int {
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
val spared = descendants(kept)
@@ -21,6 +21,7 @@ object OrphanReaper {
if (pid == me || pid <= 1 || pid in spared) return@forEach
if (uidOf(pid) != uid) return@forEach
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
+ if (!matching(name)) return@forEach
try {
android.os.Process.killProcess(pid)
killed++
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt
new file mode 100644
index 000000000..9f4937385
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncPrefs.kt
@@ -0,0 +1,43 @@
+package com.droiddeck.launcher.sync
+
+import android.content.Context
+import java.security.SecureRandom
+
+object SyncPrefs {
+ const val RUN_WHILE_OPEN = "open"
+ const val RUN_BACKGROUND = "background"
+
+ private fun prefs(context: Context) = context.getSharedPreferences("syncthing", Context.MODE_PRIVATE)
+
+ fun enabled(context: Context): Boolean = prefs(context).getBoolean("enabled", false)
+ fun setEnabled(context: Context, on: Boolean) { prefs(context).edit().putBoolean("enabled", on).apply() }
+
+ fun runMode(context: Context): String =
+ prefs(context).getString("run", RUN_WHILE_OPEN).takeIf { it == RUN_BACKGROUND } ?: RUN_WHILE_OPEN
+ fun setRunMode(context: Context, mode: String) { prefs(context).edit().putString("run", mode).apply() }
+
+ fun wifiOnly(context: Context): Boolean = prefs(context).getBoolean("wifi-only", false)
+ fun setWifiOnly(context: Context, on: Boolean) { prefs(context).edit().putBoolean("wifi-only", on).apply() }
+
+ fun pauseInGames(context: Context): Boolean = prefs(context).getBoolean("pause-in-games", false)
+ fun setPauseInGames(context: Context, on: Boolean) { prefs(context).edit().putBoolean("pause-in-games", on).apply() }
+
+ fun userPaused(context: Context): Boolean = prefs(context).getBoolean("paused", false)
+ fun setUserPaused(context: Context, on: Boolean) { prefs(context).edit().putBoolean("paused", on).apply() }
+
+ /** Whether DroidDeck has paused all devices through the API and must resume them. */
+ fun pauseApplied(context: Context): Boolean = prefs(context).getBoolean("pause-applied", false)
+ fun setPauseApplied(context: Context, on: Boolean) { prefs(context).edit().putBoolean("pause-applied", on).apply() }
+
+ fun guiPassword(context: Context): String = prefs(context).getString("gui-password", null) ?: randomToken().also {
+ prefs(context).edit().putString("gui-password", it).apply()
+ }
+
+ fun clearIdentity(context: Context) { prefs(context).edit().remove("gui-password").apply() }
+
+ internal fun randomToken(length: Int = 32): String {
+ val alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ val random = SecureRandom()
+ return String(CharArray(length) { alphabet[random.nextInt(alphabet.length)] })
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt b/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt
new file mode 100644
index 000000000..b1510c038
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/Syncthing.kt
@@ -0,0 +1,545 @@
+package com.droiddeck.launcher.sync
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Base64
+import android.util.Log
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.core.HostProcess
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.session.OrphanReaper
+import com.droiddeck.launcher.session.SessionState
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.security.SecureRandom
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+object Syncthing {
+ enum class Phase { OFF, STARTING, RUNNING, STOPPING, FAILED }
+ enum class AutoPause { WIFI, GAME }
+
+ const val FAILURE_MISSING = "binary-missing"
+ private const val FAILURE_POLICY = "Stopped because a folder outside DroidDeck's storage could not be paused"
+ private const val FAILURE_PAUSE = "Stopped because Syncthing could not be paused"
+ private const val FAILURE_STILL_RUNNING = "The previous Syncthing process did not exit"
+ private const val TAG = "Syncthing"
+ private const val GRACE_MS = 60_000L
+ private const val ENFORCE_ATTEMPTS = 3
+ private const val LOG_LIMIT = 4L shl 20
+ private const val FOLDER_ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
+
+ var enabled by mutableStateOf(false); private set
+ var phase by mutableStateOf(Phase.OFF); private set
+ var snapshot by mutableStateOf(null); private set
+ var failure by mutableStateOf(null); private set
+ var ports by mutableStateOf(null); private set
+ var apiKeyTail by mutableStateOf(null); private set
+ var userPaused by mutableStateOf(false); private set
+ var autoPause by mutableStateOf(null); private set
+ /** Whether Syncthing has confirmed every device is paused, as opposed to a pause only being requested. */
+ var devicesPaused by mutableStateOf(false); private set
+ val pauseRequested get() = userPaused || autoPause != null
+ /** Bumped to ask the Setup page to show the Sync tab. */
+ var openRequests by mutableStateOf(0)
+ private set
+ var openPending = false
+
+ fun requestOpen() {
+ openPending = true
+ openRequests++
+ }
+
+ val needsAttention get() = phase == Phase.FAILED || snapshot?.needsAttention == true
+
+ /** Called on the main thread whenever the state above changes; the background service follows it. */
+ @Volatile var onChange: (() -> Unit)? = null
+
+ private val main = Handler(Looper.getMainLooper())
+ private val worker = Executors.newSingleThreadExecutor { Thread(it, "syncthing") }
+ private lateinit var app: Context
+ @Volatile private var process: Process? = null
+ @Volatile private var api: SyncthingApi? = null
+ @Volatile private var generation = 0
+ @Volatile private var onWifi = true
+ private var visible = 0
+ private var foreground = false
+ private val watchers = AtomicInteger()
+ private val pauseFailures = AtomicInteger()
+ private val pauseLock = Any()
+ private val leaveForeground = Runnable { foreground = false; evaluate() }
+
+ fun home(context: Context) = File(context.filesDir, "syncthing")
+ fun folderRoot(context: Context) = File(context.getExternalFilesDir(null) ?: context.filesDir, "Sync")
+ fun logFile(context: Context) = File(home(context), "syncthing.log")
+ private fun config(context: Context) = File(home(context), "config.xml")
+ private fun guiSocket(context: Context) = File(home(context), "gui.sock")
+ private fun binary(context: Context) = File(context.applicationInfo.nativeLibraryDir, "libsyncthingnative.so")
+
+ /** DroidDeck's own storage: its private data directory and its folder on each shared volume. */
+ fun storageRoots(context: Context): List =
+ listOf(context.dataDir) + context.getExternalFilesDirs(null).filterNotNull().mapNotNull { it.parentFile }
+
+ fun guard(context: Context) = SyncthingGuard(folderRoot(context), storageRoots(context), listOf(home(context)))
+
+ fun init(application: Application) {
+ app = application.applicationContext
+ userPaused = SyncPrefs.userPaused(app)
+ enabled = SyncPrefs.enabled(app)
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, error ->
+ runCatching { process?.destroyForcibly() }
+ previous?.uncaughtException(thread, error) ?: Runtime.getRuntime().halt(2)
+ }
+ application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
+ override fun onActivityStarted(activity: Activity) {
+ visible++
+ main.removeCallbacks(leaveForeground)
+ if (!foreground) { foreground = true; evaluate() }
+ }
+ override fun onActivityStopped(activity: Activity) {
+ visible = (visible - 1).coerceAtLeast(0)
+ if (visible == 0) main.postDelayed(leaveForeground, GRACE_MS)
+ }
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
+ override fun onActivityResumed(activity: Activity) {}
+ override fun onActivityPaused(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ })
+ val connectivity = app.getSystemService(ConnectivityManager::class.java)
+ onWifi = connectivity?.activeNetwork?.let { connectivity.getNetworkCapabilities(it) }?.let(::isWifi) ?: false
+ runCatching {
+ connectivity?.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { networkChanged(isWifi(caps)) }
+ override fun onLost(network: Network) { networkChanged(false) }
+ })
+ }
+ evaluate()
+ }
+
+ private fun isWifi(caps: NetworkCapabilities) =
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
+
+ private fun networkChanged(wifi: Boolean) {
+ if (wifi == onWifi) return
+ onWifi = wifi
+ worker.execute { api?.let { applyPause(it) } }
+ }
+
+ /** Starts or stops Syncthing to match the settings and whether DroidDeck is on screen. */
+ fun evaluate() {
+ if (Looper.myLooper() != Looper.getMainLooper()) { main.post { evaluate() }; return }
+ val enabled = SyncPrefs.enabled(app)
+ val background = SyncPrefs.runMode(app) == SyncPrefs.RUN_BACKGROUND
+ if (enabled && background) SyncthingService.start(app) else SyncthingService.stop(app)
+ if (enabled && (background || foreground)) start() else stop()
+ }
+
+ fun setEnabled(context: Context, on: Boolean, forget: Boolean = false) {
+ if (on && !LinuxRuntime.isInstalled(context)) return
+ SyncPrefs.setEnabled(context, on)
+ enabled = on
+ if (on && phase == Phase.FAILED) phase = Phase.OFF
+ evaluate()
+ if (!on && forget) worker.execute {
+ if (process != null) return@execute
+ home(context).deleteRecursively()
+ SyncPrefs.clearIdentity(context)
+ SyncPrefs.setPauseApplied(context, false)
+ }
+ }
+
+ fun setPaused(context: Context, paused: Boolean) {
+ SyncPrefs.setUserPaused(context, paused)
+ userPaused = paused
+ changed()
+ worker.execute { api?.let { applyPause(it) } }
+ }
+
+ fun settingsChanged() {
+ evaluate()
+ worker.execute { api?.let { applyPause(it) } }
+ }
+
+ fun restart() {
+ stop()
+ evaluate()
+ }
+
+ fun reset(context: Context) {
+ stop()
+ worker.execute {
+ if (process != null) return@execute
+ home(context).deleteRecursively()
+ SyncPrefs.clearIdentity(context)
+ SyncPrefs.setPauseApplied(context, false)
+ main.post { evaluate() }
+ }
+ }
+
+ fun regenerateApiKey() {
+ worker.execute {
+ val current = api ?: return@execute
+ val key = SyncPrefs.randomToken()
+ runCatching { current.setApiKey(key) }.onSuccess {
+ api = SyncthingApi(current.socket, key)
+ main.post { apiKeyTail = key.takeLast(4); changed() }
+ }.onFailure { Log.w(TAG, "API key change failed", it) }
+ }
+ }
+
+ /** Adds a folder at [path] shared with [devices]; pass the offered folder's [id] to accept a share. */
+ fun addFolder(context: Context, path: File, label: String, devices: List, id: String = newFolderId(), onDone: (Boolean) -> Unit) {
+ worker.execute {
+ val ok = runCatching {
+ val client = api ?: error("Syncthing is not running")
+ check(guard(context).allows(path.path)) { "${path.path} is outside DroidDeck's storage" }
+ path.mkdirs()
+ client.addFolder(client.defaultFolder().apply {
+ put("id", id)
+ put("label", label)
+ put("path", path.path)
+ put("devices", JSONArray(devices.map { JSONObject().put("deviceID", it) }))
+ })
+ }.onFailure { Log.w(TAG, "add folder failed", it) }.isSuccess
+ main.post { onDone(ok) }
+ }
+ }
+
+ private fun newFolderId(): String {
+ val random = SecureRandom()
+ fun part() = (1..5).map { FOLDER_ID_CHARS[random.nextInt(FOLDER_ID_CHARS.length)] }.joinToString("")
+ return "${part()}-${part()}"
+ }
+
+ fun removeFolder(id: String) {
+ worker.execute { api?.let { a -> runCatching { a.removeFolder(id) }.onFailure { Log.w(TAG, "remove $id failed", it) } } }
+ }
+
+ /** Starts a loopback proxy for the in-app web UI, which can't reach Syncthing's private socket itself. */
+ fun webUiProxy(context: Context): SyncthingProxy? {
+ if (phase != Phase.RUNNING) return null
+ val token = Base64.encodeToString("${SyncthingConfig.GUI_USER}:${SyncPrefs.guiPassword(context)}".toByteArray(), Base64.NO_WRAP)
+ return runCatching { SyncthingProxy(guiSocket(context), "Basic $token") }.onFailure { Log.w(TAG, "web UI proxy failed", it) }.getOrNull()
+ }
+
+ /** UI screens showing live status call this so the status refreshes faster while they're visible. */
+ fun watch(): () -> Unit {
+ watchers.incrementAndGet()
+ return { watchers.decrementAndGet() }
+ }
+
+ private fun start() {
+ if (phase == Phase.STARTING || phase == Phase.RUNNING) return
+ if (phase == Phase.FAILED && failure == FAILURE_MISSING) return
+ val gen = ++generation
+ phase = Phase.STARTING
+ failure = null
+ changed()
+ worker.execute { launch(gen) }
+ }
+
+ private fun stop() {
+ if (phase == Phase.OFF) return
+ val gen = ++generation
+ phase = if (process != null || phase == Phase.STARTING) Phase.STOPPING else Phase.OFF
+ changed()
+ worker.execute {
+ shutdown()
+ val stopped = process == null
+ main.post {
+ if (gen != generation) return@post
+ if (stopped) { phase = Phase.OFF; snapshot = null; ports = null } else { phase = Phase.FAILED; failure = FAILURE_STILL_RUNNING }
+ changed()
+ }
+ }
+ }
+
+ private fun launch(gen: Int) {
+ val context = app
+ try {
+ if (!binary(context).isFile) throw SyncthingFailure(FAILURE_MISSING)
+ if (process != null) throw SyncthingFailure(FAILURE_STILL_RUNNING)
+ val name = binary(context).name
+ OrphanReaper.reap("stale Syncthing") { it.substringAfterLast('/') == name }
+ home(context).mkdirs()
+ folderRoot(context).mkdirs()
+ if (!config(context).isFile) generate(context)
+ val picked = SyncthingPortPicker.pick()
+ val guard = guard(context)
+ val paused = SyncPrefs.userPaused(context) || autoPauseReason() != null
+ SyncthingConfig.apply(config(context), picked, guiSocket(context), folderRoot(context), paused, guard::allows)
+ if (paused) SyncPrefs.setPauseApplied(context, true)
+ val key = SyncthingConfig.apiKey(config(context)) ?: throw SyncthingFailure("config.xml has no API key")
+ if (gen != generation) return
+ rotateLog(context)
+ val child = ProcessBuilder(binary(context).path, "serve", "--no-browser", "--no-restart")
+ .redirectErrorStream(true)
+ .also { environment(context, it.environment()) }
+ .start()
+ process = child
+ OrphanReaper.keep(HostProcess.pidOf(child))
+ pump(gen, child, logFile(context))
+ pauseFailures.set(0)
+ val client = SyncthingApi(guiSocket(context), key)
+ val deadline = System.currentTimeMillis() + 60_000
+ while (!client.ping()) {
+ if (gen != generation) return
+ if (!child.isAlive) throw SyncthingFailure("Syncthing exited during start (${child.exitValue()})")
+ if (System.currentTimeMillis() > deadline) throw SyncthingFailure("Syncthing did not answer on ${guiSocket(context).name}")
+ Thread.sleep(250)
+ }
+ api = client
+ main.post {
+ if (gen == generation) { ports = picked; apiKeyTail = key.takeLast(4); devicesPaused = paused; phase = Phase.RUNNING; changed() }
+ }
+ Thread({ poll(gen, guard) }, "syncthing-status").start()
+ Thread({ enforce(gen, guard) }, "syncthing-guard").start()
+ } catch (e: Exception) {
+ Log.w(TAG, "start failed", e)
+ shutdown()
+ val reason = (e as? SyncthingFailure)?.message ?: e.toString()
+ main.post { if (gen == generation) { phase = Phase.FAILED; failure = reason; changed() } }
+ }
+ }
+
+ private fun generate(context: Context) {
+ val output = File(home(context), "generate.log")
+ val child = ProcessBuilder(binary(context).path, "generate", "--gui-user", SyncthingConfig.GUI_USER, "--gui-password", "-")
+ .redirectErrorStream(true)
+ .redirectOutput(output)
+ .also { environment(context, it.environment()) }
+ .start()
+ val pid = HostProcess.pidOf(child)
+ OrphanReaper.keep(pid)
+ try {
+ child.outputStream.bufferedWriter().use { it.write(SyncPrefs.guiPassword(context) + "\n") }
+ if (!child.waitFor(60, TimeUnit.SECONDS) || child.exitValue() != 0 || !config(context).isFile) {
+ child.destroy()
+ val last = runCatching { output.readLines().lastOrNull { it.isNotBlank() } }.getOrNull().orEmpty()
+ throw SyncthingFailure("syncthing generate failed: $last")
+ }
+ } finally {
+ output.delete()
+ OrphanReaper.release(pid)
+ }
+ }
+
+ private fun environment(context: Context, env: MutableMap) {
+ listOf("LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH").forEach { env.remove(it) }
+ env["STHOMEDIR"] = home(context).path
+ env["HOME"] = folderRoot(context).path
+ env["STNOUPGRADE"] = "1"
+ env["STNODEFAULTFOLDER"] = "1"
+ env["STMONITORED"] = "1"
+ env["SQLITE_TMPDIR"] = context.cacheDir.path
+ env["STVERSIONEXTRA"] = "DroidDeck"
+ }
+
+ private fun rotateLog(context: Context) {
+ val log = logFile(context)
+ if (log.length() > 0) log.renameTo(File(log.parentFile, "syncthing.old.log"))
+ }
+
+ private fun pump(gen: Int, child: Process, log: File) {
+ Thread({
+ var last = ""
+ runCatching {
+ log.appendText("")
+ child.inputStream.bufferedReader().useLines { lines ->
+ lines.forEach { line ->
+ if (line.isNotBlank()) last = line
+ if (log.length() < LOG_LIMIT) log.appendText(line + "\n")
+ }
+ }
+ }
+ val code = runCatching { child.waitFor() }.getOrDefault(-1)
+ OrphanReaper.release(HostProcess.pidOf(child))
+ main.post {
+ if (process !== child) return@post
+ process = null
+ if (gen == generation && phase == Phase.RUNNING) {
+ api = null
+ phase = Phase.FAILED; failure = "Syncthing stopped ($code): $last"; snapshot = null; changed()
+ }
+ }
+ }, "syncthing-output").start()
+ }
+
+ private fun shutdown() {
+ val child = process ?: return
+ api?.let { runCatching { it.shutdown() } }
+ api = null
+ if (!child.waitFor(10, TimeUnit.SECONDS)) {
+ child.destroy()
+ if (!child.waitFor(3, TimeUnit.SECONDS)) {
+ child.destroyForcibly()
+ child.waitFor(3, TimeUnit.SECONDS)
+ }
+ }
+ if (child.isAlive) {
+ Log.w(TAG, "Syncthing did not exit")
+ return
+ }
+ process = null
+ }
+
+ private fun poll(gen: Int, guard: SyncthingGuard) {
+ var previous: Pair>? = null
+ var myId: String? = null
+ while (gen == generation) {
+ val client = api ?: return
+ applyPause(client)
+ try {
+ val id = myId ?: client.myId().also { myId = it }
+ val connections = client.connections()
+ val total = connections.optJSONObject("total")
+ val now = System.currentTimeMillis()
+ val bytes = (total?.optLong("inBytesTotal") ?: 0L) to (total?.optLong("outBytesTotal") ?: 0L)
+ val rates = previous?.let { (at, was) ->
+ val seconds = ((now - at) / 1000.0).coerceAtLeast(0.5)
+ ((bytes.first - was.first).coerceAtLeast(0) / seconds).toLong() to ((bytes.second - was.second).coerceAtLeast(0) / seconds).toLong()
+ } ?: (0L to 0L)
+ previous = now to bytes
+ val snap = collect(client, guard, id, connections, rates)
+ main.post { if (gen == generation) { snapshot = snap; changed() } }
+ } catch (e: PolicyFailure) {
+ failClosed(gen, e)
+ return
+ } catch (e: Exception) {
+ Log.d(TAG, "status poll failed: $e")
+ }
+ Thread.sleep(if (watchers.get() > 0) 2_000 else 10_000)
+ }
+ }
+
+ private fun collect(client: SyncthingApi, guard: SyncthingGuard, myId: String, connections: org.json.JSONObject, rates: Pair): SyncSnapshot {
+ val names = SyncthingParse.deviceNames(client.devices(), myId)
+ val config = client.folders()
+ val folders = (0 until config.length()).map { config.getJSONObject(it) }.map { f ->
+ val id = f.getString("id")
+ val path = f.optString("path")
+ val blocked = !guard.allows(path)
+ if (blocked && !f.optBoolean("paused")) pauseFolder(client, id)
+ val paused = blocked || f.optBoolean("paused")
+ val status = if (paused) null else runCatching { client.folderStatus(id) }.getOrElse { SyncthingParse.unreadableStatus() }
+ SyncthingParse.folder(f, status, blocked, names)
+ }
+ val conns = connections.optJSONObject("connections")
+ val stats = runCatching { client.deviceStats() }.getOrNull()
+ val devices = names.map { (id, name) ->
+ val c = conns?.optJSONObject(id)
+ val seen = stats?.optJSONObject(id)?.optString("lastSeen")?.let(::parseTime)
+ SyncDevice(id, name, c?.optBoolean("connected") == true, c?.optString("address")?.takeIf { it.isNotEmpty() }, seen)
+ }
+ return SyncSnapshot(
+ myId, folders, devices,
+ runCatching { SyncthingParse.pendingFolders(client.pendingFolders(), names) }.getOrDefault(emptyList()),
+ runCatching { SyncthingParse.pendingDevices(client.pendingDevices()) }.getOrDefault(emptyList()),
+ rates.first, rates.second,
+ )
+ }
+
+ private fun parseTime(s: String): Long? = runCatching {
+ java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli().takeIf { it > 86_400_000L }
+ }.getOrNull()
+
+ /** Pauses any folder outside DroidDeck's storage as soon as Syncthing saves its config, and stops Syncthing if it can't. */
+ private fun enforce(gen: Int, guard: SyncthingGuard) {
+ var since = 0L
+ var pending = true
+ var failures = 0
+ while (gen == generation) {
+ val client = api ?: return
+ try {
+ if (!pending) {
+ val (last, saved) = client.waitForConfigSaved(since, 60)
+ since = last
+ pending = saved
+ }
+ if (pending && gen == generation) {
+ applyPause(client)
+ val folders = client.folders()
+ for (i in 0 until folders.length()) {
+ val f = folders.getJSONObject(i)
+ if (!f.optBoolean("paused") && !guard.allows(f.optString("path"))) pauseFolder(client, f.getString("id"))
+ }
+ pending = false
+ }
+ failures = 0
+ } catch (e: PolicyFailure) {
+ failClosed(gen, e)
+ return
+ } catch (e: Exception) {
+ if (pending && ++failures >= ENFORCE_ATTEMPTS) {
+ failClosed(gen, PolicyFailure(e))
+ return
+ }
+ Thread.sleep(2_000)
+ }
+ }
+ }
+
+ private fun pauseFolder(client: SyncthingApi, id: String) {
+ try {
+ client.pauseFolder(id)
+ } catch (e: Exception) {
+ throw PolicyFailure(e)
+ }
+ }
+
+ private fun failClosed(gen: Int, cause: Exception, reason: String = FAILURE_POLICY) {
+ Log.w(TAG, reason, cause)
+ worker.execute {
+ if (gen != generation) return@execute
+ shutdown()
+ main.post { if (gen == generation) { phase = Phase.FAILED; failure = reason; snapshot = null; changed() } }
+ }
+ }
+
+ // Runs from the worker, poll and enforce threads; serialised so an older decision can't land after a newer one.
+ private fun applyPause(client: SyncthingApi) = synchronized(pauseLock) {
+ val context = app
+ val auto = autoPauseReason()
+ val want = SyncPrefs.userPaused(context) || auto != null
+ var held = SyncPrefs.pauseApplied(context) && !(want && hasUnpausedPeer(client))
+ if (want != held) {
+ runCatching { if (want) client.pauseAll() else client.resumeAll() }
+ .onSuccess { SyncPrefs.setPauseApplied(context, want); held = want; pauseFailures.set(0) }
+ .onFailure { Log.w(TAG, "pause change failed", it); if (want) pauseFailures.incrementAndGet() }
+ }
+ if (auto != autoPause || held != devicesPaused) main.post { autoPause = auto; devicesPaused = held; changed() }
+ if (want && !held && pauseFailures.get() >= ENFORCE_ATTEMPTS) failClosed(generation, IllegalStateException(FAILURE_PAUSE), FAILURE_PAUSE)
+ }
+
+ // An unreadable peer list counts as unpaused, so a required pause is reasserted rather than assumed.
+ private fun hasUnpausedPeer(client: SyncthingApi): Boolean = runCatching {
+ val myId = client.myId()
+ val devices = client.devices()
+ (0 until devices.length()).map { devices.getJSONObject(it) }
+ .any { it.getString("deviceID") != myId && !it.optBoolean("paused") }
+ }.getOrDefault(true)
+
+ private fun autoPauseReason(): AutoPause? = when {
+ SyncPrefs.wifiOnly(app) && !onWifi -> AutoPause.WIFI
+ SyncPrefs.pauseInGames(app) && SessionState.running -> AutoPause.GAME
+ else -> null
+ }
+
+ private fun changed() { onChange?.invoke() }
+
+ private class SyncthingFailure(message: String) : Exception(message)
+ private class PolicyFailure(cause: Exception) : Exception(cause)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt
new file mode 100644
index 000000000..9498bf4fb
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingApi.kt
@@ -0,0 +1,58 @@
+package com.droiddeck.launcher.sync
+
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.net.URLEncoder
+
+class SyncthingApi(val socket: File, private val apiKey: String) {
+ fun ping(): Boolean = runCatching { get("/rest/system/ping").optString("ping") == "pong" }.getOrDefault(false)
+ fun myId(): String = get("/rest/system/status").getString("myID")
+ fun folders(): JSONArray = getArray("/rest/config/folders")
+ fun devices(): JSONArray = getArray("/rest/config/devices")
+ fun folderStatus(id: String): JSONObject = get("/rest/db/status?folder=${enc(id)}")
+ fun connections(): JSONObject = get("/rest/system/connections")
+ fun deviceStats(): JSONObject = get("/rest/stats/device")
+ fun pendingDevices(): JSONObject = get("/rest/cluster/pending/devices")
+ fun pendingFolders(): JSONObject = get("/rest/cluster/pending/folders")
+
+ fun defaultFolder(): JSONObject = get("/rest/config/defaults/folder")
+ fun addFolder(folder: JSONObject) = send("POST", "/rest/config/folders", folder)
+ fun pauseFolder(id: String) = merge("/rest/config/folders/${enc(id)}") { put("paused", true) }
+ fun removeFolder(id: String) { send("DELETE", "/rest/config/folders/${enc(id)}", null) }
+ fun pauseAll() { send("POST", "/rest/system/pause", null) }
+ fun resumeAll() { send("POST", "/rest/system/resume", null) }
+ fun setApiKey(key: String) = merge("/rest/config/gui") { put("apiKey", key) }
+ fun shutdown() { send("POST", "/rest/system/shutdown", null) }
+
+ private fun merge(path: String, change: JSONObject.() -> Unit) = send("PUT", path, get(path).apply(change))
+
+ /** Long-polls for config saves; returns the last event id seen, or [since] on timeout. */
+ fun waitForConfigSaved(since: Long, timeoutSeconds: Int): Pair {
+ val events = JSONArray(request("GET", "/rest/events?events=ConfigSaved&since=$since&timeout=$timeoutSeconds", null, (timeoutSeconds + 10) * 1000))
+ if (events.length() == 0) return since to false
+ return events.getJSONObject(events.length() - 1).getLong("id") to true
+ }
+
+ private fun get(path: String) = JSONObject(request("GET", path, null, TIMEOUT_MS))
+ private fun getArray(path: String) = JSONArray(request("GET", path, null, TIMEOUT_MS))
+ private fun send(method: String, path: String, body: JSONObject?) { request(method, path, body, TIMEOUT_MS) }
+
+ private fun request(method: String, path: String, body: JSONObject?, timeoutMs: Int): String = LocalSocket().use { s ->
+ s.connect(LocalSocketAddress(socket.path, LocalSocketAddress.Namespace.FILESYSTEM))
+ s.soTimeout = timeoutMs
+ val headers = listOf("X-API-Key" to apiKey) + if (body != null) listOf("Content-Type" to "application/json") else emptyList()
+ SyncthingHttp.writeRequest(s.outputStream, method, path, headers, body?.toString()?.toByteArray())
+ val (code, text) = SyncthingHttp.readResponse(s.inputStream.buffered())
+ if (code !in 200..299) throw SyncthingApiException(code, text)
+ text
+ }
+
+ private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
+
+ companion object { private const val TIMEOUT_MS = 5_000 }
+}
+
+class SyncthingApiException(val code: Int, message: String) : Exception("HTTP $code: $message")
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt
new file mode 100644
index 000000000..15838ae48
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingConfig.kt
@@ -0,0 +1,82 @@
+package com.droiddeck.launcher.sync
+
+import org.w3c.dom.Document
+import org.w3c.dom.Element
+import java.io.File
+import java.io.IOException
+import java.io.StringReader
+import java.io.StringWriter
+import javax.xml.parsers.DocumentBuilderFactory
+import javax.xml.transform.OutputKeys
+import javax.xml.transform.TransformerFactory
+import javax.xml.transform.dom.DOMSource
+import javax.xml.transform.stream.StreamResult
+import org.xml.sax.InputSource
+
+object SyncthingConfig {
+ const val GUI_USER = "droiddeck"
+ private const val RELAYS = "dynamic+https://relays.syncthing.net/endpoint"
+
+ fun apiKey(file: File): String? = runCatching { apiKey(file.readText()) }.getOrNull()
+
+ internal fun apiKey(xml: String): String? =
+ parse(xml).documentElement.child("gui")?.child("apikey")?.textContent?.trim()?.takeIf { it.isNotEmpty() }
+
+ fun apply(file: File, ports: SyncthingPorts, guiSocket: File, folderRoot: File, pauseDevices: Boolean, allows: (String) -> Boolean) {
+ val staged = File(file.parentFile, "${file.name}.tmp")
+ try {
+ staged.writeText(apply(file.readText(), ports, guiSocket.path, folderRoot.path, pauseDevices, allows))
+ if (!staged.renameTo(file)) throw IOException("could not replace ${file.name}")
+ } finally {
+ staged.delete()
+ }
+ }
+
+ internal fun apply(xml: String, ports: SyncthingPorts, guiSocket: String, folderRoot: String, pauseDevices: Boolean = false, allows: (String) -> Boolean): String {
+ val doc = parse(xml)
+ val root = doc.documentElement
+ val gui = root.child("gui") ?: doc.createElement("gui").also { root.appendChild(it) }
+ gui.setAttribute("tls", "false")
+ // An absolute path makes Syncthing listen on a Unix socket, which other apps can't reach in our private storage.
+ gui.set("address", guiSocket)
+ gui.set("unixSocketPermissions", "600")
+ val options = root.child("options") ?: doc.createElement("options").also { root.appendChild(it) }
+ options.setList("listenAddress", listOf("tcp://:${ports.sync}", "quic://:${ports.sync}", RELAYS))
+ options.set("localAnnouncePort", ports.discovery.toString())
+ options.set("urAccepted", "-1")
+ options.set("crashReportingEnabled", "false")
+ options.set("autoUpgradeIntervalH", "0")
+ options.set("startBrowser", "false")
+ val defaults = root.child("defaults") ?: doc.createElement("defaults").also { root.appendChild(it) }
+ val folder = defaults.child("folder") ?: doc.createElement("folder").also { defaults.appendChild(it) }
+ folder.setAttribute("path", folderRoot)
+ root.children("folder").filter { !allows(it.getAttribute("path")) }.forEach { it.set("paused", "true") }
+ if (pauseDevices) root.children("device").forEach { it.set("paused", "true") }
+ return write(doc)
+ }
+
+ private fun parse(xml: String): Document =
+ DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(InputSource(StringReader(xml)))
+
+ private fun write(doc: Document): String = StringWriter().also {
+ TransformerFactory.newInstance().newTransformer().apply { setOutputProperty(OutputKeys.INDENT, "no") }
+ .transform(DOMSource(doc), StreamResult(it))
+ }.toString()
+
+ private fun Element.children(name: String): List =
+ (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == name }
+
+ private fun Element.child(name: String): Element? = children(name).firstOrNull()
+
+ private fun Element.set(name: String, value: String) = setList(name, listOf(value))
+
+ private fun Element.setList(name: String, values: List) {
+ val existing = children(name)
+ val anchor = existing.lastOrNull()?.nextSibling
+ existing.forEach { removeChild(it) }
+ values.forEach { value ->
+ val e = ownerDocument.createElement(name).apply { textContent = value }
+ if (anchor != null) insertBefore(e, anchor) else appendChild(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt
new file mode 100644
index 000000000..30d00a66b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingGuard.kt
@@ -0,0 +1,30 @@
+package com.droiddeck.launcher.sync
+
+import java.io.File
+
+/** Decides which Syncthing folder paths stay inside DroidDeck's own storage. */
+class SyncthingGuard(private val home: File, roots: List, forbidden: List) {
+ private val roots = roots.map { it.canonicalFile }
+ private val forbidden = forbidden.map { it.canonicalFile }
+
+ fun allows(path: String): Boolean {
+ val trimmed = path.trim()
+ if (trimmed.isEmpty()) return false
+ val expanded = when {
+ trimmed == "~" -> home
+ trimmed.startsWith("~/") -> File(home, trimmed.removePrefix("~/"))
+ else -> File(trimmed)
+ }
+ if (!expanded.isAbsolute) return false
+ val target = runCatching { expanded.canonicalFile }.getOrNull() ?: return false
+ return roots.any { within(target, it) } && forbidden.none { within(target, it) || within(it, target) }
+ }
+
+ /** Whether [dir] can be opened while looking for a folder: inside DroidDeck's storage and outside Syncthing's own files. */
+ fun browsable(dir: File): Boolean {
+ val target = runCatching { dir.canonicalFile }.getOrNull() ?: return false
+ return roots.any { within(target, it) } && forbidden.none { within(target, it) }
+ }
+
+ private fun within(target: File, root: File) = target == root || target.path.startsWith(root.path + File.separator)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt
new file mode 100644
index 000000000..4cee6c672
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingHttp.kt
@@ -0,0 +1,82 @@
+package com.droiddeck.launcher.sync
+
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+/** Just enough HTTP/1.1 to talk to Syncthing over its Unix socket, one request per connection. */
+internal object SyncthingHttp {
+ private const val LINE_LIMIT = 16 shl 10
+ private const val HEADER_LIMIT = 100
+
+ fun writeRequest(out: OutputStream, method: String, path: String, headers: List>, body: ByteArray?) {
+ val head = StringBuilder("$method $path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n")
+ headers.forEach { (name, value) -> head.append(name).append(": ").append(value).append("\r\n") }
+ head.append("Content-Length: ").append(body?.size ?: 0).append("\r\n\r\n")
+ out.write(head.toString().toByteArray(Charsets.ISO_8859_1))
+ body?.let(out::write)
+ out.flush()
+ }
+
+ fun readResponse(input: InputStream): Pair {
+ val status = readLine(input) ?: throw IOException("no response")
+ val code = status.split(' ').getOrNull(1)?.toIntOrNull() ?: throw IOException("bad status line: $status")
+ val headers = readHeaders(input)
+ fun header(name: String) = headers.lastOrNull { it.first.equals(name, ignoreCase = true) }?.second
+ val body = when {
+ header("Transfer-Encoding")?.contains("chunked", ignoreCase = true) == true -> chunked(input)
+ header("Content-Length") != null -> exactly(input, header("Content-Length")!!.trim().toInt())
+ else -> input.readBytes()
+ }
+ return code to String(body, Charsets.UTF_8)
+ }
+
+ /** Reads one CRLF-terminated line, or null at end of stream. */
+ fun readLine(input: InputStream): String? {
+ val line = ByteArrayOutputStream()
+ while (true) {
+ val b = input.read()
+ if (b == -1) return if (line.size() == 0) null else throw IOException("truncated line")
+ if (b == '\n'.code) break
+ if (line.size() >= LINE_LIMIT) throw IOException("line too long")
+ line.write(b)
+ }
+ return line.toString("ISO-8859-1").removeSuffix("\r")
+ }
+
+ fun readHeaders(input: InputStream): List> {
+ val headers = mutableListOf>()
+ while (true) {
+ val line = readLine(input) ?: throw IOException("truncated headers")
+ if (line.isEmpty()) return headers
+ if (headers.size >= HEADER_LIMIT) throw IOException("too many headers")
+ val colon = line.indexOf(':')
+ if (colon <= 0) throw IOException("bad header: $line")
+ headers += line.substring(0, colon).trim() to line.substring(colon + 1).trim()
+ }
+ }
+
+ private fun chunked(input: InputStream): ByteArray {
+ val out = ByteArrayOutputStream()
+ while (true) {
+ val size = readLine(input)?.substringBefore(';')?.trim()?.toIntOrNull(16) ?: throw IOException("bad chunk size")
+ if (size == 0) break
+ out.write(exactly(input, size))
+ readLine(input)
+ }
+ while (readLine(input)?.isNotEmpty() == true) Unit
+ return out.toByteArray()
+ }
+
+ private fun exactly(input: InputStream, size: Int): ByteArray {
+ val bytes = ByteArray(size)
+ var read = 0
+ while (read < size) {
+ val n = input.read(bytes, read, size - read)
+ if (n == -1) throw IOException("truncated body")
+ read += n
+ }
+ return bytes
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt
new file mode 100644
index 000000000..c8bf4cc7b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingPorts.kt
@@ -0,0 +1,30 @@
+package com.droiddeck.launcher.sync
+
+import java.net.DatagramSocket
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+
+data class SyncthingPorts(val sync: Int, val discovery: Int)
+
+object SyncthingPortPicker {
+ // Syncthing's defaults, plus the alternates Syncthing-Fork's debug build uses.
+ internal val RESERVED = setOf(8384, 8385, 22000, 22001, 21027, 21028)
+ internal const val SYNC = 22100
+ internal const val DISCOVERY = 21127
+ private const val SPAN = 50
+
+ fun pick(): SyncthingPorts = pick(::syncFree, ::udpFree)
+
+ internal fun pick(syncFree: (Int) -> Boolean, discoveryFree: (Int) -> Boolean) =
+ SyncthingPorts(first(SYNC, syncFree), first(DISCOVERY, discoveryFree))
+
+ private fun first(base: Int, free: (Int) -> Boolean): Int =
+ (base until base + SPAN).firstOrNull { it !in RESERVED && free(it) } ?: error("no free port in $base..${base + SPAN - 1}")
+
+ private fun tcpFree(port: Int): Boolean = runCatching {
+ ServerSocket().use { it.reuseAddress = false; it.bind(InetSocketAddress(port)) }
+ }.isSuccess
+
+ private fun udpFree(port: Int) = runCatching { DatagramSocket(port).close() }.isSuccess
+ private fun syncFree(port: Int) = tcpFree(port) && udpFree(port)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt
new file mode 100644
index 000000000..58779eb42
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingProxy.kt
@@ -0,0 +1,128 @@
+package com.droiddeck.launcher.sync
+
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import java.io.Closeable
+import java.io.File
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketTimeoutException
+import java.security.MessageDigest
+import java.util.Collections
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Semaphore
+
+/**
+ * Bridges the in-app WebView to Syncthing's Unix socket while the web UI is open. Any app can connect to a
+ * loopback port, so requests without this session's cookie are refused before they reach Syncthing.
+ */
+class SyncthingProxy(private val socket: File, private val auth: String) : Closeable {
+ // Android's getLoopbackAddress() is ::1, but the WebView is pointed at 127.0.0.1.
+ private val server = ServerSocket(0, 16, InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)))
+ private val token = SyncPrefs.randomToken()
+ val url = "http://127.0.0.1:${server.localPort}/"
+ val cookie = "$COOKIE=$token"
+ private val workers = Semaphore(MAX_WORKERS)
+ private val open: MutableSet = Collections.newSetFromMap(ConcurrentHashMap())
+
+ init {
+ Thread(::accept, "syncthing-proxy").apply { isDaemon = true }.start()
+ }
+
+ override fun close() {
+ runCatching { server.close() }
+ open.forEach { runCatching { it.close() } }
+ }
+
+ private fun track(c: Closeable) {
+ open += c
+ if (server.isClosed) runCatching { c.close() }
+ }
+
+ private fun accept() {
+ while (true) {
+ val client = runCatching { server.accept() }.getOrNull() ?: return
+ if (!workers.tryAcquire()) {
+ runCatching { client.close() }
+ continue
+ }
+ val worker = runCatching {
+ track(client)
+ Thread({ try { serve(client) } finally { open -= client; workers.release() } }, "syncthing-proxy-request").apply { isDaemon = true }.start()
+ }
+ if (worker.isFailure) { open -= client; workers.release(); runCatching { client.close() } }
+ }
+ }
+
+ private fun serve(client: Socket) = client.use {
+ runCatching {
+ val deadline = HeadDeadline(client, client.getInputStream(), System.nanoTime() + HEAD_TIMEOUT_MS * 1_000_000L)
+ val input = deadline.buffered()
+ val request = SyncthingHttp.readLine(input) ?: return@runCatching
+ val headers = SyncthingHttp.readHeaders(input)
+ if (!authorized(headers)) {
+ client.getOutputStream().write(FORBIDDEN)
+ return@runCatching
+ }
+ deadline.lift()
+ LocalSocket().use { upstream ->
+ track(upstream)
+ try { relay(client, input, request, headers, upstream) } finally { open -= upstream }
+ }
+ }
+ }
+
+ private fun relay(client: Socket, input: InputStream, request: String, headers: List>, upstream: LocalSocket) {
+ upstream.connect(LocalSocketAddress(socket.path, LocalSocketAddress.Namespace.FILESYSTEM))
+ val head = StringBuilder(request).append("\r\n")
+ headers.filter { it.first.lowercase() !in DROPPED }.forEach { (name, value) ->
+ val kept = if (name.equals("Cookie", ignoreCase = true)) withoutToken(value) else value
+ if (kept.isNotEmpty()) head.append(name).append(": ").append(kept).append("\r\n")
+ }
+ // One request per connection keeps every request behind serve's cookie check.
+ head.append("Authorization: ").append(auth).append("\r\nConnection: close\r\n\r\n")
+ upstream.outputStream.write(head.toString().toByteArray(Charsets.ISO_8859_1))
+ Thread({ runCatching { input.copyTo(upstream.outputStream) } }, "syncthing-proxy-body").apply { isDaemon = true }.start()
+ upstream.inputStream.copyTo(client.getOutputStream())
+ }
+
+ private fun cookies(header: String) = header.split(';').map { it.trim() }
+
+ private fun authorized(headers: List>): Boolean = headers
+ .filter { it.first.equals("Cookie", ignoreCase = true) }
+ .flatMap { cookies(it.second) }
+ .any { MessageDigest.isEqual(it.toByteArray(), cookie.toByteArray()) }
+
+ private fun withoutToken(header: String) = cookies(header).filter { !it.startsWith("$COOKIE=") }.joinToString("; ")
+
+ /** Bounds the whole request line and headers by one deadline, so a slow client can't hold a worker. */
+ private class HeadDeadline(private val client: Socket, input: InputStream, private val until: Long) : FilterInputStream(input) {
+ @Volatile private var lifted = false
+
+ fun lift() {
+ lifted = true
+ client.soTimeout = 0
+ }
+
+ override fun read(): Int { arm(); return super.read() }
+ override fun read(b: ByteArray, off: Int, len: Int): Int { arm(); return super.read(b, off, len) }
+
+ private fun arm() {
+ if (lifted) return
+ val left = (until - System.nanoTime()) / 1_000_000L
+ if (left <= 0) throw SocketTimeoutException("request head took too long")
+ client.soTimeout = left.toInt()
+ }
+ }
+
+ private companion object {
+ const val COOKIE = "droiddeck-proxy"
+ const val HEAD_TIMEOUT_MS = 10_000
+ const val MAX_WORKERS = 16
+ val DROPPED = setOf("authorization", "x-api-key", "connection", "proxy-connection", "keep-alive", "upgrade")
+ val FORBIDDEN = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".toByteArray()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt
new file mode 100644
index 000000000..1ca58487d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingService.kt
@@ -0,0 +1,105 @@
+package com.droiddeck.launcher.sync
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import com.droiddeck.launcher.FrontEndActivity
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.stores.download.StoreDownloadService
+
+/** Keeps the app process, and with it Syncthing, alive while "Run in the background" is chosen. */
+class SyncthingService : Service() {
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_PAUSE -> Syncthing.setPaused(this, !Syncthing.userPaused)
+ ACTION_OFF -> { Syncthing.setEnabled(this, false); return START_NOT_STICKY }
+ }
+ if (!SyncPrefs.enabled(this) || SyncPrefs.runMode(this) != SyncPrefs.RUN_BACKGROUND) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ val n = notification()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) startForeground(ID, n, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ else startForeground(ID, n)
+ Syncthing.onChange = { getSystemService(NotificationManager::class.java)?.notify(ID, notification()) }
+ Syncthing.evaluate()
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ Syncthing.onChange = null
+ super.onDestroy()
+ }
+
+ private fun notification(): Notification {
+ channel(this)
+ val snap = Syncthing.snapshot
+ val title = getString(when {
+ Syncthing.phase == Syncthing.Phase.FAILED -> R.string.sync_status_failed
+ Syncthing.phase != Syncthing.Phase.RUNNING -> R.string.sync_status_starting
+ Syncthing.pauseRequested && Syncthing.devicesPaused -> R.string.sync_status_paused
+ Syncthing.pauseRequested -> R.string.sync_status_pausing
+ Syncthing.devicesPaused -> R.string.sync_status_resuming
+ snap?.needsAttention == true -> R.string.sync_status_attention
+ snap?.syncing == true -> R.string.sync_status_syncing
+ else -> R.string.sync_status_up_to_date
+ })
+ val text = snap?.let {
+ getString(R.string.sync_summary, it.folders.size, it.devices.count { d -> d.connected }, it.devices.size)
+ }.orEmpty()
+ val open = PendingIntent.getActivity(this, 0, Intent(this, FrontEndActivity::class.java)
+ .putExtra(StoreDownloadService.EXTRA_NAV, NAV).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL) else @Suppress("DEPRECATION") Notification.Builder(this)
+ return builder.setSmallIcon(R.drawable.ic_stat_session)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .addAction(Notification.Action.Builder(null, getString(if (Syncthing.userPaused) R.string.sync_resume else R.string.sync_pause), action(ACTION_PAUSE, 1)).build())
+ .addAction(Notification.Action.Builder(null, getString(R.string.sync_turn_off), action(ACTION_OFF, 2)).build())
+ .build()
+ }
+
+ private fun action(name: String, code: Int) = PendingIntent.getService(this, code,
+ Intent(this, SyncthingService::class.java).setAction(name), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+
+ companion object {
+ const val NAV = "setup:sync"
+ private const val CHANNEL = "syncthing"
+ private const val ID = 9203
+ private const val ACTION_PAUSE = "droiddeck.sync.PAUSE"
+ private const val ACTION_OFF = "droiddeck.sync.OFF"
+ @Volatile private var running = false
+
+ private fun channel(context: Context) {
+ val nm = context.getSystemService(NotificationManager::class.java) ?: return
+ if (nm.getNotificationChannel(CHANNEL) == null) {
+ nm.createNotificationChannel(NotificationChannel(CHANNEL, context.getString(R.string.sync_notification_channel), NotificationManager.IMPORTANCE_LOW).apply {
+ setShowBadge(false)
+ })
+ }
+ }
+
+ fun start(context: Context) {
+ if (running) return
+ running = true
+ runCatching { context.startForegroundService(Intent(context, SyncthingService::class.java)) }.onFailure { running = false }
+ }
+
+ fun stop(context: Context) {
+ if (!running) return
+ running = false
+ context.stopService(Intent(context, SyncthingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt
new file mode 100644
index 000000000..b52415ed6
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingStatus.kt
@@ -0,0 +1,73 @@
+package com.droiddeck.launcher.sync
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class SyncFolder(
+ val id: String, val label: String, val path: String, val paused: Boolean, val blocked: Boolean,
+ val state: String, val globalBytes: Long, val needBytes: Long, val errors: Int, val sharedWith: List,
+) {
+ val syncing get() = !paused && !blocked && state in ACTIVE && needBytes > 0
+ val outOfSync get() = !paused && !blocked && (errors > 0 || state == "error" || (state == "idle" && needBytes > 0))
+
+ companion object { private val ACTIVE = setOf("syncing", "sync-preparing", "sync-waiting") }
+}
+
+data class SyncDevice(val id: String, val name: String, val connected: Boolean, val address: String?, val lastSeen: Long?)
+
+data class SyncPendingFolder(val id: String, val label: String, val offeredBy: String, val deviceId: String)
+
+data class SyncSnapshot(
+ val myId: String,
+ val folders: List,
+ val devices: List,
+ val pendingFolders: List,
+ val pendingDevices: List,
+ val inBytesPerSecond: Long,
+ val outBytesPerSecond: Long,
+) {
+ val blocked get() = folders.filter { it.blocked }
+ val needsAttention get() = blocked.isNotEmpty() || pendingFolders.isNotEmpty() || pendingDevices.isNotEmpty() || folders.any { it.outOfSync }
+ val syncing get() = folders.any { it.syncing }
+ val needBytes get() = folders.filter { it.syncing }.sumOf { it.needBytes }
+
+ /** Share of the syncing folders' data already in place, 0..100. */
+ val percent: Int get() {
+ val active = folders.filter { it.syncing }
+ val global = active.sumOf { it.globalBytes }
+ if (global <= 0) return 0
+ return (((global - active.sumOf { it.needBytes }).coerceAtLeast(0) * 100) / global).toInt().coerceIn(0, 99)
+ }
+}
+
+internal object SyncthingParse {
+ /** Stands in for a folder whose status request failed, so it shows as an error rather than as up to date. */
+ fun unreadableStatus(): JSONObject = JSONObject().put("state", "error").put("errors", 1)
+
+ fun folder(config: JSONObject, status: JSONObject?, blocked: Boolean, names: Map): SyncFolder {
+ val id = config.getString("id")
+ val shared = config.optJSONArray("devices")?.let { d -> (0 until d.length()).mapNotNull { names[d.getJSONObject(it).optString("deviceID")] } }.orEmpty()
+ return SyncFolder(
+ id, config.optString("label").ifEmpty { id }, config.optString("path"), blocked || config.optBoolean("paused"), blocked,
+ status?.optString("state").orEmpty(), status?.optLong("globalBytes") ?: 0, status?.optLong("needBytes") ?: 0,
+ (status?.optInt("errors") ?: 0) + (status?.optInt("pullErrors") ?: 0), shared,
+ )
+ }
+
+ fun deviceNames(devices: JSONArray, myId: String): Map = (0 until devices.length())
+ .map { devices.getJSONObject(it) }
+ .filter { it.getString("deviceID") != myId }
+ .associate { it.getString("deviceID") to it.optString("name").ifEmpty { it.getString("deviceID").take(7) } }
+
+ fun pendingDevices(json: JSONObject): List =
+ json.keys().asSequence().map { id -> json.optJSONObject(id)?.optString("name").orEmpty().ifEmpty { id.take(7) } }.toList()
+
+ fun pendingFolders(json: JSONObject, names: Map): List =
+ json.keys().asSequence().flatMap { folderId ->
+ val offeredBy = json.getJSONObject(folderId).optJSONObject("offeredBy") ?: JSONObject()
+ offeredBy.keys().asSequence().map { device ->
+ val label = offeredBy.getJSONObject(device).optString("label").ifEmpty { folderId }
+ SyncPendingFolder(folderId, label, names[device] ?: device.take(7), device)
+ }
+ }.toList()
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt
new file mode 100644
index 000000000..7d26ab159
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/sync/SyncthingWebActivity.kt
@@ -0,0 +1,139 @@
+package com.droiddeck.launcher.sync
+
+import android.annotation.SuppressLint
+import android.net.Uri
+import android.os.Bundle
+import android.webkit.CookieManager
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.ui.BackLink
+import com.droiddeck.launcher.ui.DroidDeckTheme
+import com.droiddeck.launcher.ui.LocalPalette
+import com.droiddeck.launcher.ui.SecondaryButton
+
+/** Syncthing's own web UI, signed in automatically and kept on the loopback address. */
+class SyncthingWebActivity : ComponentActivity() {
+ private var web: WebView? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) { Screen() } }
+ }
+
+ override fun onDestroy() {
+ web?.destroy()
+ web = null
+ super.onDestroy()
+ }
+
+ @Composable
+ private fun Screen() {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val running = Syncthing.phase == Syncthing.Phase.RUNNING
+ val target = remember(running) { if (running) Syncthing.webUiProxy(this) else null }
+ DisposableEffect(target) { onDispose { target?.close() } }
+ LaunchedEffect(Syncthing.phase) { if (Syncthing.phase == Syncthing.Phase.OFF) finish() }
+ BackHandler { web?.takeIf { it.canGoBack() }?.goBack() ?: finish() }
+ Column(Modifier.fillMaxSize().background(colors.background)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().background(colors.surface).padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ BackLink(stringResource(R.string.mode_back), compact = true) { finish() }
+ Text(stringResource(R.string.sync_web_title), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
+ Box(Modifier.weight(1f))
+ target?.let { Text(Uri.parse(it.url).authority.orEmpty(), fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) }
+ SecondaryButton(stringResource(R.string.sync_web_reload), compact = true) { web?.reload() }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ if (target == null) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.sync_not_running), fontSize = 14.sp, color = colors.onSurfaceVariant)
+ }
+ } else {
+ val view = remember(target) { webView(target.url, target.cookie) }
+ AndroidView({ view }, Modifier.fillMaxSize())
+ }
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun webView(url: String, cookie: String): WebView = WebView(this).apply {
+ web?.destroy()
+ web = this
+ setBackgroundColor(0xFF0A0B0D.toInt())
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.allowFileAccess = false
+ settings.allowContentAccess = false
+ val cookies = CookieManager.getInstance()
+ cookies.setAcceptCookie(true)
+ val origin = Uri.parse(url).authority
+ webViewClient = object : WebViewClient() {
+ // The proxy cookie isn't port-scoped, so nothing else on loopback may receive it.
+ override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
+ if (request.url.authority == origin) null else WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), null)
+
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
+ request.url.authority != origin
+
+ override fun onPageFinished(view: WebView, url: String) {
+ view.evaluateJavascript(lockFolderPath(getString(R.string.sync_web_path_locked)), null)
+ }
+ }
+ cookies.setCookie(url, "$cookie; HttpOnly; SameSite=Strict") { loadUrl(url) }
+ }
+}
+
+/** Keeps Syncthing's folder path field read-only, so new folders get the default path inside DroidDeck's Sync folder. */
+private fun lockFolderPath(note: String) = """
+ (function (note) {
+ if (window.droiddeckPathLock) return;
+ window.droiddeckPathLock = true;
+ function lock() {
+ var input = document.getElementById('folderPath');
+ if (!input) return;
+ if (!input.readOnly) input.readOnly = true;
+ if (input.hasAttribute('list')) input.removeAttribute('list');
+ if (!document.getElementById('droiddeck-path-note')) {
+ var p = document.createElement('p');
+ p.id = 'droiddeck-path-note';
+ p.className = 'help-block';
+ p.textContent = note;
+ input.parentNode.insertBefore(p, input.nextSibling);
+ }
+ }
+ new MutationObserver(lock).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['readonly'] });
+ lock();
+ })(${org.json.JSONObject.quote(note)});
+""".trimIndent()
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
index dcd1f50ff..ecc597c93 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
@@ -203,7 +203,7 @@ internal fun railLabelIcon(key: String): Pair = when (key) {
}
internal fun setupNeedsAttention(s: FrontEndState): Boolean = !s.ready || (s.available != null && s.available != s.installed) ||
- PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
+ PhantomProcessLimit.blocksSteam(s.phantomProcessStatus) || com.droiddeck.launcher.sync.Syncthing.needsAttention
@Composable
private fun NavigationItems(
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index 6360336da..23d836912 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -435,6 +435,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
"stores:downloads" -> { com.droiddeck.launcher.stores.StoresState.openDownloads = true; selected = "stores" }
"components" -> a.onComponents(true)
"setup:launcher" -> { SetupTabs.openLauncher = true; selected = "setup" }
+ com.droiddeck.launcher.sync.SyncthingService.NAV -> com.droiddeck.launcher.sync.Syncthing.requestOpen()
"setup:integration" -> { SetupTabs.openOverview = true; selected = "setup" }
else -> selected = target
}
@@ -554,6 +555,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
if (com.droiddeck.launcher.storage.StorageState.openPending) showRailPage("setup", true)
}
+ LaunchedEffect(com.droiddeck.launcher.sync.Syncthing.openRequests) {
+ if (com.droiddeck.launcher.sync.Syncthing.openPending) showRailPage("setup", true)
+ }
val onRailFocus: (String) -> Unit = { key ->
if (key != railSelection) showRailPage(key, false)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index 383c5356a..dfe09a03e 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -93,7 +93,8 @@ internal object SetupTabs {
var openLauncher = false
var openOverview = false
}
-private const val GLASSES_TAB = 5
+private const val GLASSES_TAB = 6
+private const val SYNC_TAB = 4
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
@@ -190,7 +191,7 @@ internal fun SetupPanel(
onDismiss = { editDownloadProxy = false },
onSave = { a.onDownloadProxy(it); editDownloadProxy = false },
)
- val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses))
+ val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_sync), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses))
var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) }
SetupTabs.openLauncher = false
if (SetupTabs.openOverview) { tab = 0; SetupTabs.openOverview = false }
@@ -210,6 +211,18 @@ internal fun SetupPanel(
a.onRefreshPhantomStatus()
}
}
+ LaunchedEffect(com.droiddeck.launcher.sync.Syncthing.openRequests) {
+ if (com.droiddeck.launcher.sync.Syncthing.openPending) {
+ com.droiddeck.launcher.sync.Syncthing.openPending = false
+ tab = SYNC_TAB
+ }
+ }
+ var syncAdvanced by rememberSaveable { mutableStateOf(false) }
+ if (syncAdvanced) {
+ androidx.activity.compose.BackHandler { syncAdvanced = false }
+ SyncAdvancedPage(host) { syncAdvanced = false }
+ return
+ }
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
@@ -492,7 +505,8 @@ internal fun SetupPanel(
}
}
STORAGE_TAB -> StoragePanel(s, a)
- 4 -> {
+ SYNC_TAB -> SyncPanel(host, runtimeOk) { syncAdvanced = true }
+ 5 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt
index 50df03a74..b389992cf 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt
@@ -489,10 +489,11 @@ fun ChoiceRow(
options: List>, selected: T, enabled: Boolean = true, note: String? = null,
chipModifier: Modifier = Modifier,
hintLines: Int? = null,
+ stacked: Boolean = false,
onPick: (T) -> Unit,
) {
val open = host.open == key
- SettingsRow(label, hint, highlighted = open, hintLines = hintLines) {
+ SettingsRow(label, hint, highlighted = open, hintLines = hintLines, stacked = stacked) {
Box {
ValueChip(options.firstOrNull { it.first == selected }?.second ?: "-", open, enabled, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label, note = note) { firstItemFocus ->
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt
new file mode 100644
index 000000000..332be2b0d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncAdvancedPage.kt
@@ -0,0 +1,109 @@
+package com.droiddeck.launcher.ui
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.FileProvider
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.sync.SyncPrefs
+import com.droiddeck.launcher.sync.Syncthing
+import java.io.File
+
+@Composable
+internal fun SyncAdvancedPage(host: MenuHost, onBack: () -> Unit) {
+ val ctx = LocalContext.current
+ var runMode by remember { mutableStateOf(SyncPrefs.runMode(ctx)) }
+ var wifiOnly by remember { mutableStateOf(SyncPrefs.wifiOnly(ctx)) }
+ var pauseInGames by remember { mutableStateOf(SyncPrefs.pauseInGames(ctx)) }
+ var confirmReset by rememberSaveable { mutableStateOf(false) }
+ if (confirmReset) ResetDialog(onDismiss = { confirmReset = false }) { Syncthing.reset(ctx) }
+ val ports = Syncthing.ports
+ SettingsPage(host, stringResource(R.string.sync_advanced), onBack, eyebrow = stringResource(R.string.setup_tab_sync)) {
+ PairedColumns(
+ {
+ SettingsGroup(stringResource(R.string.sync_ports)) {
+ PortRow(stringResource(R.string.sync_port_sync), ports?.sync?.toString())
+ PortRow(stringResource(R.string.sync_port_discovery), ports?.discovery?.toString())
+ ActionRow(stringResource(R.string.sync_repick), stringResource(R.string.sync_repick_hint), stringResource(R.string.sync_repick_button), onClick = { Syncthing.restart() })
+ }
+ },
+ {
+ SettingsGroup(stringResource(R.string.sync_running_group)) {
+ ChoiceRow(
+ host, "sync-run", stringResource(R.string.sync_run), null,
+ listOf(SyncPrefs.RUN_WHILE_OPEN to stringResource(R.string.sync_run_open), SyncPrefs.RUN_BACKGROUND to stringResource(R.string.sync_run_background)),
+ runMode, stacked = true,
+ ) { SyncPrefs.setRunMode(ctx, it); runMode = it; Syncthing.settingsChanged() }
+ ToggleRow(host, "sync-wifi", stringResource(R.string.sync_wifi_only), null, wifiOnly) {
+ SyncPrefs.setWifiOnly(ctx, it); wifiOnly = it; Syncthing.settingsChanged()
+ }
+ ToggleRow(host, "sync-games", stringResource(R.string.sync_pause_games), null, pauseInGames) {
+ SyncPrefs.setPauseInGames(ctx, it); pauseInGames = it; Syncthing.settingsChanged()
+ }
+ ActionRow(stringResource(R.string.sync_logs), stringResource(R.string.sync_logs_hint), stringResource(R.string.sync_logs_share), onClick = { shareLog(ctx) })
+ }
+ },
+ )
+ SettingsGroup(stringResource(R.string.sync_reset_group)) {
+ SettingsRow(stringResource(R.string.sync_api_key), Syncthing.apiKeyTail?.let { "••••••••••••••••••••$it" }) {
+ SecondaryButton(stringResource(R.string.sync_regenerate), enabled = Syncthing.phase == Syncthing.Phase.RUNNING, compact = true) { Syncthing.regenerateApiKey() }
+ }
+ ActionRow(stringResource(R.string.sync_reset), stringResource(R.string.sync_reset_hint), stringResource(R.string.sync_reset_button), onClick = { confirmReset = true })
+ }
+ }
+}
+
+@Composable
+private fun PortRow(label: String, value: String?) {
+ val colors = MaterialTheme.colorScheme
+ SettingsRow(label, null) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(value ?: "—", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace, color = colors.onBackground)
+ Text(stringResource(R.string.common_auto).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun ResetDialog(onDismiss: () -> Unit, onReset: () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val shown = rememberShown(onDismiss)
+ val close = { shown.targetState = false }
+ AppDialog(shown, close, "syncReset", wide = false, maxWidth = 480.dp) {
+ DialogTitleRow(stringResource(R.string.sync_reset_confirm_title), onClose = close)
+ Text(stringResource(R.string.sync_reset_hint), fontSize = 13.5.sp, color = colors.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) {
+ SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close)
+ PrimaryButton(stringResource(R.string.sync_reset_button), compact = true) { onReset(); close() }
+ }
+ }
+}
+
+private fun shareLog(context: Context) {
+ val log = Syncthing.logFile(context).takeIf { it.isFile } ?: return
+ val out = File(File(context.cacheDir, "share").apply { mkdirs() }, "syncthing-log.txt")
+ log.copyTo(out, overwrite = true)
+ val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", out)
+ val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_STREAM, uri).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ send.clipData = ClipData.newRawUri(out.name, uri)
+ context.startActivity(Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION))
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt
new file mode 100644
index 000000000..26560e047
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncFolderPicker.kt
@@ -0,0 +1,132 @@
+package com.droiddeck.launcher.ui
+
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.CreateNewFolder
+import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material.icons.outlined.FolderSpecial
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material.icons.outlined.SdCard
+import androidx.compose.material.icons.outlined.Smartphone
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.sync.Syncthing
+import java.io.File
+
+internal class SyncLocation(val root: File, val label: String, val icon: ImageVector)
+
+/** The Linux runtime's /root, which is HOME for everything DroidDeck runs in the container. */
+internal fun containerHome(context: Context) = File(LinuxRuntime.rootDir(context), "root")
+
+internal fun syncLocations(context: Context): List {
+ val volumes = pickVolumes(context)
+ val roots = Syncthing.storageRoots(context)
+ val shared = roots.drop(1).mapIndexed { i, root ->
+ val volume = volumes.firstOrNull { root.path.startsWith(it.root.path + "/") }
+ if (i == 0) SyncLocation(root, context.getString(R.string.sync_location_shared), Icons.Outlined.Smartphone)
+ else SyncLocation(root, context.getString(R.string.sync_location_card, volume?.label ?: root.path), Icons.Outlined.SdCard)
+ }
+ val home = containerHome(context).takeIf { it.isDirectory }?.let { SyncLocation(it, context.getString(R.string.sync_location_home), Icons.Outlined.Home) }
+ return listOfNotNull(home, SyncLocation(Syncthing.folderRoot(context), context.getString(R.string.sync_location_sync), Icons.Outlined.FolderSpecial)) +
+ shared + SyncLocation(roots.first(), context.getString(R.string.sync_location_app), Icons.Outlined.Folder)
+}
+
+/** Browses only DroidDeck's own storage and returns a folder Syncthing is allowed to use. */
+@Composable
+internal fun SyncFolderPicker(title: String, suggestedName: String?, onDismiss: () -> Unit, onPick: (File) -> Unit) {
+ val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val density = LocalDensity.current
+ val shown = rememberShown(onDismiss)
+ val close = { shown.targetState = false }
+ val locations = remember { syncLocations(ctx) }
+ val guard = remember { Syncthing.guard(ctx) }
+ fun locationOf(d: File) = locations.filter { d.path == it.root.path || d.path.startsWith(it.root.path + "/") }.maxByOrNull { it.root.path.length }
+ var dir by remember { mutableStateOf(containerHome(ctx).takeIf { it.isDirectory } ?: Syncthing.folderRoot(ctx).also { it.mkdirs() }) }
+ var refresh by remember { mutableIntStateOf(0) }
+ var naming by remember { mutableStateOf(false) }
+ val location = dir?.let { locationOf(it) }
+ val up = { dir = dir?.let { d -> if (location == null || d.path == location.root.path) null else d.parentFile } }
+ val back = { if (dir == null) close() else up() }
+ val first = remember(dir) { FocusRequester() }
+ LaunchedEffect(dir) { focusWithinFrames({ false }) { first } }
+ val folders = remember(dir, refresh) {
+ dir?.listFiles()?.filter { it.isDirectory && it.canRead() && guard.browsable(it) }
+ ?.sortedBy { it.name.lowercase() }.orEmpty()
+ }
+ var locked by remember { mutableStateOf(null) }
+ if (naming) {
+ TextEntryDialog(stringResource(R.string.sync_new_folder), suggestedName.orEmpty(), onDismiss = { naming = false }) { name ->
+ val parent = dir
+ if (parent != null && validName(name)) {
+ val created = File(parent, name)
+ if (created.isDirectory || created.mkdirs()) { dir = created; refresh++ }
+ }
+ }
+ }
+ AppDialog(
+ shown, close, "syncFolderPicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back,
+ modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } },
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
+ DialogTitleRow(title, onClose = close)
+ if (dir != null && location != null) {
+ val crumbs = listOf(location.label) + dir!!.path.removePrefix(location.root.path).split('/').filter { it.isNotEmpty() }
+ Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.7f).dp
+ Column(
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()).padding(top = 4.dp),
+ ) {
+ if (dir == null) {
+ locations.forEachIndexed { i, l ->
+ key(l.root.path) { PickerRow(l.icon, l.label, null, tall = true, focus = if (i == 0) first else null) { dir = l.root.also { it.mkdirs() } } }
+ }
+ } else {
+ PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up)
+ PickerRow(Icons.Filled.Check, stringResource(R.string.sync_use_folder), null, accent = true, enabled = guard.allows(dir!!.path)) {
+ onPick(dir!!); close()
+ }
+ PickerRow(Icons.Outlined.CreateNewFolder, stringResource(R.string.sync_new_folder), null) { naming = true }
+ for (f in folders) key(f.path) {
+ PickerRow(Icons.Outlined.Folder, f.name, null) { dir = f }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun validName(name: String) = name.isNotBlank() && '/' !in name && name != "." && name != ".."
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt
new file mode 100644
index 000000000..cac4d039e
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SyncPanel.kt
@@ -0,0 +1,437 @@
+package com.droiddeck.launcher.ui
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.text.format.DateUtils
+import android.text.format.Formatter
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Block
+import androidx.compose.material.icons.outlined.Sync
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.sync.SyncDevice
+import com.droiddeck.launcher.sync.SyncFolder
+import com.droiddeck.launcher.sync.SyncPendingFolder
+import com.droiddeck.launcher.sync.SyncSnapshot
+import com.droiddeck.launcher.sync.Syncthing
+import com.droiddeck.launcher.sync.SyncthingWebActivity
+import java.io.File
+
+@Composable
+internal fun SyncPanel(host: MenuHost, runtimeReady: Boolean, onAdvanced: () -> Unit) {
+ val ctx = LocalContext.current
+ DisposableEffect(Unit) { val release = Syncthing.watch(); onDispose { release() } }
+ var showId by rememberSaveable { mutableStateOf(false) }
+ var confirmOff by rememberSaveable { mutableStateOf(false) }
+ val snap = Syncthing.snapshot
+ if (showId && snap != null) DeviceIdDialog(snap.myId) { showId = false }
+ if (confirmOff) TurnOffDialog(onDismiss = { confirmOff = false }) { forget ->
+ Syncthing.setEnabled(ctx, false, forget)
+ }
+ val phase = Syncthing.phase
+ when {
+ phase == Syncthing.Phase.FAILED -> SyncFailed(
+ if (Syncthing.failure == Syncthing.FAILURE_MISSING) stringResource(R.string.sync_missing) else Syncthing.failure.orEmpty(),
+ onRetry = { Syncthing.restart() }, onAdvanced = onAdvanced, onOff = { confirmOff = true })
+ !Syncthing.enabled -> SyncOff(host, runtimeReady) { Syncthing.setEnabled(ctx, true) }
+ phase != Syncthing.Phase.RUNNING || snap == null -> SyncStarting()
+ else -> SyncRunning(snap, onShowId = { showId = true }, onAdvanced = onAdvanced, onOff = { confirmOff = true })
+ }
+}
+
+@Composable
+private fun SyncOff(host: MenuHost, runtimeReady: Boolean, onEnable: () -> Unit) {
+ val missing = Syncthing.failure == Syncthing.FAILURE_MISSING
+ val hint = when {
+ missing -> R.string.sync_missing
+ !runtimeReady -> R.string.sync_needs_runtime
+ else -> R.string.sync_enable_hint
+ }
+ SettingsGroup(stringResource(R.string.sync_group)) {
+ ToggleRow(
+ host, "sync-enable", stringResource(R.string.sync_enable), stringResource(hint), false, enabled = !missing && runtimeReady,
+ ) { if (it) onEnable() }
+ }
+}
+
+@Composable
+private fun SyncStarting() {
+ val colors = MaterialTheme.colorScheme
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 96.dp),
+ ) {
+ CircularProgressIndicator(strokeWidth = 3.dp, color = LocalPalette.current.signal, modifier = Modifier.size(40.dp))
+ Text(stringResource(R.string.sync_starting), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant)
+ }
+}
+
+@Composable
+private fun SyncFailed(reason: String, onRetry: () -> Unit, onAdvanced: () -> Unit, onOff: () -> Unit) {
+ SettingsGroup(stringResource(R.string.sync_group)) {
+ SettingsRow(stringResource(R.string.sync_status_failed), reason) {
+ SecondaryButton(stringResource(R.string.sync_failed_retry), compact = true, onClick = onRetry)
+ }
+ SettingsRow(stringResource(R.string.sync_advanced), stringResource(R.string.sync_advanced_hint)) {
+ SecondaryButton(stringResource(R.string.sync_open), compact = true, onClick = onAdvanced)
+ }
+ SettingsRow(stringResource(R.string.sync_running), stringResource(R.string.sync_running_hint)) {
+ ToggleSwitch(true, label = stringResource(R.string.sync_running)) { if (!it) onOff() }
+ }
+ }
+}
+
+@Composable
+private fun SyncRunning(snap: SyncSnapshot, onShowId: () -> Unit, onAdvanced: () -> Unit, onOff: () -> Unit) {
+ PairedColumns(
+ {
+ SettingsGroup(stringResource(R.string.sync_group)) {
+ StatusCard(snap)
+ SettingsRow(stringResource(R.string.sync_running), stringResource(R.string.sync_running_hint)) {
+ ToggleSwitch(true, label = stringResource(R.string.sync_running)) { if (!it) onOff() }
+ }
+ }
+ SettingsGroup(stringResource(R.string.sync_this_device)) {
+ SettingsRow(stringResource(R.string.sync_device_id), shortId(snap.myId)) {
+ SecondaryButton(stringResource(R.string.sync_show_qr), compact = true, onClick = onShowId)
+ }
+ SettingsRow(stringResource(R.string.sync_advanced), stringResource(R.string.sync_advanced_hint)) {
+ SecondaryButton(stringResource(R.string.sync_open), compact = true, onClick = onAdvanced)
+ }
+ }
+ },
+ {
+ Waiting(snap)
+ SettingsGroup(stringResource(R.string.sync_folders)) {
+ if (snap.folders.isEmpty()) SettingsRow(stringResource(R.string.sync_no_folders), null) {}
+ snap.folders.sortedByDescending { it.blocked }.forEach { FolderRow(it) }
+ AddFolderRow(snap.devices)
+ }
+ SettingsGroup(stringResource(R.string.sync_devices)) {
+ if (snap.devices.isEmpty()) SettingsRow(stringResource(R.string.sync_no_devices), null) {}
+ snap.devices.forEach { DeviceRow(it) }
+ }
+ },
+ )
+}
+
+/** Two columns of settings groups side by side, stacked on narrow panes. */
+@Composable
+internal fun PairedColumns(left: @Composable ColumnScope.() -> Unit, right: @Composable ColumnScope.() -> Unit) {
+ if (LocalNarrowPane.current) {
+ Column { left(); right() }
+ return
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.weight(1f)) { left() }
+ Column(Modifier.weight(1f)) { right() }
+ }
+}
+
+@Composable
+private fun StatusCard(snap: SyncSnapshot) {
+ val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val paused = Syncthing.pauseRequested || Syncthing.devicesPaused
+ val (headline, tint) = when {
+ Syncthing.pauseRequested && Syncthing.devicesPaused -> stringResource(R.string.sync_status_paused) to colors.onSurfaceVariant
+ Syncthing.pauseRequested -> stringResource(R.string.sync_status_pausing) to colors.onSurfaceVariant
+ Syncthing.devicesPaused -> stringResource(R.string.sync_status_resuming) to colors.onSurfaceVariant
+ snap.needsAttention -> stringResource(R.string.sync_status_attention) to AttentionAmber
+ snap.syncing -> stringResource(R.string.sync_status_syncing) to pal.signal
+ else -> stringResource(R.string.sync_status_up_to_date) to pal.good
+ }
+ val detail = when (Syncthing.autoPause) {
+ Syncthing.AutoPause.WIFI -> stringResource(R.string.sync_paused_wifi)
+ Syncthing.AutoPause.GAME -> stringResource(R.string.sync_paused_game)
+ null -> if (snap.syncing && !paused) stringResource(R.string.sync_left, Formatter.formatShortFileSize(ctx, snap.needBytes))
+ else stringResource(R.string.sync_summary, snap.folders.size, snap.devices.count { it.connected }, snap.devices.size)
+ }
+ Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ when {
+ snap.syncing && !paused -> Icon(Icons.Outlined.Sync, null, tint = tint, modifier = Modifier.size(22.dp))
+ snap.needsAttention && !paused -> Icon(Icons.Outlined.WarningAmber, null, tint = tint, modifier = Modifier.size(22.dp))
+ else -> Box(Modifier.size(12.dp).clip(CircleShape).background(tint))
+ }
+ Text(headline, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.weight(1f))
+ if (snap.syncing && !paused) Text("${snap.percent}%", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = pal.signal)
+ }
+ if (snap.syncing && !paused) LinearProgressIndicator(
+ progress = { snap.percent / 100f }, color = pal.signal, trackColor = pal.line, modifier = Modifier.fillMaxWidth().height(4.dp).clip(CircleShape),
+ )
+ Text(detail, fontSize = 12.5.sp, color = colors.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text("↓ ${rate(ctx, snap.inBytesPerSecond)}", fontSize = 13.sp, color = colors.onSurfaceVariant)
+ Text("↑ ${rate(ctx, snap.outBytesPerSecond)}", fontSize = 13.sp, color = colors.onSurfaceVariant)
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ PrimaryButton(stringResource(R.string.sync_open_web_ui), compact = true, modifier = Modifier.weight(1f)) { openWebUi(ctx) }
+ SecondaryButton(stringResource(if (Syncthing.userPaused) R.string.sync_resume else R.string.sync_pause), compact = true) {
+ Syncthing.setPaused(ctx, !Syncthing.userPaused)
+ }
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+}
+
+@Composable
+private fun Waiting(snap: SyncSnapshot) {
+ if (snap.pendingFolders.isEmpty() && snap.pendingDevices.isEmpty()) return
+ val ctx = LocalContext.current
+ var accepting by remember { mutableStateOf(null) }
+ accepting?.let { f ->
+ SyncFolderPicker(stringResource(R.string.sync_accept_title, f.label), f.label, onDismiss = { accepting = null }) { dir ->
+ Syncthing.addFolder(ctx, dir, f.label, listOf(f.deviceId), f.id) { ok -> if (!ok) addFailed(ctx) }
+ }
+ }
+ SettingsGroup(stringResource(R.string.sync_waiting)) {
+ snap.pendingDevices.forEach { name ->
+ SettingsRow(stringResource(R.string.sync_pending_device, name), null, highlighted = true) {
+ SecondaryButton(stringResource(R.string.sync_review), compact = true) { openWebUi(ctx) }
+ }
+ }
+ snap.pendingFolders.forEach { f ->
+ SettingsRow(stringResource(R.string.sync_pending_folder, f.offeredBy, f.label), stringResource(R.string.sync_pending_folder_hint), highlighted = true) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ SecondaryButton(stringResource(R.string.sync_review), compact = true) { openWebUi(ctx) }
+ PrimaryButton(stringResource(R.string.sync_accept), compact = true) { accepting = f }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddFolderRow(devices: List) {
+ val ctx = LocalContext.current
+ var picking by remember { mutableStateOf(false) }
+ var picked by remember { mutableStateOf(null) }
+ if (picking) SyncFolderPicker(stringResource(R.string.sync_add_folder), null, onDismiss = { picking = false }) { picked = it }
+ picked?.let { dir ->
+ ShareFolderDialog(dir, devices, onDismiss = { picked = null }) { chosen ->
+ Syncthing.addFolder(ctx, dir, dir.name, chosen) { ok -> if (!ok) addFailed(ctx) }
+ }
+ }
+ SettingsRow(stringResource(R.string.sync_add_folder), stringResource(R.string.sync_add_folder_hint)) {
+ SecondaryButton(stringResource(R.string.sync_choose), compact = true) { picking = true }
+ }
+}
+
+@Composable
+private fun ShareFolderDialog(folder: File, devices: List, onDismiss: () -> Unit, onAdd: (List) -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val shown = rememberShown(onDismiss)
+ val close = { shown.targetState = false }
+ val chosen = remember { mutableStateListOf() }
+ AppDialog(shown, close, "syncShareFolder", wide = false, maxWidth = 520.dp) {
+ DialogTitleRow(folder.name, onClose = close)
+ Text(folder.path, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ if (devices.isEmpty()) {
+ Text(stringResource(R.string.sync_share_none), fontSize = 13.5.sp, color = colors.onSurfaceVariant)
+ } else {
+ Text(stringResource(R.string.sync_share_with), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ devices.forEach { d ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().clip(Shape12).border(1.dp, pal.line, Shape12).background(colors.background).padding(end = 12.dp),
+ ) {
+ Checkbox(d.id in chosen, { if (it) chosen.add(d.id) else chosen.remove(d.id) })
+ Text(d.name, fontSize = 14.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) {
+ SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close)
+ PrimaryButton(stringResource(R.string.sync_add), compact = true) { onAdd(chosen.toList()); close() }
+ }
+ }
+}
+
+private fun addFailed(context: Context) {
+ android.widget.Toast.makeText(context, R.string.sync_add_failed, android.widget.Toast.LENGTH_LONG).show()
+}
+
+@Composable
+private fun FolderRow(f: SyncFolder) {
+ val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ if (f.blocked) {
+ Column(Modifier.fillMaxWidth().background(colors.error.copy(alpha = 0.06f)).padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Icon(Icons.Outlined.Block, null, tint = colors.error, modifier = Modifier.size(18.dp))
+ Text(f.label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.weight(1f))
+ Text(stringResource(R.string.sync_folder_blocked), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.error)
+ }
+ Text(f.path, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Text(stringResource(R.string.sync_folder_blocked_hint), fontSize = 12.5.sp, color = colors.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) {
+ SecondaryButton(stringResource(R.string.sync_remove_folder), compact = true) { Syncthing.removeFolder(f.id) }
+ SecondaryButton(stringResource(R.string.sync_open_web_ui), compact = true) { openWebUi(ctx) }
+ }
+ Text(stringResource(R.string.sync_remove_folder_hint), fontSize = 12.sp, color = colors.onSurfaceVariant)
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
+ return
+ }
+ val (state, tint) = when {
+ f.paused -> stringResource(R.string.sync_folder_paused) to colors.onSurfaceVariant
+ f.syncing -> stringResource(R.string.sync_folder_syncing, percent(f)) to pal.signal
+ f.outOfSync -> stringResource(R.string.sync_folder_out_of_sync) to AttentionAmber
+ f.state == "scanning" || f.state == "scan-waiting" -> stringResource(R.string.sync_folder_scanning) to colors.onSurfaceVariant
+ else -> stringResource(R.string.sync_folder_up_to_date) to pal.good
+ }
+ val detail = when {
+ f.errors > 0 -> stringResource(R.string.sync_folder_errors, f.errors)
+ f.sharedWith.isEmpty() -> Formatter.formatShortFileSize(ctx, f.globalBytes)
+ else -> stringResource(R.string.sync_folder_detail, Formatter.formatShortFileSize(ctx, f.globalBytes), f.sharedWith.joinToString(", "))
+ }
+ SettingsRow(f.label, detail) { StateLabel(state, tint) }
+}
+
+@Composable
+private fun DeviceRow(d: SyncDevice) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val detail = when {
+ d.connected -> d.address?.substringAfter("://")
+ d.lastSeen != null -> stringResource(R.string.sync_last_seen, DateUtils.getRelativeTimeSpanString(d.lastSeen).toString())
+ else -> null
+ }
+ SettingsRow(d.name, detail) {
+ if (d.connected) StateLabel(stringResource(R.string.sync_connected), pal.good)
+ else StateLabel(stringResource(R.string.sync_offline), colors.onSurfaceVariant)
+ }
+}
+
+@Composable
+private fun StateLabel(text: String, tint: Color) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
+ Box(Modifier.size(9.dp).clip(CircleShape).background(tint))
+ Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 1)
+ }
+}
+
+@Composable
+private fun DeviceIdDialog(id: String, onDismiss: () -> Unit) {
+ val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val shown = rememberShown(onDismiss)
+ val close = { shown.targetState = false }
+ val qr = remember(id) { qrBitmap(id) }
+ AppDialog(shown, close, "syncDeviceId", wide = true, maxWidth = 640.dp) {
+ DialogTitleRow(stringResource(R.string.sync_device_id_title), onClose = close)
+ Row(horizontalArrangement = Arrangement.spacedBy(22.dp), verticalAlignment = Alignment.CenterVertically) {
+ Image(qr, stringResource(R.string.sync_device_id), filterQuality = FilterQuality.None,
+ modifier = Modifier.size(208.dp).clip(Shape14).background(Color.White).padding(10.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.weight(1f)) {
+ Text(id.chunked(32).joinToString("\n"), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, fontFamily = FontFamily.Monospace,
+ color = colors.onBackground, lineHeight = 26.sp)
+ Text(stringResource(R.string.sync_device_id_hint), fontSize = 13.sp, color = colors.onSurfaceVariant)
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) {
+ SecondaryButton(stringResource(R.string.sync_copy), compact = true) {
+ ctx.getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText("Syncthing device ID", id))
+ }
+ SecondaryButton(stringResource(R.string.sync_share), compact = true) {
+ ctx.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, id), null))
+ }
+ PrimaryButton(stringResource(R.string.sync_done), compact = true, onClick = close)
+ }
+ }
+}
+
+@Composable
+private fun TurnOffDialog(onDismiss: () -> Unit, onTurnOff: (Boolean) -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val shown = rememberShown(onDismiss)
+ val close = { shown.targetState = false }
+ var forget by remember { mutableStateOf(false) }
+ AppDialog(shown, close, "syncTurnOff", wide = false, maxWidth = 520.dp) {
+ DialogTitleRow(stringResource(R.string.sync_off_title), onClose = close)
+ Text(stringResource(R.string.sync_off_body), fontSize = 13.5.sp, color = colors.onSurfaceVariant)
+ Row(
+ verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth().clip(Shape12).border(1.dp, pal.line, Shape12).background(colors.background).padding(end = 12.dp, top = 2.dp, bottom = 8.dp),
+ ) {
+ Checkbox(forget, { forget = it })
+ Column(Modifier.padding(top = 12.dp)) {
+ Text(stringResource(R.string.sync_off_forget), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ Text(stringResource(R.string.sync_off_forget_hint), fontSize = 12.5.sp, color = colors.onSurfaceVariant)
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), modifier = Modifier.fillMaxWidth()) {
+ SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = close)
+ PrimaryButton(stringResource(R.string.sync_turn_off), compact = true) { onTurnOff(forget); close() }
+ }
+ }
+}
+
+internal fun openWebUi(context: Context) {
+ context.startActivity(Intent(context, SyncthingWebActivity::class.java))
+}
+
+private fun shortId(id: String) = if (id.length > 16) "${id.take(7)}-…-${id.takeLast(7)}" else id
+
+private fun rate(context: Context, bytesPerSecond: Long) = Formatter.formatShortFileSize(context, bytesPerSecond) + "/s"
+
+private fun percent(f: SyncFolder): Int =
+ if (f.globalBytes <= 0) 0 else (((f.globalBytes - f.needBytes).coerceAtLeast(0) * 100) / f.globalBytes).toInt().coerceIn(0, 99)
+
+private fun qrBitmap(text: String): ImageBitmap {
+ val matrix = com.google.zxing.qrcode.QRCodeWriter().encode(text, com.google.zxing.BarcodeFormat.QR_CODE, 0, 0)
+ val bitmap = android.graphics.Bitmap.createBitmap(matrix.width, matrix.height, android.graphics.Bitmap.Config.ARGB_8888)
+ for (x in 0 until matrix.width) for (y in 0 until matrix.height) {
+ bitmap.setPixel(x, y, if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
+ }
+ return bitmap.asImageBitmap()
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d04f8f96c..ffb1c59cf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3422,6 +3422,113 @@
Saved views can be recalculated. Start a new calibration to capture in the current tracking frame.
Hand model checksum mismatch
OpenCV could not load
+ Syncthing
+ Syncthing
+ Sync with Syncthing
+ Based on Syncthing-Fork, this is a Syncthing implementation that lets you sync files in DroidDeck\'s internal file system with other Syncthing instances. This is only for files you are storing within DroidDeck\'s internal file system.
+ This build doesn\'t include Syncthing.
+ Starting Syncthing
+ Starting
+ Up to date
+ Syncing
+ Paused
+ Pausing
+ Resuming
+ Needs attention
+ Syncthing stopped
+ Try again
+ Paused until Wi-Fi
+ Paused during the game
+ %1$d folders · %2$d of %3$d devices connected
+ %1$s left
+ Open web UI
+ Pause
+ Resume
+ Running
+ Turn off to stop Syncthing completely.
+ This device
+ Device ID
+ Show QR
+ Advanced
+ Ports, when to run, reset
+ Open
+ Waiting for you
+ %1$s wants to connect
+ %1$s wants to share “%2$s”
+ Accept to choose where it\'s saved in DroidDeck.
+ Review in web UI
+ Folders
+ No folders yet.
+ %1$s · %2$s
+ Up to date
+ Syncing %1$d%%
+ Scanning
+ Paused
+ Out of sync
+ %1$d files couldn\'t sync
+ Blocked
+ This folder is outside DroidDeck\'s storage, so DroidDeck paused it. To sync folders outside DroidDeck, use the Syncthing-Fork app.
+ Remove from Syncthing
+ Removing it doesn\'t delete any files.
+ Devices
+ No devices yet.
+ Connected
+ Offline
+ Last seen %1$s
+ This device\'s ID
+ On your other device, add a device in Syncthing and scan this code or paste the ID. Then accept it here in the web UI.
+ Copy
+ Share
+ Done
+ Turn off Syncthing?
+ Syncthing stops now and stays stopped until it\'s turned back on. Synced files stay where they are.
+ Also delete Syncthing\'s config
+ Removes this device\'s ID, folders and devices. Turning it on again starts fresh.
+ Turn off
+ Ports
+ Sync
+ Local discovery
+ Pick new ports
+ Checked again on every start.
+ Re-pick
+ Reset
+ API key
+ Regenerate
+ Reset Syncthing
+ New device ID, folders and devices removed. Files in DroidDeck\'s storage are kept.
+ Reset
+ Reset Syncthing?
+ Running
+ Run
+ While DroidDeck is open
+ In the background
+ Wi-Fi only
+ Pause during games
+ Logs
+ Syncthing output for this session
+ Share
+ Syncthing
+ Set up the Linux runtime first.
+ Accept
+ Save “%1$s” to
+ Add folder
+ Sync a folder in DroidDeck\'s storage
+ Choose
+ Add
+ The folder couldn\'t be added.
+ Share with
+ No devices yet. You can share this folder from the web UI later.
+ Use this folder
+ New folder
+ Container home
+ Sync folder
+ DroidDeck shared storage
+ DroidDeck on %1$s
+ DroidDeck app data
+ New folders are saved in DroidDeck\'s Sync folder. To sync a different DroidDeck folder, use Add folder on the Sync tab.
+ Syncthing
+ Reload
+ Syncthing isn\'t running.
Steam stopped with an illegal instruction (SIGILL). This can be a CPU instruction compatibility problem or a client crash. Share the logs with your device details.
You can try Steam settings → Steam client → x86-64 (experimental). It needs an additional download and may run more slowly.
The Linux graphics driver could not find a usable Vulkan device. Check that the selected Linux GPU driver supports this GPU.
diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt
new file mode 100644
index 000000000..5416bc304
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncSnapshotTest.kt
@@ -0,0 +1,52 @@
+package com.droiddeck.launcher.sync
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SyncSnapshotTest {
+ private fun folder(state: String = "idle", global: Long = 100, need: Long = 0, blocked: Boolean = false, paused: Boolean = false, errors: Int = 0) =
+ SyncFolder("id", "Saves", "/p", paused, blocked, state, global, need, errors, emptyList())
+
+ private fun snapshot(vararg folders: SyncFolder) = SyncSnapshot("me", folders.toList(), emptyList(), emptyList(), emptyList(), 0, 0)
+
+ @Test
+ fun syncingReportsPercentOfActiveFolders() {
+ val s = snapshot(folder("syncing", global = 1000, need = 360), folder())
+ assertTrue(s.syncing)
+ assertEquals(64, s.percent)
+ assertEquals(360, s.needBytes)
+ }
+
+ @Test
+ fun blockedOrFailingFoldersNeedAttention() {
+ assertTrue(snapshot(folder(blocked = true, paused = true)).needsAttention)
+ assertTrue(snapshot(folder(errors = 2)).needsAttention)
+ assertTrue(snapshot(folder("idle", need = 5)).needsAttention)
+ assertFalse(snapshot(folder(paused = true, errors = 2)).needsAttention)
+ }
+
+ @Test
+ fun unreadableFolderStatusShowsAsError() {
+ val config = JSONObject("""{"id":"saves","label":"Saves","path":"/p","paused":false,"devices":[{"deviceID":"DEV1"}]}""")
+ val f = SyncthingParse.folder(config, SyncthingParse.unreadableStatus(), blocked = false, names = mapOf("DEV1" to "Desktop PC"))
+ assertEquals("error", f.state)
+ assertTrue(f.errors > 0)
+ assertTrue(f.outOfSync)
+ assertFalse(f.syncing)
+ assertEquals(listOf("Desktop PC"), f.sharedWith)
+ assertTrue(snapshot(f).needsAttention)
+ }
+
+ @Test
+ fun parsesPendingFoldersWithDeviceNames() {
+ val json = JSONObject("""{"emu":{"offeredBy":{"DEV1":{"label":"Emulation saves","time":"2026-01-01T00:00:00Z"}}}}""")
+ val pending = SyncthingParse.pendingFolders(json, mapOf("DEV1" to "Desktop PC"))
+ assertEquals(listOf(SyncPendingFolder("emu", "Emulation saves", "Desktop PC", "DEV1")), pending)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt
new file mode 100644
index 000000000..4d51bde19
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingConfigTest.kt
@@ -0,0 +1,83 @@
+package com.droiddeck.launcher.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class SyncthingConfigTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private val generated = """
+
+
+ false
+
+
+ false
+
+
+ dynamic
+ false
+
+
+ 127.0.0.1:8384
+ droiddeck
+ key123
+
+
+ default
+ 21027
+ 0
+ true
+
+
+
+
+
+ """.trimIndent()
+
+ private val ports = SyncthingPorts(22100, 21127)
+
+ @Test
+ fun pausesDisallowedFoldersBeforeLaunch() {
+ val out = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { !it.startsWith("/storage/emulated/0/DCIM") }
+ val camera = out.substringAfter("id=\"abc\"").substringBefore("")
+ val saves = out.substringAfter("id=\"def\"").substringBefore("")
+ assertTrue(camera.contains("true"))
+ assertTrue(saves.contains("false"))
+ }
+
+ @Test
+ fun pausesDevicesBeforeLaunchWhenPolicyRequires() {
+ val paused = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync", pauseDevices = true) { true }
+ assertTrue(paused.substringAfter("id=\"PEER\"").substringBefore("").contains("true"))
+ val open = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync", pauseDevices = false) { true }
+ assertTrue(open.substringAfter("id=\"PEER\"").substringBefore("").contains("false"))
+ }
+
+ @Test
+ fun replacesConfigFileWithoutLeavingStagedCopy() {
+ val dir = tmp.newFolder()
+ val file = File(dir, "config.xml").apply { writeText(generated) }
+ SyncthingConfig.apply(file, ports, File("/data/st/gui.sock"), File("/ext/Sync"), false) { true }
+ assertEquals(SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true }, file.readText())
+ assertEquals(listOf("config.xml"), dir.list()!!.toList())
+ }
+
+ @Test
+ fun applyIsStable() {
+ val once = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true }
+ assertEquals(once, SyncthingConfig.apply(once, ports, "/data/st/gui.sock", "/ext/Sync") { true })
+ }
+
+ @Test
+ fun guiListensOnlyOnPrivateSocket() {
+ val gui = SyncthingConfig.apply(generated, ports, "/data/st/gui.sock", "/ext/Sync") { true }.substringAfter("")
+ assertTrue(gui.contains("/data/st/gui.sock"))
+ assertTrue(gui.contains("600"))
+ assertTrue(gui.contains("tls=\"false\""))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt
new file mode 100644
index 000000000..94d7f1584
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingGuardTest.kt
@@ -0,0 +1,69 @@
+package com.droiddeck.launcher.sync
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import java.nio.file.Files
+
+class SyncthingGuardTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private fun setup(): Triple {
+ val data = tmp.newFolder("data")
+ val external = tmp.newFolder("external")
+ val home = File(external, "Sync").apply { mkdirs() }
+ val config = File(data, "files/syncthing").apply { mkdirs() }
+ return Triple(SyncthingGuard(home, listOf(data, external), listOf(config)), data, external)
+ }
+
+ @Test
+ fun allowsFoldersInsideAppStorage() {
+ val (guard, data, external) = setup()
+ assertTrue(guard.allows(File(external, "Sync/Game saves").path))
+ assertTrue(guard.allows(File(data, "files/linux/home").path))
+ assertTrue(guard.allows("~/Shared"))
+ assertTrue(guard.allows(external.path))
+ }
+
+ @Test
+ fun blocksFoldersOutsideAppStorage() {
+ val (guard, _, _) = setup()
+ assertFalse(guard.allows(tmp.newFolder("DCIM").path))
+ assertFalse(guard.allows("/storage/emulated/0/Download"))
+ assertFalse(guard.allows(""))
+ assertFalse(guard.allows("relative/path"))
+ }
+
+ @Test
+ fun blocksSyncthingsOwnConfig() {
+ val (guard, data, _) = setup()
+ assertFalse(guard.allows(File(data, "files/syncthing").path))
+ assertFalse(guard.allows(File(data, "files/syncthing/keys").path))
+ }
+
+ @Test
+ fun blocksFoldersHoldingSyncthingsConfig() {
+ val (guard, data, _) = setup()
+ assertFalse(guard.allows(data.path))
+ assertFalse(guard.allows(File(data, "files").path))
+ }
+
+ @Test
+ fun blocksSiblingWithSharedPrefix() {
+ val (guard, _, external) = setup()
+ assertFalse(guard.allows(external.path + "-other"))
+ }
+
+ @Test
+ fun blocksSymlinkPointingOutside() {
+ val (guard, _, external) = setup()
+ val outside = tmp.newFolder("outside")
+ val link = File(external, "Sync/link")
+ Files.createSymbolicLink(link.toPath(), outside.toPath())
+ assertFalse(guard.allows(link.path))
+ assertFalse(guard.allows(File(link, "child").path))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt
new file mode 100644
index 000000000..1efb0d919
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingHttpTest.kt
@@ -0,0 +1,30 @@
+package com.droiddeck.launcher.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.ByteArrayOutputStream
+
+class SyncthingHttpTest {
+ private fun response(raw: String) = SyncthingHttp.readResponse(raw.byteInputStream(Charsets.ISO_8859_1))
+
+ @Test
+ fun readsContentLengthBody() {
+ assertEquals(200 to """{"ping":"pong"}""", response("HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"ping\":\"pong\"}"))
+ }
+
+ @Test
+ fun readsChunkedBody() {
+ assertEquals(403 to "CSRF Error", response("HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nCSRF\r\n6\r\n Error\r\n0\r\n\r\n"))
+ }
+
+ @Test
+ fun writesOneShotRequest() {
+ val out = ByteArrayOutputStream()
+ SyncthingHttp.writeRequest(out, "POST", "/rest/system/pause", listOf("X-API-Key" to "k"), null)
+ val text = out.toString("ISO-8859-1")
+ assertTrue(text.startsWith("POST /rest/system/pause HTTP/1.1\r\n"))
+ assertTrue(text.contains("Connection: close\r\n"))
+ assertTrue(text.endsWith("X-API-Key: k\r\nContent-Length: 0\r\n\r\n"))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt
new file mode 100644
index 000000000..3bb8017bb
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/sync/SyncthingPortsTest.kt
@@ -0,0 +1,18 @@
+package com.droiddeck.launcher.sync
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SyncthingPortsTest {
+ @Test
+ fun skipsBusyPorts() {
+ val busy = setOf(22100, 21127)
+ val picked = SyncthingPortPicker.pick({ it !in busy }, { it !in busy })
+ assertEquals(SyncthingPorts(22101, 21128), picked)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun failsWhenNoPortIsFree() {
+ SyncthingPortPicker.pick({ false }, { true })
+ }
+}
diff --git a/tools/build_local.sh b/tools/build_local.sh
index 608b664d7..8caeda49e 100755
--- a/tools/build_local.sh
+++ b/tools/build_local.sh
@@ -45,7 +45,7 @@ if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required to cross-compile the glibc ARM64 preload libraries." >&2
exit 1
fi
-for tool in curl tar zstd shasum unzip python3; do
+for tool in curl tar zstd shasum unzip python3 go; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "${tool} is required to build the CI-equivalent APK." >&2
exit 1
@@ -337,6 +337,27 @@ else
(cd "${proot_out}" && shasum -a 256 libproot.so libproot-loader.so > .proot-outputs)
fi
+syncthing_src="${repo_root}/tools/syncthing/syncthing-android/syncthing/src/github.com/syncthing/syncthing"
+git -C "${repo_root}" submodule update --init --recursive tools/syncthing/syncthing-android
+syncthing_inputs=$(printf '%s\n' "$(cd "${repo_root}" && shasum -a 256 tools/syncthing/build.sh \
+ tools/syncthing/syncthing-android/gradle/libs.versions.toml)" \
+ "$(git -C "${syncthing_src}" rev-parse HEAD)" \
+ "$(git -C "${syncthing_src}" diff --binary HEAD | shasum -a 256)" \
+ "$(cd "${syncthing_src}" && git ls-files -z --others --exclude-standard | xargs -0 shasum -a 256)" \
+ "${ndk_version}" | shasum -a 256 | cut -d' ' -f1)
+if [[ -f "${proot_out}/libsyncthingnative.so" \
+ && "$(cat "${proot_out}/.syncthing-inputs" 2>/dev/null)" = "${syncthing_inputs}" ]]; then
+ echo "syncthing: sources unchanged, keeping ${proot_out}/libsyncthingnative.so"
+else
+ syncthing_stage="${staging_dir}/syncthing"
+ if ! restore_step syncthing "${syncthing_inputs}" "${syncthing_stage}"; then
+ "${repo_root}/tools/syncthing/build.sh" "${syncthing_stage}"
+ save_step syncthing "${syncthing_inputs}" "${syncthing_stage}"
+ fi
+ install -m755 "${syncthing_stage}/libsyncthingnative.so" "${proot_out}/"
+ echo "${syncthing_inputs}" > "${proot_out}/.syncthing-inputs"
+fi
+
bundle_dir="${staging_dir}/pulseaudio-bundle"
mkdir -p "${bundle_dir}"
zstd -dc "${bundle_asset}" | tar -xf - -C "${bundle_dir}"
diff --git a/tools/syncthing/build.sh b/tools/syncthing/build.sh
new file mode 100755
index 000000000..4b253c7ca
--- /dev/null
+++ b/tools/syncthing/build.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+OUTDIR=$(mkdir -p "$1" && cd "$1" && pwd)
+: "${NDK:?set NDK to the Android NDK root}"
+API=26
+HERE=$(cd "$(dirname "$0")" && pwd)
+FORK="$HERE/syncthing-android"
+SRC="$FORK/syncthing/src/github.com/syncthing/syncthing"
+if [[ ! -f "$SRC/build.go" ]]; then
+ echo "Syncthing source missing; run: git submodule update --init --recursive tools/syncthing/syncthing-android" >&2
+ exit 1
+fi
+command -v go >/dev/null || { echo "Go is required to build Syncthing" >&2; exit 1; }
+toml="$FORK/gradle/libs.versions.toml"
+GO_VERSION=$(sed -n 's/^go_version *= *"\(.*\)"/\1/p' "$toml")
+APP_VERSION=$(sed -n 's/^version-name *= *"\(.*\)"/\1/p' "$toml")
+[[ -n "$GO_VERSION" && -n "$APP_VERSION" ]] || { echo "Cannot read go_version/version-name from $toml" >&2; exit 1; }
+ST_VERSION="v$(echo "$APP_VERSION" | cut -d. -f1-3)"
+case "$(uname -s):$(uname -m)" in
+ Darwin:*) NDK_HOST=darwin-x86_64 ;;
+ Linux:x86_64) NDK_HOST=linux-x86_64 ;;
+ Linux:aarch64|Linux:arm64) NDK_HOST=linux-aarch64 ;;
+ *) echo "Unsupported build host: $(uname -s) $(uname -m)" >&2; exit 1 ;;
+esac
+TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$NDK_HOST/bin"
+if [[ ! -d "$TOOLCHAIN" && -d "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" ]]; then
+ TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
+fi
+CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
+[[ -x "$CC" ]] || { echo "Android NDK toolchain not found at $TOOLCHAIN" >&2; exit 1; }
+
+EPOCH=$(git -C "$SRC" show -s --format=%ct HEAD)
+cache=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT
+# build.go writes its output into the source tree, so build from a copy.
+cp -a "$SRC" "$WORK/syncthing"
+rm -f "$WORK/syncthing/.git"
+# -checklinkname=0: the fork's anet dependency links against internal net symbols.
+(
+ cd "$WORK/syncthing"
+ export GOTOOLCHAIN="go${GO_VERSION}" GOPATH="$cache/go" GOMODCACHE="$cache/go/pkg/mod" GOFLAGS=-buildvcs=false
+ export CGO_ENABLED=1 EXTRA_LDFLAGS=-checklinkname=0 BUILD_HOST=droiddeck BUILD_USER=reproducible-build SOURCE_DATE_EPOCH="$EPOCH"
+ go version
+ go run build.go -goos android -goarch arm64 -cc "$CC" -version "$ST_VERSION" -no-upgrade build
+)
+install -m755 "$WORK/syncthing/syncthing" "$OUTDIR/libsyncthingnative.so"
+echo "Syncthing ${ST_VERSION} (fork ${APP_VERSION}, Go ${GO_VERSION}) built into $OUTDIR/libsyncthingnative.so"
diff --git a/tools/syncthing/syncthing-android b/tools/syncthing/syncthing-android
new file mode 160000
index 000000000..f8819078e
--- /dev/null
+++ b/tools/syncthing/syncthing-android
@@ -0,0 +1 @@
+Subproject commit f8819078e7dec1e8a954eec839febd9f7385294e
From 7eb9175def7455fdcf88620212534104f33740f4 Mon Sep 17 00:00:00 2001
From: The412Banner <205237651+The412Banner@users.noreply.github.com>
Date: Sun, 1 Nov 2026 14:26:03 -0500
Subject: [PATCH 05/17] fix(dual-screen): In-game stats hint says "a game's
data output", not only racing games (#1968)
War Thunder and Elite Dangerous show on this page too, so the hint no longer says racing game.
---
app/src/main/res/values/strings.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ffb1c59cf..36fcfc650 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1964,7 +1964,7 @@
Balance
War Thunder
Elite Dangerous
- Turn on a racing game\'s data output (often called telemetry or UDP) to see a live dash here. It stays on this device.
+ Turn on a game\'s data output (often called telemetry or UDP) to see a live dash here. It stays on this device.
Port %1$d receives data (%2$d packets of %3$d bytes), but in a format this page cannot read yet.
Another program already uses port %1$s, so that game cannot show here.
From 6cd844ea516a4c598de4a242dcdd7e80c7f57140 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 14:51:28 -0500
Subject: [PATCH 06/17] feat: Follow screen resolution can be scaled down by a
factor (#1964)
---
.../com/droiddeck/launcher/SessionActivity.kt | 19 +++++++---
.../launcher/session/SessionDisplay.kt | 33 ++++++++++++++---
.../launcher/session/SessionPrefs.kt | 4 +--
.../launcher/session/SessionState.kt | 1 +
.../launcher/ui/ModeSettingsDialog.kt | 22 +++++++++++-
.../droiddeck/launcher/ui/ResolutionRow.kt | 20 ++++++++---
app/src/main/res/values-ar/strings.xml | 1 +
app/src/main/res/values-b+es+419/strings.xml | 1 +
app/src/main/res/values-bg/strings.xml | 1 +
app/src/main/res/values-cs/strings.xml | 1 +
app/src/main/res/values-da/strings.xml | 1 +
app/src/main/res/values-de/strings.xml | 1 +
app/src/main/res/values-el/strings.xml | 1 +
app/src/main/res/values-es/strings.xml | 1 +
app/src/main/res/values-fi/strings.xml | 1 +
app/src/main/res/values-fr/strings.xml | 1 +
app/src/main/res/values-hi/strings.xml | 1 +
app/src/main/res/values-hu/strings.xml | 1 +
app/src/main/res/values-in/strings.xml | 1 +
app/src/main/res/values-it/strings.xml | 1 +
app/src/main/res/values-ja/strings.xml | 1 +
app/src/main/res/values-ko/strings.xml | 1 +
app/src/main/res/values-ms/strings.xml | 1 +
app/src/main/res/values-nb/strings.xml | 1 +
app/src/main/res/values-nl/strings.xml | 1 +
app/src/main/res/values-pl/strings.xml | 1 +
app/src/main/res/values-pt-rPT/strings.xml | 1 +
app/src/main/res/values-pt/strings.xml | 1 +
app/src/main/res/values-ro/strings.xml | 1 +
app/src/main/res/values-ru/strings.xml | 1 +
app/src/main/res/values-sv/strings.xml | 1 +
app/src/main/res/values-th/strings.xml | 1 +
app/src/main/res/values-tr/strings.xml | 1 +
app/src/main/res/values-uk/strings.xml | 1 +
app/src/main/res/values-vi/strings.xml | 1 +
app/src/main/res/values-zh-rCN/strings.xml | 1 +
app/src/main/res/values-zh-rHK/strings.xml | 1 +
app/src/main/res/values-zh-rTW/strings.xml | 1 +
app/src/main/res/values/strings.xml | 1 +
.../launcher/session/SessionDisplayTest.kt | 35 ++++++++++++++++---
40 files changed, 147 insertions(+), 20 deletions(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index dd1a4cd5f..7ba6f5243 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -607,7 +607,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fpsLimit = 0
}
applyOutputPacing()
- if (!SessionState.running) SessionState.followScreen = followChoice()
+ if (!SessionState.running) {
+ SessionState.followScreen = followChoice()
+ SessionState.followScale = followScale()
+ }
applyFollowOrientation(resources.configuration)
goFullscreen()
volumeControlStream = android.media.AudioManager.STREAM_MUSIC
@@ -2168,7 +2171,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun windowOutputSize(width: Int, height: Int): Pair? {
if (isInPictureInPictureMode || width <= 0 || height <= 0) return null
return if (SessionState.followScreen)
- com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted())
+ com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted(), SessionState.followScale)
else if (glassesScreen == null) outputSize() else null
}
@@ -2183,10 +2186,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeResizeOutput(size.first, size.second)
}
+ private fun followScale(): Float {
+ val mode = displayMode()
+ val choice = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
+ return com.droiddeck.launcher.session.SessionDisplay.followScale(choice) ?: 1f
+ }
+
private fun followChoice(): Boolean {
val mode = displayMode()
- return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
- com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
+ return com.droiddeck.launcher.session.SessionDisplay.isFollow(
+ SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)))
}
private fun applyFollowOrientation(config: android.content.res.Configuration) {
@@ -4395,7 +4404,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
- val fixed = choice != display.MATCH_SCREEN && choice != display.FOLLOW_SCREEN && display.presetHeight(choice) == null
+ val fixed = choice != display.MATCH_SCREEN && !display.isFollow(choice) && display.presetHeight(choice) == null
return if (fixed) display.resolve(panel, minOf(size.first, size.second), SessionPrefs.SHAPE_AUTO) else size
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
index 31b9b1098..1cb526c90 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt
@@ -7,6 +7,10 @@ object SessionDisplay {
const val MATCH_SCREEN = "screen"
const val FOLLOW_SCREEN = "follow"
const val FOLLOW_MIN_SIDE = 480
+ val FOLLOW_SCALES = listOf(1.5f, 2f, 2.5f, 3f, 4f)
+ const val FOLLOW_CUSTOM = "follow/custom"
+ const val FOLLOW_SCALE_MAX = 8f
+ private const val FOLLOW_SCALED_PREFIX = "follow/"
const val DEFAULT_RESOLUTION = "720p"
private val PRESET_HEIGHTS = listOf(720, 900, 1080)
@@ -14,12 +18,30 @@ object SessionDisplay {
fun presetHeight(choice: String): Int? =
if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null
+ fun followScaled(scale: Float): String = FOLLOW_SCALED_PREFIX + formatScale(scale)
+
+ fun formatScale(scale: Float): String =
+ (Math.round(scale * 10) / 10f).let { if (it % 1f == 0f) it.toInt().toString() else it.toString() }
+
+ fun parseFollowScale(text: String): Float? =
+ text.trim().replace(',', '.').toFloatOrNull()
+ ?.let { Math.round(it * 10) / 10f }?.takeIf { it > 1f && it <= FOLLOW_SCALE_MAX }
+
+ fun isFollow(choice: String): Boolean = followScale(choice) != null
+
+ fun followScale(choice: String): Float? = when {
+ choice == FOLLOW_SCREEN -> 1f
+ choice.startsWith(FOLLOW_SCALED_PREFIX) -> parseFollowScale(choice.removePrefix(FOLLOW_SCALED_PREFIX))
+ else -> null
+ }
+
fun screenSize(panel: Pair): Pair =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair, choice: String, portrait: Boolean = false): Pair {
fun orient(size: Pair) = if (portrait) size.second to size.first else size
- if (choice == MATCH_SCREEN || choice == FOLLOW_SCREEN) return orient(screenSize(panel))
+ if (choice == MATCH_SCREEN) return orient(screenSize(panel))
+ followScale(choice)?.let { return orient(scaledDown(screenSize(panel), it)) }
presetHeight(choice)?.let { return orient(resolve(panel, it, SessionPrefs.SHAPE_AUTO)) }
val parts = choice.split('x').map { it.toIntOrNull() }
return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!!
@@ -29,13 +51,16 @@ object SessionDisplay {
fun resolutionOptions(panel: Pair): List {
val panelHeight = minOf(panel.first, panel.second)
return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" }
- .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN
+ .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + FOLLOW_SCREEN + FOLLOW_SCALES.map(::followScaled)
}
- fun followSize(width: Int, height: Int, controlsBelow: Boolean = false): Pair? =
+ fun followSize(width: Int, height: Int, controlsBelow: Boolean = false, scale: Float = 1f): Pair? =
if (width <= 0 || height <= 0 ||
(if (controlsBelow) width else minOf(width, height)) < FOLLOW_MIN_SIDE) null
- else (width and 1.inv()) to (height and 1.inv())
+ else scaledDown(width to height, scale)
+
+ private fun scaledDown(size: Pair, scale: Float): Pair =
+ (Math.round(size.first / scale) and 1.inv()) to (Math.round(size.second / scale) and 1.inv())
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index fafd7202c..d49230f50 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -874,7 +874,7 @@ object SessionPrefs {
fun resolutionChoice(context: Context, mode: String, panel: Pair): String {
val saved = prefs(context)
saved.getString("displayResolution.$mode", null)?.let { value ->
- if (value == SessionDisplay.MATCH_SCREEN || value == SessionDisplay.FOLLOW_SCREEN ||
+ if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.isFollow(value) ||
SessionDisplay.presetHeight(value) != null) return value
parseResolution(value)?.let { return "${it.first}x${it.second}" }
}
@@ -887,7 +887,7 @@ object SessionPrefs {
}
fun setResolutionChoice(context: Context, mode: String, choice: String) {
- val value = if (choice == SessionDisplay.MATCH_SCREEN || choice == SessionDisplay.FOLLOW_SCREEN ||
+ val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.isFollow(choice) ||
SessionDisplay.presetHeight(choice) != null) choice else {
val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" }
"${size.first}x${size.second}"
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index 3cf93a322..947948f16 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -105,6 +105,7 @@ object SessionState {
@Volatile
var followScreen = false
+ var followScale = 1f
@Volatile
var refreshHz: Float = 60f
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
index cd37ebc08..8abc5ff93 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
@@ -225,7 +225,7 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
}
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
- val canStretch = s.resolution != com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN &&
+ val canStretch = !com.droiddeck.launcher.session.SessionDisplay.isFollow(s.resolution) &&
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
@@ -671,3 +671,23 @@ internal fun CustomResolutionDialog(initial: Pair?, onSave: (Pair Unit, onDismiss: () -> Unit) {
+ var text by remember { mutableStateOf(initial?.let(com.droiddeck.launcher.session.SessionDisplay::formatScale) ?: "") }
+ val parsed = com.droiddeck.launcher.session.SessionDisplay.parseFollowScale(text)
+ com.droiddeck.launcher.ui.GlideAlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.display_follow_screen_scaled, "…")) },
+ text = {
+ androidx.compose.material3.OutlinedTextField(
+ text, { v -> text = v.filter { it.isDigit() || it == '.' || it == ',' }.take(4) },
+ label = { Text("÷") }, singleLine = true, isError = parsed == null && text.isNotEmpty(),
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal),
+ modifier = Modifier.focusGlide(),
+ )
+ },
+ confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.common_use)) } },
+ dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss, modifier = Modifier.focusGlide(CircleShape)) { Text(stringResource(R.string.common_cancel)) } },
+ )
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt
index 8db8910a8..7112d4f39 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ResolutionRow.kt
@@ -20,22 +20,34 @@ fun ResolutionRow(
val size = SessionDisplay.resolveChoice(panel, choice, portrait)
val screen = SessionDisplay.resolveChoice(panel, SessionDisplay.MATCH_SCREEN, portrait)
val choices = SessionDisplay.resolutionOptions(panel)
- val selected = if (choice == SessionDisplay.FOLLOW_SCREEN) choice
+ val customFollow = SessionDisplay.isFollow(choice) && choice !in choices
+ val selected = if (SessionDisplay.isFollow(choice)) choice
else if (size == screen) SessionDisplay.MATCH_SCREEN else choice
var editCustom by remember { mutableStateOf(false) }
+ var editFollow by remember { mutableStateOf(false) }
val options = choices.map { value ->
val dimensions = SessionDisplay.resolveChoice(panel, value, portrait)
value to if (value == SessionDisplay.MATCH_SCREEN) stringResource(R.string.display_match_screen, screen.first, screen.second)
else if (value == SessionDisplay.FOLLOW_SCREEN) stringResource(R.string.display_follow_screen)
+ else if (SessionDisplay.isFollow(value)) stringResource(R.string.display_follow_screen_scaled,
+ SessionDisplay.formatScale(SessionDisplay.followScale(value) ?: 1f)) + " (${dimensions.first}×${dimensions.second})"
else if (value == SessionDisplay.DEFAULT_RESOLUTION) stringResource(R.string.mode_res_default, "${dimensions.first}×${dimensions.second}")
else "${dimensions.first}×${dimensions.second}"
- } + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
+ } + listOf(SessionDisplay.FOLLOW_CUSTOM to stringResource(R.string.display_follow_screen_scaled,
+ if (customFollow) SessionDisplay.formatScale(SessionDisplay.followScale(choice) ?: 1f) else "…"
+ ) + if (customFollow) " (${size.first}×${size.second})" else "") + (custom to if (selected in choices) stringResource(R.string.mode_res_custom)
else stringResource(R.string.mode_res_custom_value, size.first, size.second))
ChoiceRow(host, "resolution", stringResource(R.string.display_resolution),
stringResource(R.string.common_applies_next_session),
- options, if (selected in choices) selected else custom, chipModifier = chipModifier,
- onPick = { value -> if (value == custom) editCustom = true else onPick(value) })
+ options, if (selected in choices) selected else if (customFollow) SessionDisplay.FOLLOW_CUSTOM else custom, chipModifier = chipModifier,
+ onPick = { value ->
+ if (value == custom) editCustom = true
+ else if (value == SessionDisplay.FOLLOW_CUSTOM) editFollow = true
+ else onPick(value) })
if (editCustom) CustomResolutionDialog(size,
onSave = { value -> editCustom = false; onPick("${value.first}x${value.second}") },
onDismiss = { editCustom = false })
+ if (editFollow) CustomFollowScaleDialog(SessionDisplay.followScale(choice)?.takeIf { it > 1f },
+ onSave = { value -> editFollow = false; onPick(SessionDisplay.followScaled(value)) },
+ onDismiss = { editFollow = false })
}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 9713cd463..152a0d732 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -2198,6 +2198,7 @@
%1$s يضبط أسلوب التكبير على %2$s.
مطابقة الشاشة (%1$d×%2$d)
اتباع الشاشة (يُغيَّر الحجم عند الطي أو الفتح)
+ مقياس %1$s
Steam
إظهار تفاصيل الجهاز
إخفاء تفاصيل الجهاز
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 588972b08..3f959fe51 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -2174,6 +2174,7 @@
%1$s Cambia el escalado a %2$s.
Igual a la pantalla (%1$d×%2$d)
Seguir la pantalla (cambia de tamaño al plegar o desplegar)
+ Escalado %1$s
Steam
Mostrar detalles del dispositivo
Ocultar detalles del dispositivo
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 6ed8e2b25..eff7ca9b6 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Задава мащабиране на %2$s.
Напасване на екрана (%1$d×%2$d)
Следване на екрана (преоразмерява при сгъване или разгъване)
+ Мащабирано %1$s
Steam
Покажи данните за устройството
Скрий данните за устройството
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index e089858a8..272aa9171 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -2152,6 +2152,7 @@
%1$s Nastaví škálování na %2$s.
Podle obrazovky (%1$d×%2$d)
Sledovat obrazovku (změní velikost při složení nebo rozložení)
+ Zmenšeno %1$s
Steam
Zobrazit podrobnosti o zařízení
Skrýt podrobnosti o zařízení
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index db489b5c7..eb774419e 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Indstiller skalering til %2$s.
Match skærm (%1$d×%2$d)
Følg skærm (ændrer størrelse ved fold/udfold)
+ Skaleret %1$s
Steam
Vis enhedsoplysninger
Skjul enhedsoplysninger
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 5f6ea9a06..559a2f727 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -2098,6 +2098,7 @@
%1$s Stellt die Skalierung auf %2$s.
An Bildschirm anpassen (%1$d×%2$d)
Bildschirm folgen (passt sich beim Auf- und Zuklappen an)
+ Skaliert %1$s
Steam
Gerätedetails anzeigen
Gerätedetails ausblenden
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 0ed66d718..739608d37 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Ορίζει την κλιμάκωση σε %2$s.
Αντιστοίχιση οθόνης (%1$d×%2$d)
Ακολούθηση οθόνης (αλλάζει μέγεθος όταν διπλώνει ή ξεδιπλώνει)
+ Κλιμάκωση %1$s
Steam
Εμφάνιση στοιχείων συσκευής
Απόκρυψη στοιχείων συσκευής
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 353f4b71d..302930a53 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -2129,6 +2129,7 @@
%1$s Cambia el escalado a %2$s.
Igual a la pantalla (%1$d×%2$d)
Seguir la pantalla (cambia de tamaño al plegar o desplegar)
+ Escalado %1$s
Steam
Mostrar detalles del dispositivo
Ocultar detalles del dispositivo
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 341923973..a6e509511 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Asettaa skaalauksen tilaan %2$s.
Sovita näyttöön (%1$d×%2$d)
Seuraa näyttöä (koko muuttuu taitettuna tai avattuna)
+ Skaalattu %1$s
Steam
Näytä laitteen tiedot
Piilota laitteen tiedot
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 5c2a78005..f9827384b 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -2129,6 +2129,7 @@
%1$s Règle la mise à l\'échelle sur %2$s.
Adapter à l\'écran (%1$d×%2$d)
Suivre l\'écran (s\'adapte au pliage et au dépliage)
+ Mis à l\'échelle %1$s
Steam
Afficher les détails de l\'appareil
Masquer les détails de l\'appareil
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index ab6577ded..ddac601c3 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -2106,6 +2106,7 @@
%1$s स्केलिंग को %2$s पर सेट करता है।
स्क्रीन से मिलाएँ (%1$d×%2$d)
Follow screen (खुलने या बंद होने पर आकार बदलता है)
+ स्केल %1$s
Steam
डिवाइस जानकारी दिखाएं
डिवाइस जानकारी छिपाएं
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 9a783c5c6..da127047e 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -2106,6 +2106,7 @@
%1$s A méretezést erre állítja: %2$s.
Képernyőhöz illesztés (%1$d×%2$d)
Képernyő követése (átméreteződik össze- vagy kinyitáskor)
+ Skálázva %1$s
Steam
Eszközrészletek megjelenítése
Eszközrészletek elrejtése
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index e63446f2c..960dddb10 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -2083,6 +2083,7 @@
%1$s Mengatur skala ke %2$s.
Sesuaikan layar (%1$d×%2$d)
Ikuti layar (ukuran berubah saat dilipat atau dibuka)
+ Diskalakan %1$s
Steam
Tampilkan detail perangkat
Sembunyikan detail perangkat
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 59998204b..81706b171 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -2121,6 +2121,7 @@
%1$s Imposta il ridimensionamento su %2$s.
Adatta allo schermo (%1$d×%2$d)
Segui lo schermo (si ridimensiona quando lo pieghi o lo apri)
+ Scalato %1$s
Steam
Mostra dettagli dispositivo
Nascondi dettagli dispositivo
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 7a836a27b..432caa199 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -2053,6 +2053,7 @@
%1$s スケーリングを%2$sに設定します。
画面に合わせる(%1$d×%2$d)
画面に追従(開閉時にサイズを変更)
+ スケール %1$s
Steam
デバイスの詳細を表示
デバイスの詳細を非表示
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 3c0461dfe..cf5a1a723 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -2083,6 +2083,7 @@
%1$s 스케일링을 %2$s(으)로 설정합니다.
화면에 맞춤(%1$d×%2$d)
화면 따라가기(접거나 펼치면 크기 변경)
+ 배율 %1$s
Steam
기기 세부 정보 표시
기기 세부 정보 숨기기
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index fa214eb0e..466180f52 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -2083,6 +2083,7 @@
%1$s Menetapkan penskalaan ke %2$s.
Sepadan skrin (%1$d×%2$d)
Ikut skrin (mengubah saiz apabila dilipat atau dibuka)
+ Diskalakan %1$s
Steam
Papar butiran peranti
Sembunyikan butiran peranti
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index d4ac2bf8d..f2e20bbe2 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Setter skalering til %2$s.
Tilpass skjerm (%1$d×%2$d)
Følg skjermen (endrer størrelse ved sammenslåing eller utbretting)
+ Skalert %1$s
Steam
Vis enhetsdetaljer
Skjul enhetsdetaljer
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 3a572a5cd..a5f072b2e 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Zet schaling op %2$s.
Schermformaat (%1$d×%2$d)
Scherm volgen (past formaat aan bij vouwen of uitvouwen)
+ Geschaald %1$s
Steam
Apparaatgegevens tonen
Apparaatgegevens verbergen
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index a74569a2c..1e58d4ff2 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -2152,6 +2152,7 @@
%1$s Ustawia skalowanie na %2$s.
Dopasuj do ekranu (%1$d×%2$d)
Dopasuj do ekranu (zmienia rozmiar po złożeniu lub rozłożeniu)
+ Skalowanie %1$s
Steam
Pokaż szczegóły urządzenia
Skryj szczegóły urządzenia
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index b681280f6..04cf83fed 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -2121,6 +2121,7 @@
%1$s Define a escala como %2$s.
Igual ao ecrã (%1$d×%2$d)
Seguir o ecrã (redimensiona ao dobrar ou desdobrar)
+ Escalado %1$s
Steam
Mostrar detalhes do dispositivo
Ocultar detalhes do dispositivo
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 55174b6a8..661354b3a 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -2121,6 +2121,7 @@
%1$s Define a escala como %2$s.
Igual à tela (%1$d×%2$d)
Seguir a tela (redimensiona ao dobrar ou desdobrar)
+ Escalado %1$s
Steam
Mostrar detalhes do dispositivo
Ocultar detalhes do dispositivo
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index d089650b6..92e37b8ab 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -2129,6 +2129,7 @@
%1$s Setează scalarea la %2$s.
Potrivire ecran (%1$d×%2$d)
Urmărește ecranul (se redimensionează la împăturire sau desfacere)
+ Scalat %1$s
Steam
Arată detaliile dispozitivului
Ascunde detaliile dispozitivului
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 0464dd558..211c1df95 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -2152,6 +2152,7 @@
%1$s Устанавливает масштабирование: %2$s.
По размеру экрана (%1$d×%2$d)
По экрану (меняется при складывании и раскладывании)
+ Масштаб %1$s
Steam
Показать сведения об устройстве
Скрыть сведения об устройстве
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index bf5fd29ae..86cd982e7 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -2106,6 +2106,7 @@
%1$s Ställer in skalning till %2$s.
Matcha skärmen (%1$d×%2$d)
Följ skärmen (ändrar storlek vid vikning)
+ Skalad %1$s
Steam
Visa enhetsinformation
Dölj enhetsinformation
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index fe417b62d..83c02b1c1 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -2083,6 +2083,7 @@
%1$s ตั้งการปรับขนาดเป็น %2$s
พอดีหน้าจอ (%1$d×%2$d)
ตามหน้าจอ (ปรับขนาดเมื่อพับหรือกาง)
+ ปรับสเกล %1$s
Steam
แสดงรายละเอียดอุปกรณ์
ซ่อนรายละเอียดอุปกรณ์
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 09255f6ee..555cccde8 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -2098,6 +2098,7 @@
%1$s Ölçeklemeyi %2$s olarak ayarlar.
Ekrana Uydur (%1$d×%2$d)
Ekranı takip et (katlanıp açıldığında yeniden boyutlanır)
+ Ölçekli %1$s
Steam
Cihaz ayrıntılarını göster
Cihaz ayrıntılarını gizle
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 66462283e..4e3a617d4 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -2152,6 +2152,7 @@
%1$s Встановлює масштабування на %2$s.
Під екран (%1$d×%2$d)
Слідувати за екраном (змінює розмір при згортанні чи розгортанні)
+ Масштаб %1$s
Steam
Показати відомості про пристрій
Сховати відомості про пристрій
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 4a9ad4a12..931860a60 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -2083,6 +2083,7 @@
%1$s Đặt chia tỷ lệ thành %2$s.
Khớp màn hình (%1$d×%2$d)
Theo màn hình (đổi kích thước khi gập hoặc mở)
+ Thu nhỏ %1$s
Steam
Hiện chi tiết thiết bị
Ẩn chi tiết thiết bị
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index bca2f369d..8cde3f2ca 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2131,6 +2131,7 @@
%1$s 将缩放设为 %2$s。
匹配屏幕(%1$d×%2$d)
跟随屏幕(折叠或展开时调整大小)
+ 缩放 %1$s
Steam
显示设备详情
隐藏设备详情
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 8b95bd486..4aa76ab64 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -2054,6 +2054,7 @@
%1$s 將縮放設為 %2$s。
配合螢幕(%1$d×%2$d)
跟隨螢幕(摺疊或展開時調整大小)
+ 縮放 %1$s
Steam
顯示裝置詳情
隱藏裝置詳情
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 8329c167f..53e263b3f 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -2054,6 +2054,7 @@
%1$s 將縮放設為 %2$s。
符合螢幕(%1$d×%2$d)
跟隨螢幕(摺疊或展開時調整大小)
+ 縮放 %1$s
Steam
顯示裝置詳細資料
隱藏裝置詳細資料
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 36fcfc650..d0e4aaa15 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2298,6 +2298,7 @@
%1$s Sets scaling to %2$s.
Match screen (%1$d×%2$d)
Follow screen (resizes when folded or unfolded)
+ Scaled %1$s
Steam
Show device details
Hide device details
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt
index 5d51028b0..82512c0b9 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt
@@ -4,6 +4,8 @@ import org.junit.Assert.*
import org.junit.Test
class SessionDisplayTest {
+ private val scaledFollow = listOf("follow/1.5", "follow/2", "follow/2.5", "follow/3", "follow/4")
+
@Test fun portraitPresetsSwapTheOutputAndRetainTheResolutionCap() {
assertEquals(720 to 1280, SessionDisplay.resolveChoice(1920 to 1080, "720p", portrait = true))
assertEquals(900 to 1600, SessionDisplay.resolveChoice(1080 to 1920, "900p", portrait = true))
@@ -40,13 +42,13 @@ class SessionDisplayTest {
@Test fun optionsListTheHeightsThePanelCanShowThenThePanel() {
val thor = SessionDisplay.resolutionOptions(1920 to 1080)
- assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), thor)
- assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN),
+ assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, thor)
+ assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow,
SessionDisplay.resolutionOptions(2400 to 1080))
- assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN),
+ assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow,
SessionDisplay.resolutionOptions(3120 to 1440))
val small = SessionDisplay.resolutionOptions(1280 to 720)
- assertEquals(listOf(SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN), small)
+ assertEquals(listOf(SessionDisplay.MATCH_SCREEN, SessionDisplay.FOLLOW_SCREEN) + scaledFollow, small)
assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN))
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
}
@@ -105,6 +107,31 @@ class SessionDisplayTest {
assertNull(SessionDisplay.followSize(640, 360))
}
+ @Test fun scaledFollowDividesTheWindowAndStaysEven() {
+ assertEquals(1260 to 540, SessionDisplay.resolveChoice(1080 to 2520, "follow/2"))
+ assertEquals(540 to 1260, SessionDisplay.resolveChoice(2520 to 1080, "follow/2", portrait = true))
+ assertEquals(1092 to 984, SessionDisplay.followSize(2185, 1969, scale = 2f))
+ assertEquals(656 to 728, SessionDisplay.followSize(1968, 2184, scale = 3f))
+ assertNull(SessionDisplay.followSize(640, 360, scale = 2f))
+ assertEquals(720 to 1600, SessionDisplay.resolveChoice(2400 to 1080, "follow/1.5", portrait = true))
+ assertEquals(1600 to 720, SessionDisplay.resolveChoice(1080 to 2400, "follow/1.5"))
+ assertNull(SessionDisplay.followScale("follow/1.0"))
+ }
+
+ @Test fun onlyKnownFollowChoicesCountAsFollow() {
+ assertEquals(1f, SessionDisplay.followScale(SessionDisplay.FOLLOW_SCREEN))
+ assertEquals(3f, SessionDisplay.followScale("follow/3"))
+ assertEquals(1.3f, SessionDisplay.followScale("follow/1.3"))
+ assertEquals(1.3f, SessionDisplay.followScale("follow/1.26"))
+ assertEquals("follow/1.3", SessionDisplay.followScaled(1.3f))
+ assertEquals("follow/2", SessionDisplay.followScaled(2f))
+ assertNull(SessionDisplay.followScale("follow/1"))
+ assertNull(SessionDisplay.followScale("follow/x"))
+ assertNull(SessionDisplay.followScale("follow/9"))
+ assertNull(SessionDisplay.followScale("720p"))
+ assertFalse(SessionDisplay.isFollow(SessionDisplay.MATCH_SCREEN))
+ }
+
@Test fun aPhoneCanFollowTheShortGameAreaAboveItsControls() {
assertNull(SessionDisplay.followSize(720, 404))
assertEquals(720 to 404, SessionDisplay.followSize(720, 404, controlsBelow = true))
From 8cb5c2168e8ec4a3f05bc1e075eaaf968570ca69 Mon Sep 17 00:00:00 2001
From: The412Banner <205237651+The412Banner@users.noreply.github.com>
Date: Sun, 1 Nov 2026 15:22:51 -0500
Subject: [PATCH 07/17] feat(hud): GPU load and clock without root where
Android blocks kgsl (#1969)
On some Adreno devices (the Retroid Pocket Duo) SELinux hides kgsl's sysfs
nodes from apps, so the HUD showed no GPU % and no GPU clock. Devices whose
nodes are readable keep using them; nothing changes there or on GPUs
without kgsl.
Where the nodes are blocked:
- GPU %: a native reader maps kgsl's memstore read-only (any kgsl client
may) and samples the ringbuffer start/end timestamps once a millisecond
while a HUD is shown. That covers all GPU work, Android's own
composition included.
- Backup for GPU %: an implicit Vulkan layer in the guest
(tools/gpuload) and the compositor time their own GPU work with
timestamp queries. It runs until the memstore stamps are seen moving,
and for the whole session if they never move.
- GPU clock: a small GPU job of our own reads the always-on and RBBM
cycle counters (libgpuclock).
The HUD diagnostics export lists which source each GPU value came from.
tools/gpuload/build.sh takes the Vulkan headers from build_local.sh's NDK
first and keeps NDK paths with spaces intact.
---
app/src/main/cpp/CMakeLists.txt | 2 +
app/src/main/cpp/gpuclock/kgsl_busy.c | 121 ++++++
app/src/main/cpp/gpuclock/kgsl_busy.h | 37 ++
app/src/main/cpp/gpuclock/kgsl_clock.c | 156 +++++++
app/src/main/cpp/waylandcomp/CMakeLists.txt | 2 +
app/src/main/cpp/waylandcomp/src/gpu_busy.c | 89 ++++
app/src/main/cpp/waylandcomp/src/gpu_busy.h | 9 +
app/src/main/cpp/waylandcomp/src/vk_present.c | 3 +
.../launcher/hud/DroidDeckHudHost.kt | 1 +
.../launcher/session/GpuStatsComponent.kt | 234 +++++++++-
.../droiddeck/launcher/session/KgslBusy.kt | 50 +++
.../droiddeck/launcher/session/KgslClock.kt | 50 +++
.../launcher/session/SessionFiles.kt | 7 +-
.../launcher/session/SessionService.kt | 3 +-
.../launcher/wayland/WaylandCompositor.java | 4 +
.../launcher/session/GpuLayerLoadTest.kt | 114 +++++
.../launcher/session/GpuMemstoreLoadTest.kt | 74 ++++
tools/build_local.sh | 7 +
tools/components/recipes.json | 4 +-
tools/components/recipes/runtime.sh | 2 +
tools/gpuload/build.sh | 53 +++
.../layer/VK_LAYER_DROIDDECK_gpu_load.json | 20 +
tools/gpuload/src/gpuload_busy.h | 22 +
tools/gpuload/src/gpuload_ring.h | 215 +++++++++
.../gpuload/src/vk_layer_droiddeck_gpuload.c | 410 ++++++++++++++++++
tools/gpuload/tests/test_gpuload_busy.c | 24 +
tools/gpuload/tests/test_gpuload_ring.c | 96 ++++
tools/release/runtime_inputs.py | 3 +
tools/tests/kgsl_busy_harness.c | 56 +++
tools/tests/test_kgsl_busy.py | 33 ++
30 files changed, 1891 insertions(+), 10 deletions(-)
create mode 100644 app/src/main/cpp/gpuclock/kgsl_busy.c
create mode 100644 app/src/main/cpp/gpuclock/kgsl_busy.h
create mode 100644 app/src/main/cpp/gpuclock/kgsl_clock.c
create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_busy.c
create mode 100644 app/src/main/cpp/waylandcomp/src/gpu_busy.h
create mode 100644 app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt
create mode 100755 tools/gpuload/build.sh
create mode 100644 tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json
create mode 100644 tools/gpuload/src/gpuload_busy.h
create mode 100644 tools/gpuload/src/gpuload_ring.h
create mode 100644 tools/gpuload/src/vk_layer_droiddeck_gpuload.c
create mode 100644 tools/gpuload/tests/test_gpuload_busy.c
create mode 100644 tools/gpuload/tests/test_gpuload_ring.c
create mode 100644 tools/tests/kgsl_busy_harness.c
create mode 100644 tools/tests/test_kgsl_busy.py
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
index ab06f6a56..61c7c9c2f 100644
--- a/app/src/main/cpp/CMakeLists.txt
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -37,6 +37,8 @@ target_link_libraries(sdnative log)
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl)
+add_library(gpuclock SHARED gpuclock/kgsl_clock.c gpuclock/kgsl_busy.c)
+
add_library(termux SHARED termux/termux.c)
add_library(ddvenc SHARED venc/venc_server.cpp)
diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.c b/app/src/main/cpp/gpuclock/kgsl_busy.c
new file mode 100644
index 000000000..a61114793
--- /dev/null
+++ b/app/src/main/cpp/gpuclock/kgsl_busy.c
@@ -0,0 +1,121 @@
+// Whole-GPU busy share without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps: a thread
+// samples the memstore ringbuffer timestamps (kgsl_busy.h) once a millisecond while running.
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../adrenotools/src/hook/kgsl.h"
+#include "kgsl_busy.h"
+
+#define SAMPLE_NS 1000000L
+
+struct reader {
+ int fd;
+ void *map;
+ size_t bytes;
+ int first;
+ uint32_t last[KGSL_BUSY_SLOTS];
+ pthread_mutex_t lock;
+ pthread_t thread;
+ int thread_on;
+ atomic_int run;
+ atomic_uint_fast64_t busy, samples, moves;
+};
+
+static void *sample_loop(void *arg) {
+ struct reader *r = arg;
+ const struct timespec gap = {0, SAMPLE_NS};
+ while (atomic_load(&r->run)) {
+ int moved = 0;
+ int busy = kgsl_busy_sample(r->map, r->first, r->last, &moved);
+ if (busy) atomic_fetch_add(&r->busy, 1);
+ if (moved) atomic_fetch_add(&r->moves, 1);
+ atomic_fetch_add(&r->samples, 1);
+ nanosleep(&gap, NULL);
+ }
+ return NULL;
+}
+
+static void reader_run(struct reader *r, int on) {
+ pthread_mutex_lock(&r->lock);
+ if (on && !r->thread_on) {
+ atomic_store(&r->run, 1);
+ if (pthread_create(&r->thread, NULL, sample_loop, r) == 0) r->thread_on = 1;
+ else atomic_store(&r->run, 0);
+ } else if (!on && r->thread_on) {
+ atomic_store(&r->run, 0);
+ pthread_join(r->thread, NULL);
+ r->thread_on = 0;
+ }
+ pthread_mutex_unlock(&r->lock);
+}
+
+static void reader_close(struct reader *r) {
+ if (!r) return;
+ reader_run(r, 0);
+ if (r->map && r->map != MAP_FAILED) munmap(r->map, r->bytes);
+ if (r->fd >= 0) close(r->fd);
+ pthread_mutex_destroy(&r->lock);
+ free(r);
+}
+
+// Returns 0 or a negative errno; *out is set on success.
+static int reader_open(struct reader **out) {
+ struct reader *r = calloc(1, sizeof(*r));
+ if (!r) return -ENOMEM;
+ pthread_mutex_init(&r->lock, NULL);
+ r->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
+ if (r->fd < 0) { int e = -errno; reader_close(r); return e; }
+ struct kgsl_shadowprop shadow;
+ memset(&shadow, 0, sizeof(shadow));
+ struct kgsl_device_getproperty prop = {.type = KGSL_PROP_DEVICE_SHADOW, .value = &shadow, .sizebytes = sizeof(shadow)};
+ if (ioctl(r->fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &prop)) { int e = -errno; reader_close(r); return e; }
+ r->bytes = shadow.size;
+ r->first = kgsl_busy_first_slot(shadow.size);
+ if (r->first < 0) { reader_close(r); return -ERANGE; }
+ r->map = mmap(NULL, r->bytes, PROT_READ, MAP_SHARED, r->fd, (off_t)shadow.gpuaddr);
+ if (r->map == MAP_FAILED) { int e = -errno; reader_close(r); return e; }
+ int moved = 0, started = 0;
+ kgsl_busy_sample(r->map, r->first, r->last, &moved);
+ for (int i = 0; i < KGSL_BUSY_SLOTS; i++) started |= r->last[i] != 0;
+ if (!started) { reader_close(r); return -ENODATA; }
+ *out = r;
+ return 0;
+}
+
+#ifndef KGSL_BUSY_NO_JNI
+#include
+
+JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeOpen(JNIEnv *env, jclass cls, jintArray error) {
+ struct reader *r = NULL;
+ int e = reader_open(&r);
+ if (e && error && (*env)->GetArrayLength(env, error) > 0) {
+ jint v = e;
+ (*env)->SetIntArrayRegion(env, error, 0, 1, &v);
+ }
+ return e ? 0 : (jlong)(intptr_t)r;
+}
+
+JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRun(JNIEnv *env, jclass cls, jlong handle, jboolean on) {
+ if (handle) reader_run((struct reader *)(intptr_t)handle, on ? 1 : 0);
+}
+
+JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRead(JNIEnv *env, jclass cls, jlong handle, jlongArray out) {
+ if (!handle || !out || (*env)->GetArrayLength(env, out) < 3) return JNI_FALSE;
+ struct reader *r = (struct reader *)(intptr_t)handle;
+ jlong v[3] = {(jlong)atomic_load(&r->busy), (jlong)atomic_load(&r->samples), (jlong)atomic_load(&r->moves)};
+ (*env)->SetLongArrayRegion(env, out, 0, 3, v);
+ return JNI_TRUE;
+}
+
+JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeClose(JNIEnv *env, jclass cls, jlong handle) {
+ reader_close((struct reader *)(intptr_t)handle);
+}
+#endif
diff --git a/app/src/main/cpp/gpuclock/kgsl_busy.h b/app/src/main/cpp/gpuclock/kgsl_busy.h
new file mode 100644
index 000000000..2179cf533
--- /dev/null
+++ b/app/src/main/cpp/gpuclock/kgsl_busy.h
@@ -0,0 +1,37 @@
+#ifndef DROIDDECK_KGSL_BUSY_H
+#define DROIDDECK_KGSL_BUSY_H
+// kgsl lets any client map its memstore read-only (KGSL_PROP_DEVICE_SHADOW). The CP writes a ringbuffer's
+// start-of-pipe timestamp there before each submission's command buffers and its end-of-pipe timestamp after them
+// (adreno_gen7_ringbuffer.c gen7_ringbuffer_addcmds), so start != end means that ringbuffer has work on the GPU.
+// Ringbuffer entries start at KGSL_MEMSTORE_MAX: entries - 2 - 4 levels, or entries - 1 - 4 on kernels without the
+// LPAC slot. Entries - 6 .. entries - 2 cover both; a context entry in that range follows the same start/end rule.
+#include
+#include
+
+#define KGSL_BUSY_ENTRY_WORDS 10
+#define KGSL_BUSY_SOP 0
+#define KGSL_BUSY_EOP 2
+#define KGSL_BUSY_SLOTS 5
+
+static inline int kgsl_busy_first_slot(size_t bytes) {
+ size_t entries = bytes / (KGSL_BUSY_ENTRY_WORDS * sizeof(uint32_t));
+ return entries < 8 ? -1 : (int)(entries - 6);
+}
+
+// 1 when a watched slot has a submission between its start and end timestamps. *moved is set when a start
+// timestamp differs from last[], which then holds the new ones.
+static inline int kgsl_busy_sample(const volatile uint32_t *memstore, int first, uint32_t last[KGSL_BUSY_SLOTS], int *moved) {
+ int busy = 0;
+ for (int i = 0; i < KGSL_BUSY_SLOTS; i++) {
+ const volatile uint32_t *entry = memstore + (size_t)(first + i) * KGSL_BUSY_ENTRY_WORDS;
+ uint32_t sop = entry[KGSL_BUSY_SOP], eop = entry[KGSL_BUSY_EOP];
+ if (sop != eop) busy = 1;
+ if (sop != last[i]) {
+ *moved = 1;
+ last[i] = sop;
+ }
+ }
+ return busy;
+}
+
+#endif
diff --git a/app/src/main/cpp/gpuclock/kgsl_clock.c b/app/src/main/cpp/gpuclock/kgsl_clock.c
new file mode 100644
index 000000000..b021208b4
--- /dev/null
+++ b/app/src/main/cpp/gpuclock/kgsl_clock.c
@@ -0,0 +1,156 @@
+// GPU clock without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps.
+//
+// kgsl lets any app reserve a performance counter (IOCTL_KGSL_PERFCOUNTER_GET) but refuses to read it back while
+// /sys/class/kgsl/kgsl-3d0/perfcounter is 0, and clears the counters whenever the GPU switches to another process.
+// So the counter is read by the GPU itself: a small command buffer copies CP_ALWAYS_ON_COUNTER (19.2 MHz) and the
+// RBBM ALWAYS_COUNT counter (GPU core cycles) to memory, runs a block of NOP packets, and copies both again.
+// clock = cycles / always-on ticks * 19.2 MHz. Both reads sit in one command buffer, so a counter clear between
+// them only happens if the buffer is preempted; such samples fail the range checks and are dropped.
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../adrenotools/src/hook/kgsl.h"
+
+#define CP_TYPE7_PKT 0x70000000u
+#define CP_NOP 0x10u
+#define CP_REG_TO_MEM 0x3eu
+#define CP_ALWAYS_ON_COUNTER 0x980u
+#define RBBM_ALWAYS_COUNT 0u
+// gen7 CP protection marks registers 0x000-0x4ff readable by command buffers (adreno-gpulist.h); a counter
+// kgsl places anywhere else is not read.
+#define READABLE_LAST 0x4ffu
+#define XO_HZ 19200000.0
+#define NOP_DWORDS 0x3fffu
+#define NOP_BLOCKS 4
+
+struct sampler {
+ int fd;
+ unsigned context;
+ unsigned counter_reg;
+ struct kgsl_gpumem_alloc_id ib, out;
+ uint32_t *ib_map;
+ volatile uint64_t *out_map;
+ size_t ib_bytes;
+};
+
+static unsigned odd_parity(unsigned v) {
+ v ^= v >> 16;
+ v ^= v >> 8;
+ v ^= v >> 4;
+ v &= 0xf;
+ return (~0x6996u >> v) & 1u;
+}
+
+static uint32_t pkt7(unsigned op, unsigned count) {
+ return CP_TYPE7_PKT | count | odd_parity(count) << 15 | (op & 0x7f) << 16 | odd_parity(op) << 23;
+}
+
+static void sampler_close(struct sampler *s) {
+ if (!s) return;
+ if (s->fd >= 0) {
+ if (s->ib_map && s->ib_map != MAP_FAILED) munmap(s->ib_map, s->ib.mmapsize);
+ if (s->out_map && s->out_map != MAP_FAILED) munmap((void *)s->out_map, s->out.mmapsize);
+ if (s->ib.id) { struct kgsl_gpumem_free_id f = {.id = s->ib.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); }
+ if (s->out.id) { struct kgsl_gpumem_free_id f = {.id = s->out.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); }
+ if (s->context) { struct kgsl_drawctxt_destroy d = {.drawctxt_id = s->context}; ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &d); }
+ if (s->counter_reg) {
+ struct kgsl_perfcounter_put p = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT};
+ ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_PUT, &p);
+ }
+ close(s->fd);
+ }
+ free(s);
+}
+
+static void emit_reads(struct sampler *s, size_t *n, unsigned pass) {
+ const unsigned regs[2] = {CP_ALWAYS_ON_COUNTER, s->counter_reg};
+ for (unsigned k = 0; k < 2; k++) {
+ uint64_t dst = s->out.gpuaddr + (uint64_t)(pass * 2 + k) * sizeof(uint64_t);
+ s->ib_map[(*n)++] = pkt7(CP_REG_TO_MEM, 3);
+ s->ib_map[(*n)++] = regs[k] | (2u << 18) | (1u << 30); // two dwords, 64-bit read
+ s->ib_map[(*n)++] = (uint32_t)dst;
+ s->ib_map[(*n)++] = (uint32_t)(dst >> 32);
+ }
+}
+
+// Returns 0 or a negative errno; *out is set on success.
+static int sampler_open(struct sampler **out) {
+ struct sampler *s = calloc(1, sizeof(*s));
+ if (!s) return -ENOMEM;
+ s->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
+ if (s->fd < 0) { int e = -errno; s->fd = -1; sampler_close(s); return e; }
+ struct kgsl_perfcounter_get get = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT};
+ if (ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_GET, &get)) { int e = -errno; sampler_close(s); return e; }
+ s->counter_reg = get.offset;
+ if (!get.offset || get.offset > READABLE_LAST) { sampler_close(s); return -ERANGE; }
+ struct kgsl_drawctxt_create ctx = {.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE};
+ if (ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_CREATE, &ctx)) { int e = -errno; sampler_close(s); return e; }
+ s->context = ctx.drawctxt_id;
+ s->ib_bytes = (16 + NOP_BLOCKS * (NOP_DWORDS + 1)) * sizeof(uint32_t);
+ s->ib.size = (s->ib_bytes + 4095) & ~(size_t)4095;
+ s->out.size = 4096;
+ if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->ib)) { int e = -errno; s->ib.id = 0; sampler_close(s); return e; }
+ if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->out)) { int e = -errno; s->out.id = 0; sampler_close(s); return e; }
+ s->ib_map = mmap(NULL, s->ib.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->ib.id << 12);
+ s->out_map = mmap(NULL, s->out.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->out.id << 12);
+ if (s->ib_map == MAP_FAILED || s->out_map == MAP_FAILED) { int e = -errno; sampler_close(s); return e; }
+ size_t n = 0;
+ emit_reads(s, &n, 0);
+ for (unsigned b = 0; b < NOP_BLOCKS; b++) {
+ s->ib_map[n++] = pkt7(CP_NOP, NOP_DWORDS);
+ memset(&s->ib_map[n], 0, NOP_DWORDS * sizeof(uint32_t));
+ n += NOP_DWORDS;
+ }
+ emit_reads(s, &n, 1);
+ s->ib_bytes = n * sizeof(uint32_t);
+ *out = s;
+ return 0;
+}
+
+// MHz, or a negative value: -errno from kgsl, or -1000 for a sample that failed the range checks.
+static int sampler_mhz(struct sampler *s) {
+ for (int i = 0; i < 4; i++) s->out_map[i] = 0;
+ struct kgsl_command_object cmd = {.gpuaddr = s->ib.gpuaddr, .size = s->ib_bytes, .flags = KGSL_CMDLIST_IB};
+ struct kgsl_gpu_command submit = {
+ .cmdlist = (uint64_t)(uintptr_t)&cmd, .cmdsize = sizeof(cmd), .numcmds = 1, .context_id = s->context,
+ };
+ if (ioctl(s->fd, IOCTL_KGSL_GPU_COMMAND, &submit)) return -errno;
+ struct kgsl_device_waittimestamp_ctxtid wait = {.context_id = s->context, .timestamp = submit.timestamp, .timeout = 250};
+ if (ioctl(s->fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait)) return -errno;
+ uint64_t xo0 = s->out_map[0], cyc0 = s->out_map[1], xo1 = s->out_map[2], cyc1 = s->out_map[3];
+ if (xo1 <= xo0 || cyc1 <= cyc0) return -1000;
+ uint64_t xo = xo1 - xo0, cycles = cyc1 - cyc0;
+ // 5 us .. 50 ms of always-on time, and a clock between 50 MHz and 2 GHz.
+ if (xo < 96 || xo > 960000) return -1000;
+ double mhz = (double)cycles / (double)xo * XO_HZ / 1e6;
+ if (mhz < 50 || mhz > 2000) return -1000;
+ return (int)(mhz + 0.5);
+}
+
+#ifndef KGSL_CLOCK_NO_JNI
+#include
+
+JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeOpen(JNIEnv *env, jclass cls, jintArray error) {
+ struct sampler *s = NULL;
+ int r = sampler_open(&s);
+ if (r && error && (*env)->GetArrayLength(env, error) > 0) {
+ jint e = r;
+ (*env)->SetIntArrayRegion(env, error, 0, 1, &e);
+ }
+ return r ? 0 : (jlong)(intptr_t)s;
+}
+
+JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeSample(JNIEnv *env, jclass cls, jlong handle) {
+ return handle ? sampler_mhz((struct sampler *)(intptr_t)handle) : -EBADF;
+}
+
+JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeClose(JNIEnv *env, jclass cls, jlong handle) {
+ sampler_close((struct sampler *)(intptr_t)handle);
+}
+#endif
diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt
index a4de38ca1..fd0b2f20a 100644
--- a/app/src/main/cpp/waylandcomp/CMakeLists.txt
+++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt
@@ -18,6 +18,7 @@ add_library(droiddeckwayland SHARED
src/wl_color_mgmt.c
src/hdr_compose.c
src/blend_pass.c
+ src/gpu_busy.c
generated/color-management-v1-protocol.c
generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c
@@ -43,6 +44,7 @@ target_include_directories(droiddeckwayland PRIVATE
prebuilt/include
src
${CMAKE_CURRENT_SOURCE_DIR}/../shaders
+ ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/gpuload/src
)
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.c b/app/src/main/cpp/waylandcomp/src/gpu_busy.c
new file mode 100644
index 000000000..48d565c20
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.c
@@ -0,0 +1,89 @@
+#include "gpu_busy.h"
+
+#include
+#include
+#include
+
+#include "gpuload_ring.h"
+
+static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
+static struct gpuload_vk g_v;
+static struct gpuload_ring g_ring;
+static uint32_t g_family, g_bits;
+static PFN_vkQueueSubmit g_submit;
+static int g_was_on;
+static _Atomic int g_on, g_lost, g_timing;
+static _Atomic uint64_t g_busy_ns;
+
+static VkResult VKAPI_CALL timed_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) {
+ if (!count || !atomic_load(&g_on) || atomic_load(&g_lost)) return g_submit(queue, count, infos, fence);
+ pthread_mutex_lock(&g_lock);
+ if (!atomic_load(&g_on) || atomic_load(&g_lost)) {
+ pthread_mutex_unlock(&g_lock);
+ return g_submit(queue, count, infos, fence);
+ }
+ if (g_ring.state == 0 && gpuload_ring_setup(&g_ring, &g_v, g_family, g_bits)) atomic_store(&g_timing, 1);
+ VkSubmitInfo *mine = NULL;
+ uint32_t used = 0;
+ if (g_ring.state == 1) {
+ uint64_t ns = gpuload_ring_harvest(&g_ring, &g_v);
+ if (g_was_on) atomic_fetch_add(&g_busy_ns, ns); // the first harvest after a pause holds work timed before it
+ g_was_on = 1;
+ mine = gpuload_ring_wrap(&g_ring, count, infos, &used);
+ }
+ VkResult r = g_submit(queue, count, mine ? mine : infos, fence);
+ if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&g_ring, used);
+ pthread_mutex_unlock(&g_lock);
+ free(mine);
+ return r;
+}
+
+void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family) {
+ pthread_mutex_lock(&g_lock);
+ memset(&g_ring, 0, sizeof(g_ring));
+ g_was_on = 0;
+ atomic_store(&g_timing, 0);
+ atomic_store(&g_lost, 0);
+ PFN_vkGetDeviceProcAddr gdpa = g_vk.GetDeviceProcAddr;
+ g_v = (struct gpuload_vk){
+ .device = dev,
+ .create_pool = g_vk.CreateCommandPool,
+ .destroy_pool = (PFN_vkDestroyCommandPool)gdpa(dev, "vkDestroyCommandPool"),
+ .alloc_cmds = g_vk.AllocateCommandBuffers,
+ .begin = g_vk.BeginCommandBuffer,
+ .end = g_vk.EndCommandBuffer,
+ .cmd_reset = (PFN_vkCmdResetQueryPool)gdpa(dev, "vkCmdResetQueryPool"),
+ .cmd_timestamp = (PFN_vkCmdWriteTimestamp)gdpa(dev, "vkCmdWriteTimestamp"),
+ .create_queries = (PFN_vkCreateQueryPool)gdpa(dev, "vkCreateQueryPool"),
+ .destroy_queries = (PFN_vkDestroyQueryPool)gdpa(dev, "vkDestroyQueryPool"),
+ .results = (PFN_vkGetQueryPoolResults)gdpa(dev, "vkGetQueryPoolResults"),
+ };
+ VkPhysicalDeviceProperties props;
+ g_vk.GetPhysicalDeviceProperties(pd, &props);
+ gpuload_vk_period(&g_v, props.limits.timestampPeriod);
+ VkQueueFamilyProperties families[16];
+ uint32_t n = 16;
+ g_vk.GetPhysicalDeviceQueueFamilyProperties(pd, &n, families);
+ g_family = family;
+ g_bits = family < n ? families[family].timestampValidBits : 0;
+ // vk_loader_load_device has just put the driver's function back.
+ if (g_vk.QueueSubmit != timed_submit) g_submit = g_vk.QueueSubmit;
+ if (g_submit) g_vk.QueueSubmit = timed_submit;
+ pthread_mutex_unlock(&g_lock);
+}
+
+void vkp_gpu_busy_device_lost(void) {
+ atomic_store(&g_lost, 1);
+ atomic_store(&g_timing, 0);
+}
+
+JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGpuBusyNs(JNIEnv *env, jclass cls) {
+ return atomic_load(&g_timing) ? (jlong)atomic_load(&g_busy_ns) : -1;
+}
+
+JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTiming(JNIEnv *env, jclass cls, jboolean on) {
+ pthread_mutex_lock(&g_lock);
+ if (!on) g_was_on = 0;
+ atomic_store(&g_on, on ? 1 : 0);
+ pthread_mutex_unlock(&g_lock);
+}
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_busy.h b/app/src/main/cpp/waylandcomp/src/gpu_busy.h
new file mode 100644
index 000000000..a477d5236
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_busy.h
@@ -0,0 +1,9 @@
+#ifndef GPU_BUSY_H
+#define GPU_BUSY_H
+#include "vk_loader.h"
+
+// Times the compositor's own GPU work for the HUD (tools/gpuload/src/gpuload_ring.h); call after the device and its
+// functions are loaded. Off until WaylandCompositor.nativeSetGpuTiming(true).
+void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family);
+void vkp_gpu_busy_device_lost(void);
+#endif
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index 8d6b3480a..ffdc5cb1d 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -7,6 +7,7 @@
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
+#include "gpu_busy.h"
#include "droiddeck_color.h"
#include "quest_device.h"
#include
@@ -517,6 +518,7 @@ static int dev_init(void) {
}
vk_loader_load_device(g_dev);
g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue);
+ vkp_gpu_busy_device(g_pd, g_dev, g_qfam);
if (want_sem_fd) {
g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR");
VkSemaphoreCreateInfo wsci = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
@@ -1311,6 +1313,7 @@ static void device_lost(const char *where) {
g_dev_state = -2;
g_base_black = 0;
vkp_framegen_device_lost();
+ vkp_gpu_busy_device_lost();
droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
index 2784f4fa8..296e0487f 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
@@ -213,6 +213,7 @@ class DroidDeckHudHost(context: Context) {
"CPU ${mango.cpuPercent}% ${mango.cpuTempC}°C, GPU ${mango.gpuPercent}% ${mango.gpuClockMhz}MHz ${mango.gpuTempC}°C, " +
"RAM ${mango.ramUsedText()}/${mango.ramTotalText()}, VRAM ${mango.vramText()}, battery ${mango.battery.percent}% " +
"${mango.battery.watts}W charging=${mango.battery.charging}\n" +
+ "GPU feeds: ${com.droiddeck.launcher.session.GpuStatsComponent.sources.ifEmpty { "none (no session, or kgsl absent)" }}\n" +
"CPU temperature zones: ${LinuxRuntime.cpuTempZones().joinToString { it.name }}\n" +
"Frames: ${if (feed.engine.isNotEmpty() || feed.pid > 0) "gamescope (pid ${feed.pid}, ${feed.engine})" else "compositor"}, " +
"fps ${feed.stats.fps}\n")
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
index 006350916..8e10024fc 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
@@ -7,10 +7,16 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.io.RandomAccessFile
-class GpuStatsComponent(val dir: File) : SessionPart() {
+class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() {
@Volatile private var running = false
private var thread: Thread? = null
private val feeds = ArrayList()
+ private var layerLoad: LayerLoad? = null
+ private var memstore: KgslBusy.Reader? = null
+ private var memstoreLoad: MemstoreLoad? = null
+ private var memstoreState: String? = null
+ private var cycleClock: KgslClock.Sampler? = null
+ @Volatile private var shown = false
private class Feed(
val file: File, val source: String, val read: () -> Long?, val format: String, val percent: Boolean = false,
@@ -24,9 +30,11 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
}
fun prepare(): Boolean {
+ sources = ""
if (!File(KGSL).exists()) return false
val unreadable = MANGOHUD_FILES.any { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }
- if (!unreadable && !File(GPUBUSY).canRead()) return false
+ // Where SELinux hides the kgsl nodes completely (exists() is false too), the layer and cycle-counter feeds stand in.
+ if (!unreadable && !File(GPUBUSY).canRead() && !loadBlocked()) return false
return try {
dir.mkdirs()
dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) }
@@ -34,19 +42,18 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
PASSTHROUGH_FILES.forEach { name ->
runCatching { File(dir, name).writeText(File(KGSL, name).readText()) }
}
- loadFeed()?.let { load ->
+ (loadFeed() ?: blockedLoadFeed())?.let { load ->
feeds.add(load)
feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true))
}
- pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { clock ->
+ (pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) } ?: cycleClockFeed())?.let { clock ->
feeds.add(clock)
feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n"))
}
pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) }
refresh()
- Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name ->
- "$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none")
- })
+ describeSources()
+ Log.i(TAG, "hud: kgsl stats from the session's own: $sources")
true
} catch (e: Exception) {
Log.w(TAG, "hud: could not stand in for kgsl stats: $e")
@@ -68,17 +75,212 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
// kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read.
private fun refresh() {
+ if (layerLoad != null || cycleClock != null || memstore != null) {
+ shown = hudShown()
+ memstore?.run(shown)
+ if (!shown) memstoreLoad?.pause()
+ layerLoad?.wanted(shown && memstoreLoad?.working != true)
+ }
val values = HashMap<() -> Long?, Long?>()
for (feed in feeds) {
if (feed.read !in values) values[feed.read] = feed.read()
values[feed.read]?.let { write(feed, it) }
}
+ memstoreLoad?.let { load ->
+ val state = when {
+ load.working -> "working"
+ load.failed -> "failed"
+ else -> null
+ }
+ if (state != memstoreState) {
+ memstoreState = state
+ if (load.failed) {
+ memstore?.close()
+ memstore = null
+ Log.i(TAG, "hud: kgsl memstore timestamps never moved; GPU load from Vulkan timing")
+ } else if (load.working) {
+ Log.i(TAG, "hud: GPU load from the kgsl memstore (all GPU work)")
+ }
+ describeSources()
+ }
+ }
+ }
+
+ private fun describeSources() {
+ sources = MANGOHUD_FILES.joinToString { name ->
+ val source = if (name == "gpu_busy_percentage" && memstoreLoad != null) memstoreSource()
+ else feeds.firstOrNull { it.file.name == name }?.source
+ "$name " + (source ?: "none")
+ }
+ }
+
+ private fun memstoreSource(): String = when {
+ memstoreLoad?.working == true -> "kgsl memstore ringbuffer timestamps (all GPU work)"
+ memstoreLoad?.failed == true -> LAYER_SOURCE
+ else -> "kgsl memstore once its timestamps move; until then $LAYER_SOURCE"
}
override fun stop() {
running = false
thread?.interrupt()
thread = null
+ memstore?.close()
+ memstore = null
+ memstoreLoad = null
+ memstoreState = null
+ layerLoad?.clear()
+ cycleClock?.close()
+ cycleClock = null
+ sources = ""
+ }
+
+ private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing
+
+ // Android hides the load (kgsl sysfs blocked): the kgsl memstore gives the whole GPU's busy share. The in-game
+ // Vulkan layer and the compositor time their own GPU work until the memstore timestamps are seen moving, and
+ // for the whole session where they never move.
+ private fun blockedLoadFeed(): Feed? {
+ if (!loadBlocked()) return null
+ val load = LayerLoad(guestLoadDir(root), extra = ::compositorBusyNs, onWanted = ::compositorTiming)
+ .also { it.clear(); layerLoad = it }
+ val sampled = KgslBusy.open()?.let { reader ->
+ memstore = reader
+ MemstoreLoad(reader::counts).also { memstoreLoad = it }
+ }
+ val read = read@{
+ if (!shown) return@read null
+ val whole = sampled?.percent()
+ if (sampled?.working == true) whole else load.percent()
+ }
+ return Feed(File(dir, "gpu_busy_percentage"), LAYER_SOURCE, read, "%3d %%\n")
+ }
+
+ // The clock nodes are blocked too: read the GPU's cycle counter from a small GPU job of our own.
+ private fun cycleClockFeed(): Feed? {
+ val sampler = KgslClock.open() ?: return null
+ cycleClock = sampler
+ // clock_mhz and cur_freq both read this; one GPU job per refresh serves both.
+ var at = 0L
+ var last: Long? = null
+ val read = {
+ val now = SystemClock.elapsedRealtime()
+ if (now - at > PERIOD_MS / 2) { at = now; last = if (shown) sampler.mhz()?.toLong() else null }
+ last
+ }
+ return Feed(File(dir, "clock_mhz"), "GPU cycle counter (own GPU job)", read, "%5d\n")
+ }
+
+ private fun compositorBusyNs(): Long? =
+ runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeGpuBusyNs() }.getOrNull()?.takeIf { it >= 0 }
+
+ private fun compositorTiming(on: Boolean) {
+ runCatching { com.droiddeck.launcher.wayland.WaylandCompositor.nativeSetGpuTiming(on) }
+ }
+
+ /**
+ * Busy time the layer (tools/gpuload) writes per process, plus [extra] (the compositor's own GPU time), as a share
+ * of wall time between two reads.
+ */
+ internal class LayerLoad(
+ private val dir: File,
+ private val clock: () -> Long = System::nanoTime,
+ private val extra: () -> Long? = { null },
+ private val onWanted: (Boolean) -> Unit = {},
+ ) {
+ private val last = HashMap()
+ private var lastAt = 0L
+ private var on = false
+
+ fun wanted(on: Boolean) {
+ val flag = File(dir, "on")
+ if (on && !flag.exists()) runCatching { dir.mkdirs(); flag.createNewFile() }
+ if (!on && flag.exists()) { flag.delete(); last.clear(); lastAt = 0L }
+ if (on != this.on) onWanted(on)
+ this.on = on
+ }
+
+ fun percent(): Long? {
+ val now = clock()
+ val files = dir.listFiles { f -> f.name.isNotEmpty() && f.name.all(Char::isDigit) }.orEmpty()
+ var busy = 0L
+ val seen = HashSet()
+ for (file in files) {
+ val total = runCatching { file.readText().trim().substringBefore(' ').toLong() }.getOrNull() ?: continue
+ seen += file.name
+ last[file.name]?.let { if (total >= it) busy += total - it }
+ last[file.name] = total
+ if (System.currentTimeMillis() - file.lastModified() > STALE_MS) file.delete()
+ }
+ extra()?.let { total ->
+ seen += EXTRA
+ last[EXTRA]?.let { if (total >= it) busy += total - it }
+ last[EXTRA] = total
+ }
+ last.keys.retainAll(seen)
+ val wall = now - lastAt
+ val first = lastAt == 0L
+ lastAt = now
+ if (first || wall <= 0) return null
+ return (busy * 100 / wall).coerceIn(0, 100)
+ }
+
+ fun clear() {
+ dir.listFiles()?.forEach { it.delete() }
+ last.clear()
+ lastAt = 0L
+ if (on) onWanted(false)
+ on = false
+ }
+
+ private companion object {
+ const val EXTRA = "compositor"
+ }
+ }
+
+ /**
+ * Busy share over the last [WINDOW] reads from [KgslBusy] running totals (busy samples, samples, samples with
+ * new ringbuffer work). Not [working] until new work has been seen; [failed] once [GIVE_UP_SAMPLES] samples
+ * passed without any.
+ */
+ internal class MemstoreLoad(private val counts: () -> LongArray?) {
+ var working = false
+ private set
+ var failed = false
+ private set
+ private val window = ArrayDeque()
+ private var waited = 0L
+
+ fun percent(): Long? {
+ if (failed) return null
+ val now = counts() ?: return null
+ val previous = window.lastOrNull()
+ window.addLast(now)
+ while (window.size > WINDOW + 1) window.removeFirst()
+ if (previous == null) return null
+ val fresh = now[1] - previous[1]
+ if (fresh <= 0) return null
+ if (!working) {
+ if (now[2] > previous[2]) {
+ working = true
+ } else {
+ waited += fresh
+ if (waited >= GIVE_UP_SAMPLES) failed = true
+ return null
+ }
+ }
+ val base = window.first()
+ return ((now[0] - base[0]) * 100 / (now[1] - base[1])).coerceIn(0, 100)
+ }
+
+ /** The totals stop while sampling is off; the next reading starts a new window. */
+ fun pause() {
+ window.clear()
+ }
+
+ companion object {
+ const val GIVE_UP_SAMPLES = 5_000L
+ const val WINDOW = 2
+ }
}
private fun loadFeed(): Feed? {
@@ -134,6 +336,24 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
private const val LOAD_HOLD_MS = 5_000L
private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp")
private val PASSTHROUGH_FILES = listOf("gpu_model", "gpu_chipid")
+ const val GUEST_LOAD_DIR = "/run/droiddeck-hud/gpu-load"
+ private const val LAYER_SOURCE = "Vulkan timing: games ($GUEST_LOAD_DIR) + DroidDeck compositor"
+
+ /** Where the running session's GPU feeds come from, for the HUD diagnostics export. */
+ @Volatile var sources = ""
+ private const val STALE_MS = 30_000L
+
+ fun guestLoadDir(root: File) = File(root, GUEST_LOAD_DIR.removePrefix("/"))
+
+ /** True when this app can see kgsl but none of its GPU load nodes (SELinux-blocked, e.g. the Retroid Pocket Duo). */
+ fun loadBlocked(): Boolean = File(KGSL).exists() && !File(GPUBUSY).canRead() && LOAD_SOURCES.none { File(it).canRead() }
+
+ /** Turns on the in-game layer (tools/gpuload) where the load is blocked; empty elsewhere. */
+ fun guestEnv(root: File): List {
+ if (!loadBlocked()) return emptyList()
+ guestLoadDir(root).mkdirs()
+ return listOf("DROIDDECK_GPU_LOAD=1", "DROIDDECK_GPU_LOAD_DIR=$GUEST_LOAD_DIR")
+ }
private const val QCOM_GPU = "/sys/kernel/gpu"
private val LOAD_SOURCES = listOf(
"$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy",
diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt
new file mode 100644
index 000000000..dad62121a
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/KgslBusy.kt
@@ -0,0 +1,50 @@
+package com.droiddeck.launcher.session
+
+import android.util.Log
+
+/**
+ * Whole-GPU busy share for Adreno devices whose kgsl load nodes are SELinux-blocked for apps
+ * (app/src/main/cpp/gpuclock/kgsl_busy.c). It covers every process's GPU work, Android's own composition included.
+ */
+object KgslBusy {
+ private const val TAG = "SessionService"
+
+ @JvmStatic private external fun nativeOpen(error: IntArray): Long
+ @JvmStatic private external fun nativeRun(handle: Long, on: Boolean)
+ @JvmStatic private external fun nativeRead(handle: Long, out: LongArray): Boolean
+ @JvmStatic private external fun nativeClose(handle: Long)
+
+ private val loaded: Boolean by lazy {
+ runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess
+ }
+
+ class Reader internal constructor(private var handle: Long) : AutoCloseable {
+ /** Samples once a millisecond while on. */
+ @Synchronized fun run(on: Boolean) {
+ if (handle != 0L) nativeRun(handle, on)
+ }
+
+ /** Running totals: busy samples, samples, samples in which a ringbuffer started new work. */
+ @Synchronized fun counts(): LongArray? {
+ if (handle == 0L) return null
+ val out = LongArray(3)
+ return if (nativeRead(handle, out)) out else null
+ }
+
+ @Synchronized override fun close() {
+ if (handle != 0L) nativeClose(handle)
+ handle = 0L
+ }
+ }
+
+ fun open(): Reader? {
+ if (!loaded) return null
+ val error = IntArray(1)
+ val handle = runCatching { nativeOpen(error) }.getOrDefault(0L)
+ if (handle == 0L) {
+ Log.i(TAG, "hud: no GPU load from the kgsl memstore (open error ${error[0]})")
+ return null
+ }
+ return Reader(handle)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt
new file mode 100644
index 000000000..11c7367ef
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/KgslClock.kt
@@ -0,0 +1,50 @@
+package com.droiddeck.launcher.session
+
+import android.util.Log
+
+/**
+ * GPU clock from the GPU's own cycle counter, for Adreno devices whose kgsl clock nodes are SELinux-blocked for
+ * apps (app/src/main/cpp/gpuclock/kgsl_clock.c). Each [mhz] call runs one tiny GPU job, so callers sample only
+ * while a HUD is shown.
+ */
+object KgslClock {
+ private const val TAG = "SessionService"
+
+ @JvmStatic private external fun nativeOpen(error: IntArray): Long
+ @JvmStatic private external fun nativeSample(handle: Long): Int
+ @JvmStatic private external fun nativeClose(handle: Long)
+
+ private val loaded: Boolean by lazy {
+ runCatching { System.loadLibrary("gpuclock") }.onFailure { Log.w(TAG, "hud: gpuclock library: $it") }.isSuccess
+ }
+
+ class Sampler internal constructor(private var handle: Long) : AutoCloseable {
+ /** MHz, or null when this sample was refused or failed its checks. */
+ @Synchronized fun mhz(): Int? = if (handle == 0L) null else nativeSample(handle).takeIf { it > 0 }
+
+ @Synchronized override fun close() {
+ if (handle != 0L) nativeClose(handle)
+ handle = 0L
+ }
+ }
+
+ /** A sampler that already produced one good reading, or null (the reason goes to the log). */
+ fun open(): Sampler? {
+ if (!loaded) return null
+ val error = IntArray(1)
+ val handle = runCatching { nativeOpen(error) }.getOrDefault(0L)
+ if (handle == 0L) {
+ Log.i(TAG, "hud: no GPU clock from the cycle counter (open error ${error[0]})")
+ return null
+ }
+ val sampler = Sampler(handle)
+ val first = (0 until 3).firstNotNullOfOrNull { sampler.mhz() }
+ if (first == null) {
+ Log.i(TAG, "hud: GPU cycle counter gave no usable reading")
+ sampler.close()
+ return null
+ }
+ Log.i(TAG, "hud: GPU clock from the cycle counter ($first MHz now)")
+ return sampler
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 9bc5e59fe..bcb13d14f 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -160,6 +160,11 @@ object SessionFiles {
) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } +
listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to
"usr/share/vulkan/implicit_layer.d/droiddeck_vr.json")
+ // HUD GPU load where Android hides it (GpuStatsComponent.guestEnv turns the layer on).
+ val gpuLoad = listOf(
+ "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so" to "usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so",
+ "usr/local/lib/droiddeck-gpuload/VK_LAYER_DROIDDECK_gpu_load.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_gpu_load.json",
+ )
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
} + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
@@ -171,7 +176,7 @@ object SessionFiles {
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
"usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll",
- ) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
+ ) + mangoapp + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index 62314d3cb..09bedc4e3 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -808,6 +808,7 @@ class SessionService : Service() {
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
guest.add("MESA_DISK_CACHE_DATABASE=1")
+ guest.addAll(GpuStatsComponent.guestEnv(LinuxRuntime.rootDir(this)))
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null
@@ -994,7 +995,7 @@ class SessionService : Service() {
gpuMem.attach(this)
components.add(gpuMem)
}
- val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"))
+ val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"), LinuxRuntime.rootDir(this))
if (gpuStats.prepare()) {
gpuStats.attach(this)
components.add(gpuStats)
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
index 073ed60ff..65e398eec 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
@@ -148,6 +148,10 @@ public final class WaylandCompositor {
public static native void nativeStart(String xdgRuntimeDir);
+ /** Nanoseconds of the compositor's own GPU work while timing is on, or -1 when it is not timed. */
+ public static native long nativeGpuBusyNs();
+ public static native void nativeSetGpuTiming(boolean on);
+
public static native void nativeStartWithSurface(Surface surface, String xdgRuntimeDir,
String driverPath, String libraryName,
String nativeLibDir);
diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt
new file mode 100644
index 000000000..996035e31
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/GpuLayerLoadTest.kt
@@ -0,0 +1,114 @@
+package com.droiddeck.launcher.session
+
+import java.io.File
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class GpuLayerLoadTest {
+ @get:Rule val tmp = TemporaryFolder()
+ private var now = 0L
+ private fun load(dir: File) = GpuStatsComponent.LayerLoad(dir, clock = { now })
+ private fun write(dir: File, pid: Int, busyNs: Long) = File(dir, "$pid").writeText("%20d %-32s\n".format(busyNs, "game"))
+
+ @Test fun busyTimeOfAllProcessesOverWallTime() {
+ val dir = tmp.newFolder("gpu-load")
+ val load = load(dir)
+ write(dir, 100, 1_000_000_000L)
+ write(dir, 200, 5_000_000_000L)
+ now = 10_000_000_000L
+ assertNull(load.percent())
+ write(dir, 100, 1_300_000_000L)
+ write(dir, 200, 5_100_000_000L)
+ now += 1_000_000_000L
+ assertEquals(40L, load.percent())
+ }
+
+ @Test fun aNewProcessStartsFromItsOwnTotalAndAGoneOneStopsCounting() {
+ val dir = tmp.newFolder("gpu-load")
+ val load = load(dir)
+ write(dir, 100, 2_000_000_000L)
+ now = 1L
+ load.percent()
+ File(dir, "100").delete()
+ write(dir, 300, 9_000_000_000L)
+ now += 500_000_000L
+ assertEquals(0L, load.percent())
+ write(dir, 300, 9_250_000_000L)
+ now += 500_000_000L
+ assertEquals(50L, load.percent())
+ }
+
+ @Test fun capsAtFullAndIgnoresOtherFiles() {
+ val dir = tmp.newFolder("gpu-load")
+ val load = load(dir)
+ File(dir, "on").createNewFile()
+ write(dir, 1, 0L)
+ write(dir, 2, 0L)
+ now = 1L
+ load.percent()
+ write(dir, 1, 900_000_000L)
+ write(dir, 2, 900_000_000L)
+ now += 1_000_000_000L
+ assertEquals(100L, load.percent())
+ }
+
+ @Test fun theOnFlagFollowsTheHudAndHidingDropsTheBaseline() {
+ val dir = tmp.newFolder("gpu-load")
+ val load = load(dir)
+ load.wanted(true)
+ assertTrue(File(dir, "on").exists())
+ write(dir, 1, 100L)
+ now = 1L
+ load.percent()
+ load.wanted(false)
+ assertFalse(File(dir, "on").exists())
+ load.wanted(true)
+ now += 1_000L
+ assertNull(load.percent())
+ }
+
+ @Test fun theCompositorsOwnGpuTimeAddsToTheGames() {
+ val dir = tmp.newFolder("gpu-load")
+ var compositor: Long? = 7_000_000_000L
+ val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor })
+ write(dir, 100, 1_000_000_000L)
+ now = 1L
+ assertNull(load.percent())
+ write(dir, 100, 1_600_000_000L)
+ compositor = 7_150_000_000L
+ now += 1_000_000_000L
+ assertEquals(75L, load.percent())
+ compositor = null
+ write(dir, 100, 1_900_000_000L)
+ now += 1_000_000_000L
+ assertEquals(30L, load.percent())
+ }
+
+ @Test fun onlyTheCompositorCountsWhenNoGameReportsYet() {
+ val dir = File(tmp.root, "never-created")
+ var compositor = 0L
+ val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, extra = { compositor })
+ now = 1L
+ load.percent()
+ compositor = 200_000_000L
+ now += 1_000_000_000L
+ assertEquals(20L, load.percent())
+ }
+
+ @Test fun compositorTimingFollowsTheHudOnlyOnChanges() {
+ val dir = tmp.newFolder("gpu-load")
+ val calls = mutableListOf()
+ val load = GpuStatsComponent.LayerLoad(dir, clock = { now }, onWanted = { calls += it })
+ load.wanted(true)
+ load.wanted(true)
+ load.wanted(false)
+ load.wanted(true)
+ load.clear()
+ assertEquals(listOf(true, false, true, false), calls)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt
new file mode 100644
index 000000000..f0d7478d8
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/GpuMemstoreLoadTest.kt
@@ -0,0 +1,74 @@
+package com.droiddeck.launcher.session
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GpuMemstoreLoadTest {
+ private var busy = 0L
+ private var samples = 0L
+ private var moves = 0L
+ private var available = true
+ private val load = GpuStatsComponent.MemstoreLoad { if (available) longArrayOf(busy, samples, moves) else null }
+
+ private fun tick(newSamples: Long, newBusy: Long, newMoves: Long = 1) {
+ samples += newSamples
+ busy += newBusy
+ moves += newMoves
+ }
+
+ @Test fun notTrustedUntilNewWorkIsSeen() {
+ assertNull(load.percent())
+ tick(500, 400, newMoves = 0)
+ assertNull(load.percent())
+ assertFalse(load.working)
+ tick(500, 300)
+ assertEquals(70L, load.percent())
+ assertTrue(load.working)
+ }
+
+ @Test fun givesUpWhenTheTimestampsNeverMove() {
+ load.percent()
+ repeat(10) {
+ tick(500, 250, newMoves = 0)
+ assertNull(load.percent())
+ }
+ assertTrue(load.failed)
+ tick(500, 250)
+ assertNull(load.percent())
+ assertFalse(load.working)
+ }
+
+ @Test fun averagesOverTheLastTwoReads() {
+ load.percent()
+ tick(500, 500)
+ assertEquals(100L, load.percent())
+ tick(500, 0)
+ assertEquals(50L, load.percent())
+ tick(500, 0, newMoves = 0)
+ assertEquals(0L, load.percent())
+ }
+
+ @Test fun pausingStartsANewWindow() {
+ load.percent()
+ tick(500, 500)
+ load.percent()
+ load.pause()
+ tick(0, 0, newMoves = 0)
+ assertNull(load.percent())
+ tick(500, 100)
+ assertEquals(20L, load.percent())
+ }
+
+ @Test fun noReadingWithoutNewSamplesOrCounts() {
+ load.percent()
+ tick(500, 100)
+ load.percent()
+ assertNull(load.percent())
+ available = false
+ tick(500, 100)
+ assertNull(load.percent())
+ }
+}
diff --git a/tools/build_local.sh b/tools/build_local.sh
index 8caeda49e..222988773 100755
--- a/tools/build_local.sh
+++ b/tools/build_local.sh
@@ -181,6 +181,13 @@ if ! restore_step vr "${vr_key}" "${vr_out}"; then
save_step vr "${vr_key}" "${vr_out}"
fi
+gpuload_out="${linuxfs_dir}/usr/local/lib/droiddeck-gpuload"
+gpuload_key=$(inputs_hash "${image_id} ${ndk_version} ${VK_INCLUDE:-}" tools/gpuload)
+if ! restore_step gpuload "${gpuload_key}" "${gpuload_out}"; then
+ DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/gpuload/build.sh" "${gpuload_out}"
+ save_step gpuload "${gpuload_key}" "${gpuload_out}"
+fi
+
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
if [[ -z "${github_repo}" ]]; then
origin_url=$(git -C "${repo_root}" remote get-url origin)
diff --git a/tools/components/recipes.json b/tools/components/recipes.json
index 0dfb8e426..ab7d40b4a 100644
--- a/tools/components/recipes.json
+++ b/tools/components/recipes.json
@@ -14,6 +14,7 @@
"tools/msitools",
"tools/pipewire",
"tools/vr",
+ "tools/gpuload",
"app/src/main/cpp/waylandcomp/generated",
"app/src/main/cpp/waylandcomp/src/headpose_shm.h",
"app/src/main/cpp/waylandcomp/src/spatialpose_shm.h",
@@ -21,7 +22,8 @@
],
"outputs": [
"app/src/main/assets/linuxfs/usr/local/bin/gamescope",
- "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so"
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so",
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so"
],
"ndk": true,
"packages": [
diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh
index 92bcaad6c..aa5273d27 100644
--- a/tools/components/recipes/runtime.sh
+++ b/tools/components/recipes/runtime.sh
@@ -4,6 +4,8 @@ D=app/src/main/assets/linuxfs
mkdir -p "$D"
VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
+VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
+ tools/gpuload/build.sh --native "$D/usr/local/lib/droiddeck-gpuload"
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
diff --git a/tools/gpuload/build.sh b/tools/gpuload/build.sh
new file mode 100755
index 000000000..b2729de7f
--- /dev/null
+++ b/tools/gpuload/build.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Builds VK_LAYER_DROIDDECK_gpu_load for the arm64 guest: tools/gpuload/build.sh [--native] OUT
+# OUT gets libVkLayer_droiddeck_gpuload.so and its implicit-layer manifest.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(cd "$here/../.." && pwd)
+. "$repo/tools/docker_platform.sh"
+
+native=0
+if [ "${1:-}" = --native ]; then native=1; shift; fi
+out=${1:?usage: tools/gpuload/build.sh [--native] OUT}
+case "$out" in /*) ;; *) out="$PWD/$out" ;; esac
+
+vkinc=${VK_INCLUDE:-}
+if [ -z "$vkinc" ]; then
+ ndks=("${NDK:-}" "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_LATEST_HOME:-}")
+ sdk_ndks=("${ANDROID_HOME:-$HOME/Library/Android/sdk}"/ndk/*)
+ for ((i = ${#sdk_ndks[@]} - 1; i >= 0; i--)); do ndks+=("${sdk_ndks[i]}"); done
+ for ndk in "${ndks[@]}"; do
+ [ -n "$ndk" ] && [ -d "$ndk" ] || continue
+ for cand in "$ndk"/toolchains/llvm/prebuilt/*/sysroot/usr/include; do
+ if [ -f "$cand/vulkan/vk_layer.h" ]; then vkinc=$cand; break 2; fi
+ done
+ done
+fi
+[ -n "$vkinc" ] && [ -f "$vkinc/vulkan/vulkan_core.h" ] || { echo "tools/gpuload/build.sh: no Vulkan headers (set VK_INCLUDE)" >&2; exit 1; }
+
+work="$repo/build/gpuload"
+rm -rf "$work" && mkdir -p "$work/include" "$work/out"
+cp -R "$vkinc/vulkan" "$work/include/"
+if [ -d "$vkinc/vk_video" ]; then cp -R "$vkinc/vk_video" "$work/include/"; fi
+
+cmd=(sh -c '
+ set -e
+ cc -std=c11 -O2 -Wall -Wextra -Werror -Itools/gpuload/src -o build/gpuload/test_gpuload_busy tools/gpuload/tests/test_gpuload_busy.c
+ build/gpuload/test_gpuload_busy
+ cc -std=c11 -O2 -Wall -Wextra -Werror -Ibuild/gpuload/include -Itools/gpuload/src -o build/gpuload/test_gpuload_ring tools/gpuload/tests/test_gpuload_ring.c
+ build/gpuload/test_gpuload_ring
+ aarch64-linux-gnu-gcc -std=c11 -shared -fPIC -O2 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers \
+ -fvisibility=hidden -pthread -Wl,-z,defs -Ibuild/gpuload/include -Itools/gpuload/src \
+ -o build/gpuload/out/libVkLayer_droiddeck_gpuload.so tools/gpuload/src/vk_layer_droiddeck_gpuload.c
+ aarch64-linux-gnu-strip --strip-unneeded build/gpuload/out/libVkLayer_droiddeck_gpuload.so')
+if [ "$native" = 1 ]; then
+ (cd "$repo" && "${cmd[@]}")
+else
+ docker run --rm --platform "${DROIDDECK_DOCKER_PLATFORM:-linux/amd64}" --user "$(id -u):$(id -g)" -v "$repo:/src" -w /src \
+ "${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v3}" "${cmd[@]}"
+fi
+
+cp "$here/layer/VK_LAYER_DROIDDECK_gpu_load.json" "$work/out/"
+mkdir -p "$out"
+cp "$work/out/libVkLayer_droiddeck_gpuload.so" "$work/out/VK_LAYER_DROIDDECK_gpu_load.json" "$out/"
+echo "built $out"
diff --git a/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json
new file mode 100644
index 000000000..9fa67bc63
--- /dev/null
+++ b/tools/gpuload/layer/VK_LAYER_DROIDDECK_gpu_load.json
@@ -0,0 +1,20 @@
+{
+ "file_format_version": "1.2.0",
+ "layer": {
+ "name": "VK_LAYER_DROIDDECK_gpu_load",
+ "type": "GLOBAL",
+ "library_path": "/usr/local/lib/droiddeck-gpuload/libVkLayer_droiddeck_gpuload.so",
+ "api_version": "1.3.0",
+ "implementation_version": "1",
+ "description": "Times this process's GPU work for DroidDeck's HUD where Android hides the GPU load; idle unless a HUD is shown",
+ "functions": {
+ "vkNegotiateLoaderLayerInterfaceVersion": "DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion"
+ },
+ "enable_environment": {
+ "DROIDDECK_GPU_LOAD": "1"
+ },
+ "disable_environment": {
+ "DROIDDECK_GPU_LOAD_DISABLE": "1"
+ }
+ }
+}
diff --git a/tools/gpuload/src/gpuload_busy.h b/tools/gpuload/src/gpuload_busy.h
new file mode 100644
index 000000000..e5a47fb13
--- /dev/null
+++ b/tools/gpuload/src/gpuload_busy.h
@@ -0,0 +1,22 @@
+// Busy-time bookkeeping shared by VK_LAYER_DROIDDECK_gpu_load and DroidDeck's compositor, kept free of Vulkan so it
+// can be tested on its own.
+#ifndef DROIDDECK_GPULOAD_BUSY_H
+#define DROIDDECK_GPULOAD_BUSY_H
+
+#include
+
+// GPU ticks between the timestamp written before a batch and the one written after it. Batches on one queue run one
+// after the other, so these add up without overlap. Ticks are never compared across batches: on Adreno the always-on
+// counter behind the timestamps restarts when the GPU powers down between batches (seen on the Retroid Pocket Duo).
+// Anything backwards or longer than max_ticks is a bogus pair and counts as nothing.
+static inline uint64_t gpuload_interval(uint64_t start, uint64_t end, uint64_t max_ticks) {
+ return end > start && end - start <= max_ticks ? end - start : 0;
+}
+
+// A timestamp query slot is reused once its end is available; until the GPU runs the slot's reset again the
+// query still shows the previous end, so a result equal to the last one harvested is the old one.
+static inline int gpuload_result_is_new(uint64_t start_avail, uint64_t end_avail, uint64_t end, uint64_t last_end) {
+ return start_avail && end_avail && end != last_end;
+}
+
+#endif
diff --git a/tools/gpuload/src/gpuload_ring.h b/tools/gpuload/src/gpuload_ring.h
new file mode 100644
index 000000000..58f6abcb7
--- /dev/null
+++ b/tools/gpuload/src/gpuload_ring.h
@@ -0,0 +1,215 @@
+// Timestamp ring shared by VK_LAYER_DROIDDECK_gpu_load (guest) and DroidDeck's compositor (app): each submit batch
+// that has command buffers gets one of the ring's command buffers in front (resets a query pair, writes a TOP_OF_PIPE
+// timestamp) and one behind (BOTTOM_OF_PIPE timestamp). Finished pairs are read back without waiting.
+// The start timestamp sits after the batch's semaphore waits only because Turnip and Qualcomm's driver on kgsl hold a
+// whole batch until its waits signal; a driver that starts TOP_OF_PIPE work earlier would count the wait as busy.
+// Callers serialize access to one ring (a queue's submits are externally synchronized anyway).
+#ifndef DROIDDECK_GPULOAD_RING_H
+#define DROIDDECK_GPULOAD_RING_H
+
+#include
+#include
+#include
+#include
+
+#include "gpuload_busy.h"
+
+#define GPULOAD_RING 64
+
+typedef VkResult(VKAPI_PTR *gpuload_set_data_fn)(VkDevice device, void *object);
+
+struct gpuload_vk {
+ VkDevice device;
+ PFN_vkCreateCommandPool create_pool;
+ PFN_vkDestroyCommandPool destroy_pool;
+ PFN_vkAllocateCommandBuffers alloc_cmds;
+ PFN_vkBeginCommandBuffer begin;
+ PFN_vkEndCommandBuffer end;
+ PFN_vkCmdResetQueryPool cmd_reset;
+ PFN_vkCmdWriteTimestamp cmd_timestamp;
+ PFN_vkCreateQueryPool create_queries;
+ PFN_vkDestroyQueryPool destroy_queries;
+ PFN_vkGetQueryPoolResults results;
+ gpuload_set_data_fn set_data; // a layer must give the loader every command buffer it allocates; NULL elsewhere
+ double period_ns;
+ uint64_t max_ticks;
+};
+
+struct gpuload_slot {
+ VkCommandBuffer start, end;
+ uint64_t last_end;
+};
+
+struct gpuload_ring {
+ int state; // 0 not set up, 1 timing, -1 cannot time
+ VkCommandPool pool;
+ VkQueryPool queries;
+ uint64_t mask;
+ uint32_t next, oldest, inflight;
+ struct gpuload_slot slot[GPULOAD_RING];
+};
+
+#define GPULOAD_LONGEST_BATCH_NS 2000000000.0
+
+static inline void gpuload_vk_period(struct gpuload_vk *v, float timestamp_period) {
+ v->period_ns = timestamp_period;
+ v->max_ticks = timestamp_period > 0 ? (uint64_t)(GPULOAD_LONGEST_BATCH_NS / timestamp_period) : 0;
+}
+
+static inline int gpuload_vk_complete(const struct gpuload_vk *v) {
+ return v->device && v->create_pool && v->destroy_pool && v->alloc_cmds && v->begin && v->end && v->cmd_reset &&
+ v->cmd_timestamp && v->create_queries && v->destroy_queries && v->results && v->period_ns > 0;
+}
+
+static inline void gpuload_ring_release(struct gpuload_ring *r, const struct gpuload_vk *v) {
+ if (r->queries) v->destroy_queries(v->device, r->queries, NULL);
+ if (r->pool) v->destroy_pool(v->device, r->pool, NULL);
+ r->queries = VK_NULL_HANDLE;
+ r->pool = VK_NULL_HANDLE;
+ r->next = r->oldest = r->inflight = 0;
+}
+
+static inline int gpuload_ring_record(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t i) {
+ const VkCommandBufferBeginInfo begin = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
+ struct gpuload_slot *s = &r->slot[i];
+ if (v->begin(s->start, &begin) != VK_SUCCESS) return 0;
+ v->cmd_reset(s->start, r->queries, 2 * i, 2);
+ v->cmd_timestamp(s->start, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, r->queries, 2 * i);
+ if (v->end(s->start) != VK_SUCCESS) return 0;
+ if (v->begin(s->end, &begin) != VK_SUCCESS) return 0;
+ v->cmd_timestamp(s->end, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, r->queries, 2 * i + 1);
+ return v->end(s->end) == VK_SUCCESS;
+}
+
+// Sets the ring up for a queue of this family; returns 1 when it can time it (r->state says the same afterwards).
+static inline int gpuload_ring_setup(struct gpuload_ring *r, const struct gpuload_vk *v, uint32_t family,
+ uint32_t timestamp_bits) {
+ r->state = -1;
+ if (!timestamp_bits || !gpuload_vk_complete(v)) return 0;
+ r->mask = timestamp_bits >= 64 ? ~0ull : (1ull << timestamp_bits) - 1;
+ const VkCommandPoolCreateInfo pool = {.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, .queueFamilyIndex = family};
+ const VkQueryPoolCreateInfo queries = {.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO,
+ .queryType = VK_QUERY_TYPE_TIMESTAMP, .queryCount = 2 * GPULOAD_RING};
+ VkCommandBuffer cmds[2 * GPULOAD_RING];
+ if (v->create_pool(v->device, &pool, NULL, &r->pool) != VK_SUCCESS) { r->pool = VK_NULL_HANDLE; goto fail; }
+ if (v->create_queries(v->device, &queries, NULL, &r->queries) != VK_SUCCESS) { r->queries = VK_NULL_HANDLE; goto fail; }
+ const VkCommandBufferAllocateInfo alloc = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = r->pool,
+ .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 2 * GPULOAD_RING};
+ if (v->alloc_cmds(v->device, &alloc, cmds) != VK_SUCCESS) goto fail;
+ for (uint32_t i = 0; i < 2 * GPULOAD_RING; i++)
+ if (v->set_data && v->set_data(v->device, cmds[i]) != VK_SUCCESS) goto fail;
+ for (uint32_t i = 0; i < GPULOAD_RING; i++) {
+ r->slot[i].start = cmds[2 * i];
+ r->slot[i].end = cmds[2 * i + 1];
+ r->slot[i].last_end = 0;
+ if (!gpuload_ring_record(r, v, i)) goto fail;
+ }
+ r->next = r->oldest = r->inflight = 0;
+ r->state = 1;
+ return 1;
+fail:
+ gpuload_ring_release(r, v);
+ r->state = -1;
+ return 0;
+}
+
+// Nanoseconds of GPU time in the batches that finished since the last call. Slots are taken in ring order and a
+// queue runs its batches in order, so the oldest slot finishes first.
+static inline uint64_t gpuload_ring_harvest(struct gpuload_ring *r, const struct gpuload_vk *v) {
+ uint64_t ticks = 0;
+ while (r->inflight) {
+ struct gpuload_slot *s = &r->slot[r->oldest];
+ uint64_t q[4] = {0, 0, 0, 0};
+ VkResult res = v->results(v->device, r->queries, 2 * r->oldest, 2, sizeof(q), q, 2 * sizeof(uint64_t),
+ VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WITH_AVAILABILITY_BIT);
+ if (res != VK_SUCCESS && res != VK_NOT_READY) break;
+ uint64_t start = q[0] & r->mask, end = q[2] & r->mask;
+ if (!gpuload_result_is_new(q[1], q[3], end, s->last_end)) break;
+ s->last_end = end;
+ r->oldest = (r->oldest + 1) % GPULOAD_RING;
+ r->inflight--;
+ ticks += gpuload_interval(start, end, v->max_ticks);
+ }
+ return (uint64_t)((double)ticks * v->period_ns);
+}
+
+static inline int gpuload_plain_batch(const void *next) {
+ for (const VkBaseInStructure *s = (const VkBaseInStructure *)next; s; s = s->pNext) {
+ if (s->sType == VK_STRUCTURE_TYPE_DEVICE_GROUP_SUBMIT_INFO) return 0;
+ if (s->sType == VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO && ((const VkProtectedSubmitInfo *)s)->protectedSubmit)
+ return 0;
+ }
+ return 1;
+}
+
+static inline struct gpuload_slot *gpuload_ring_take(struct gpuload_ring *r) {
+ if (r->inflight >= GPULOAD_RING) return NULL;
+ struct gpuload_slot *s = &r->slot[r->next];
+ r->next = (r->next + 1) % GPULOAD_RING;
+ r->inflight++;
+ return s;
+}
+
+// Gives back the slots of a submit that failed.
+static inline void gpuload_ring_unwind(struct gpuload_ring *r, uint32_t used) {
+ r->next = (r->next + GPULOAD_RING - used) % GPULOAD_RING;
+ r->inflight -= used;
+}
+
+// A copy of the batches with the ring's command buffers around each one that has command buffers (free() it after
+// the submit), or NULL to submit the originals. *used counts the slots taken, for gpuload_ring_unwind.
+static inline VkSubmitInfo *gpuload_ring_wrap(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo *infos,
+ uint32_t *used) {
+ *used = 0;
+ size_t cmds = 0;
+ for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferCount + 2;
+ VkSubmitInfo *mine = (VkSubmitInfo *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBuffer));
+ if (!mine) return NULL;
+ VkCommandBuffer *list = (VkCommandBuffer *)(mine + count);
+ for (uint32_t i = 0; i < count; i++) {
+ uint32_t n = infos[i].commandBufferCount;
+ mine[i] = infos[i];
+ if (!n || !gpuload_plain_batch(infos[i].pNext)) continue;
+ struct gpuload_slot *s = gpuload_ring_take(r);
+ if (!s) continue;
+ list[0] = s->start;
+ memcpy(list + 1, infos[i].pCommandBuffers, n * sizeof(VkCommandBuffer));
+ list[n + 1] = s->end;
+ mine[i].pCommandBuffers = list;
+ mine[i].commandBufferCount = n + 2;
+ list += n + 2;
+ (*used)++;
+ }
+ return mine;
+}
+
+static inline VkSubmitInfo2 *gpuload_ring_wrap2(struct gpuload_ring *r, uint32_t count, const VkSubmitInfo2 *infos,
+ uint32_t *used) {
+ *used = 0;
+ size_t cmds = 0;
+ for (uint32_t i = 0; i < count; i++) cmds += infos[i].commandBufferInfoCount + 2;
+ VkSubmitInfo2 *mine = (VkSubmitInfo2 *)malloc(count * sizeof(*mine) + cmds * sizeof(VkCommandBufferSubmitInfo));
+ if (!mine) return NULL;
+ VkCommandBufferSubmitInfo *list = (VkCommandBufferSubmitInfo *)(mine + count);
+ for (uint32_t i = 0; i < count; i++) {
+ uint32_t n = infos[i].commandBufferInfoCount;
+ mine[i] = infos[i];
+ if (!n || (infos[i].flags & VK_SUBMIT_PROTECTED_BIT) || !gpuload_plain_batch(infos[i].pNext)) continue;
+ struct gpuload_slot *s = gpuload_ring_take(r);
+ if (!s) continue;
+ memset(&list[0], 0, sizeof(list[0]));
+ list[0].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO;
+ list[0].commandBuffer = s->start;
+ memcpy(list + 1, infos[i].pCommandBufferInfos, n * sizeof(VkCommandBufferSubmitInfo));
+ memset(&list[n + 1], 0, sizeof(list[n + 1]));
+ list[n + 1].sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO;
+ list[n + 1].commandBuffer = s->end;
+ mine[i].pCommandBufferInfos = list;
+ mine[i].commandBufferInfoCount = n + 2;
+ list += n + 2;
+ (*used)++;
+ }
+ return mine;
+}
+
+#endif
diff --git a/tools/gpuload/src/vk_layer_droiddeck_gpuload.c b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c
new file mode 100644
index 000000000..ef58bef6d
--- /dev/null
+++ b/tools/gpuload/src/vk_layer_droiddeck_gpuload.c
@@ -0,0 +1,410 @@
+// VK_LAYER_DROIDDECK_gpu_load: this process's GPU busy time for the HUD where Android keeps the GPU load from apps
+// (kgsl sysfs SELinux-blocked, perfcounter reads refused while /sys/class/kgsl/kgsl-3d0/perfcounter is 0).
+// The process total in nanoseconds goes to $DROIDDECK_GPU_LOAD_DIR/; nothing is timed while
+// $DROIDDECK_GPU_LOAD_DIR/on is missing (the app removes it while no HUD is shown).
+#define _GNU_SOURCE
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "gpuload_ring.h"
+
+#define EXPORT __attribute__((visibility("default")))
+#define MAX_FAMILIES 32
+#define WANTED_CHECK_NS 500000000ull
+#define WRITE_EVERY_NS 100000000ull
+
+static const char *g_dir;
+
+static void say(const char *what) {
+ // FEX leaves program_invocation_short_name empty for x86 programs.
+ fprintf(stderr, "[droiddeck-gpu-load] %s (pid %d): %s\n", program_invocation_short_name, (int)getpid(), what);
+}
+
+__attribute__((constructor)) static void init(void) {
+ const char *dir = getenv("DROIDDECK_GPU_LOAD_DIR");
+ g_dir = dir && *dir ? dir : NULL;
+}
+
+static uint64_t now_ns(void) {
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+}
+
+struct inst {
+ struct inst *next_in_list;
+ VkInstance handle;
+ PFN_vkGetInstanceProcAddr gipa;
+ PFN_vkDestroyInstance destroy;
+ PFN_vkGetPhysicalDeviceProperties props;
+ PFN_vkGetPhysicalDeviceQueueFamilyProperties families;
+};
+
+struct dev {
+ struct dev *next_in_list;
+ VkDevice handle;
+ PFN_vkGetDeviceProcAddr gdpa;
+ PFN_vkDestroyDevice destroy;
+ PFN_vkGetDeviceQueue get_queue;
+ PFN_vkGetDeviceQueue2 get_queue2;
+ PFN_vkQueueSubmit submit;
+ PFN_vkQueueSubmit2 submit2;
+ PFN_vkQueueSubmit2 submit2_khr;
+ struct gpuload_vk vk;
+ uint32_t family_count;
+ uint32_t timestamp_bits[MAX_FAMILIES];
+};
+
+struct queue {
+ struct queue *next_in_list;
+ VkQueue handle;
+ struct dev *dev;
+ uint32_t family;
+ uint32_t epoch;
+ struct gpuload_ring ring;
+};
+
+static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
+static struct inst *g_insts;
+static struct dev *g_devs;
+static struct queue *g_queues;
+
+static void *key(const void *obj) { return *(void *const *)obj; }
+
+static struct inst *inst_for(const void *obj) {
+ struct inst *r;
+ pthread_mutex_lock(&g_lock);
+ for (r = g_insts; r && key(r->handle) != key(obj); r = r->next_in_list) {}
+ pthread_mutex_unlock(&g_lock);
+ return r;
+}
+
+static struct dev *dev_for(const void *obj) {
+ struct dev *r;
+ pthread_mutex_lock(&g_lock);
+ for (r = g_devs; r && key(r->handle) != key(obj); r = r->next_in_list) {}
+ pthread_mutex_unlock(&g_lock);
+ return r;
+}
+
+static struct queue *queue_for(VkQueue handle) {
+ struct queue *r;
+ pthread_mutex_lock(&g_lock);
+ for (r = g_queues; r && r->handle != handle; r = r->next_in_list) {}
+ pthread_mutex_unlock(&g_lock);
+ return r;
+}
+
+static void note_queue(struct dev *d, VkQueue handle, uint32_t family) {
+ if (!handle || queue_for(handle)) return;
+ struct queue *q = calloc(1, sizeof(*q));
+ if (!q) return;
+ q->handle = handle;
+ q->dev = d;
+ q->family = family;
+ pthread_mutex_lock(&g_lock);
+ q->next_in_list = g_queues;
+ g_queues = q;
+ pthread_mutex_unlock(&g_lock);
+}
+
+// ---- process total ---------------------------------------------------------------------------------------------
+
+static _Atomic uint64_t g_busy_ns;
+static _Atomic uint64_t g_wanted_at;
+static _Atomic int g_wanted;
+static _Atomic uint32_t g_epoch = 1; // bumped each time the HUD comes back
+static pthread_mutex_t g_write_lock = PTHREAD_MUTEX_INITIALIZER;
+static uint64_t g_written_at;
+static int g_fd = -1;
+static pid_t g_fd_pid;
+static int g_announced;
+
+static int wanted(uint64_t now) {
+ uint64_t at = atomic_load(&g_wanted_at);
+ if (at && now - at < WANTED_CHECK_NS) return atomic_load(&g_wanted);
+ atomic_store(&g_wanted_at, now);
+ char path[512];
+ snprintf(path, sizeof(path), "%s/on", g_dir);
+ int on = access(path, F_OK) == 0;
+ if (on && !atomic_exchange(&g_wanted, on)) atomic_fetch_add(&g_epoch, 1);
+ if (!on) atomic_store(&g_wanted, 0);
+ return on;
+}
+
+static void publish(uint64_t now) {
+ if (pthread_mutex_trylock(&g_write_lock)) return;
+ if (now - g_written_at >= WRITE_EVERY_NS) {
+ g_written_at = now;
+ pid_t pid = getpid();
+ struct stat st;
+ // The app deletes files that stopped changing; a forked child needs its own.
+ if (g_fd >= 0 && (g_fd_pid != pid || fstat(g_fd, &st) || st.st_nlink == 0)) { close(g_fd); g_fd = -1; }
+ if (g_fd < 0) {
+ char path[512];
+ snprintf(path, sizeof(path), "%s/%d", g_dir, (int)pid);
+ g_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
+ g_fd_pid = pid;
+ }
+ if (g_fd >= 0) {
+ char line[80];
+ int n = snprintf(line, sizeof(line), "%20llu %-32.32s\n", (unsigned long long)atomic_load(&g_busy_ns),
+ program_invocation_short_name);
+ if (pwrite(g_fd, line, (size_t)n, 0) != n) { close(g_fd); g_fd = -1; }
+ }
+ }
+ pthread_mutex_unlock(&g_write_lock);
+}
+
+// The queue to time this submit on, or NULL to pass it through.
+static struct queue *measuring(VkQueue handle) {
+ if (!g_dir) return NULL;
+ struct queue *q = queue_for(handle);
+ if (!q) return NULL;
+ uint64_t now = now_ns();
+ if (!wanted(now)) return NULL;
+ struct dev *d = q->dev;
+ if (q->ring.state == 0) {
+ uint32_t bits = q->family < d->family_count ? d->timestamp_bits[q->family] : 0;
+ if (gpuload_ring_setup(&q->ring, &d->vk, q->family, bits)) {
+ if (!__atomic_exchange_n(&g_announced, 1, __ATOMIC_RELAXED)) say("timing this process's GPU work for the HUD");
+ } else {
+ say(bits ? "cannot set up GPU timing on this queue" : "this queue family has no timestamps");
+ }
+ q->epoch = atomic_load(&g_epoch);
+ }
+ if (q->ring.state != 1) return NULL;
+ uint64_t ns = gpuload_ring_harvest(&q->ring, &d->vk);
+ uint32_t epoch = atomic_load(&g_epoch);
+ if (q->epoch == epoch) atomic_fetch_add(&g_busy_ns, ns);
+ else q->epoch = epoch; // timed before the HUD went away
+ publish(now);
+ return q;
+}
+
+static VkResult VKAPI_CALL QueueSubmit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) {
+ struct dev *d = dev_for(queue);
+ if (!d) return VK_ERROR_DEVICE_LOST;
+ struct queue *q = count ? measuring(queue) : NULL;
+ uint32_t used = 0;
+ VkSubmitInfo *mine = q ? gpuload_ring_wrap(&q->ring, count, infos, &used) : NULL;
+ VkResult r = d->submit(queue, count, mine ? mine : infos, fence);
+ if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used);
+ free(mine);
+ return r;
+}
+
+static VkResult submit2(PFN_vkQueueSubmit2 next, VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) {
+ struct queue *q = count ? measuring(queue) : NULL;
+ uint32_t used = 0;
+ VkSubmitInfo2 *mine = q ? gpuload_ring_wrap2(&q->ring, count, infos, &used) : NULL;
+ VkResult r = next(queue, count, mine ? mine : infos, fence);
+ if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&q->ring, used);
+ free(mine);
+ return r;
+}
+
+static VkResult VKAPI_CALL QueueSubmit2(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) {
+ struct dev *d = dev_for(queue);
+ return d ? submit2(d->submit2, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST;
+}
+
+static VkResult VKAPI_CALL QueueSubmit2KHR(VkQueue queue, uint32_t count, const VkSubmitInfo2 *infos, VkFence fence) {
+ struct dev *d = dev_for(queue);
+ return d ? submit2(d->submit2_khr, queue, count, infos, fence) : VK_ERROR_DEVICE_LOST;
+}
+
+static void VKAPI_CALL GetDeviceQueue(VkDevice device, uint32_t family, uint32_t index, VkQueue *out) {
+ struct dev *d = dev_for(device);
+ if (!d) return;
+ d->get_queue(device, family, index, out);
+ if (out && *out) note_queue(d, *out, family);
+}
+
+static void VKAPI_CALL GetDeviceQueue2(VkDevice device, const VkDeviceQueueInfo2 *info, VkQueue *out) {
+ struct dev *d = dev_for(device);
+ if (!d) return;
+ d->get_queue2(device, info, out);
+ if (out && *out) note_queue(d, *out, info->queueFamilyIndex);
+}
+
+// ---- instance and device ---------------------------------------------------------------------------------------
+
+static VkResult VKAPI_CALL CreateInstance(const VkInstanceCreateInfo *ci, const VkAllocationCallbacks *alloc, VkInstance *out) {
+ VkLayerInstanceCreateInfo *link = (VkLayerInstanceCreateInfo *)ci->pNext;
+ while (link && !(link->sType == VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO && link->function == VK_LAYER_LINK_INFO))
+ link = (VkLayerInstanceCreateInfo *)link->pNext;
+ if (!link) return VK_ERROR_INITIALIZATION_FAILED;
+ PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr;
+ link->u.pLayerInfo = link->u.pLayerInfo->pNext;
+ PFN_vkCreateInstance create = (PFN_vkCreateInstance)next_gipa(VK_NULL_HANDLE, "vkCreateInstance");
+ if (!create) return VK_ERROR_INITIALIZATION_FAILED;
+ struct inst *in = calloc(1, sizeof(*in));
+ if (!in) return VK_ERROR_OUT_OF_HOST_MEMORY;
+ VkResult r = create(ci, alloc, out);
+ if (r != VK_SUCCESS) { free(in); return r; }
+ in->handle = *out;
+ in->gipa = next_gipa;
+ in->destroy = (PFN_vkDestroyInstance)next_gipa(*out, "vkDestroyInstance");
+ in->props = (PFN_vkGetPhysicalDeviceProperties)next_gipa(*out, "vkGetPhysicalDeviceProperties");
+ in->families = (PFN_vkGetPhysicalDeviceQueueFamilyProperties)next_gipa(*out, "vkGetPhysicalDeviceQueueFamilyProperties");
+ pthread_mutex_lock(&g_lock);
+ in->next_in_list = g_insts;
+ g_insts = in;
+ pthread_mutex_unlock(&g_lock);
+ return VK_SUCCESS;
+}
+
+static void VKAPI_CALL DestroyInstance(VkInstance instance, const VkAllocationCallbacks *alloc) {
+ struct inst *in = inst_for(instance);
+ if (!in) return;
+ PFN_vkDestroyInstance destroy = in->destroy;
+ pthread_mutex_lock(&g_lock);
+ for (struct inst **p = &g_insts; *p; p = &(*p)->next_in_list)
+ if (*p == in) { *p = in->next_in_list; break; }
+ pthread_mutex_unlock(&g_lock);
+ free(in);
+ if (destroy) destroy(instance, alloc);
+}
+
+static VkLayerDeviceCreateInfo *device_chain(const VkDeviceCreateInfo *ci, VkLayerFunction function) {
+ VkLayerDeviceCreateInfo *c = (VkLayerDeviceCreateInfo *)ci->pNext;
+ while (c && !(c->sType == VK_STRUCTURE_TYPE_LOADER_DEVICE_CREATE_INFO && c->function == function))
+ c = (VkLayerDeviceCreateInfo *)c->pNext;
+ return c;
+}
+
+static VkResult VKAPI_CALL CreateDevice(VkPhysicalDevice pd, const VkDeviceCreateInfo *ci, const VkAllocationCallbacks *alloc,
+ VkDevice *out) {
+ VkLayerDeviceCreateInfo *link = device_chain(ci, VK_LAYER_LINK_INFO);
+ if (!link) return VK_ERROR_INITIALIZATION_FAILED;
+ VkLayerDeviceCreateInfo *callback = device_chain(ci, VK_LOADER_DATA_CALLBACK);
+ PFN_vkGetInstanceProcAddr next_gipa = link->u.pLayerInfo->pfnNextGetInstanceProcAddr;
+ PFN_vkGetDeviceProcAddr next_gdpa = link->u.pLayerInfo->pfnNextGetDeviceProcAddr;
+ link->u.pLayerInfo = link->u.pLayerInfo->pNext;
+ struct inst *in = inst_for(pd);
+ PFN_vkCreateDevice create = (PFN_vkCreateDevice)next_gipa(in ? in->handle : VK_NULL_HANDLE, "vkCreateDevice");
+ if (!create) return VK_ERROR_INITIALIZATION_FAILED;
+ struct dev *d = calloc(1, sizeof(*d));
+ if (!d) return VK_ERROR_OUT_OF_HOST_MEMORY;
+ VkResult r = create(pd, ci, alloc, out);
+ if (r != VK_SUCCESS) { free(d); return r; }
+ d->handle = *out;
+ d->gdpa = next_gdpa;
+#define LOAD(field, type, name) d->field = (type)next_gdpa(*out, name)
+ LOAD(destroy, PFN_vkDestroyDevice, "vkDestroyDevice");
+ LOAD(get_queue, PFN_vkGetDeviceQueue, "vkGetDeviceQueue");
+ LOAD(get_queue2, PFN_vkGetDeviceQueue2, "vkGetDeviceQueue2");
+ LOAD(submit, PFN_vkQueueSubmit, "vkQueueSubmit");
+ LOAD(submit2, PFN_vkQueueSubmit2, "vkQueueSubmit2");
+ LOAD(submit2_khr, PFN_vkQueueSubmit2, "vkQueueSubmit2KHR");
+ LOAD(vk.create_pool, PFN_vkCreateCommandPool, "vkCreateCommandPool");
+ LOAD(vk.destroy_pool, PFN_vkDestroyCommandPool, "vkDestroyCommandPool");
+ LOAD(vk.alloc_cmds, PFN_vkAllocateCommandBuffers, "vkAllocateCommandBuffers");
+ LOAD(vk.begin, PFN_vkBeginCommandBuffer, "vkBeginCommandBuffer");
+ LOAD(vk.end, PFN_vkEndCommandBuffer, "vkEndCommandBuffer");
+ LOAD(vk.cmd_reset, PFN_vkCmdResetQueryPool, "vkCmdResetQueryPool");
+ LOAD(vk.cmd_timestamp, PFN_vkCmdWriteTimestamp, "vkCmdWriteTimestamp");
+ LOAD(vk.create_queries, PFN_vkCreateQueryPool, "vkCreateQueryPool");
+ LOAD(vk.destroy_queries, PFN_vkDestroyQueryPool, "vkDestroyQueryPool");
+ LOAD(vk.results, PFN_vkGetQueryPoolResults, "vkGetQueryPoolResults");
+#undef LOAD
+ d->vk.device = *out;
+ d->vk.set_data = callback ? (gpuload_set_data_fn)callback->u.pfnSetDeviceLoaderData : NULL;
+ if (in && in->props && in->families) {
+ VkPhysicalDeviceProperties props;
+ in->props(pd, &props);
+ gpuload_vk_period(&d->vk, props.limits.timestampPeriod);
+ uint32_t n = 0;
+ in->families(pd, &n, NULL);
+ VkQueueFamilyProperties families[MAX_FAMILIES];
+ if (n > MAX_FAMILIES) n = MAX_FAMILIES;
+ in->families(pd, &n, families);
+ d->family_count = n;
+ for (uint32_t i = 0; i < n; i++) d->timestamp_bits[i] = families[i].timestampValidBits;
+ }
+ // Without the loader callback our command buffers would lack the loader's dispatch pointer.
+ if (!d->vk.set_data) d->family_count = 0;
+ pthread_mutex_lock(&g_lock);
+ d->next_in_list = g_devs;
+ g_devs = d;
+ pthread_mutex_unlock(&g_lock);
+ return VK_SUCCESS;
+}
+
+static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallbacks *alloc) {
+ struct dev *d = dev_for(device);
+ if (!d) return;
+ struct queue *mine = NULL;
+ pthread_mutex_lock(&g_lock);
+ for (struct queue **p = &g_queues; *p;) {
+ struct queue *q = *p;
+ if (q->dev == d) { *p = q->next_in_list; q->next_in_list = mine; mine = q; } else p = &q->next_in_list;
+ }
+ for (struct dev **p = &g_devs; *p; p = &(*p)->next_in_list)
+ if (*p == d) { *p = d->next_in_list; break; }
+ pthread_mutex_unlock(&g_lock);
+ while (mine) {
+ struct queue *q = mine;
+ mine = q->next_in_list;
+ gpuload_ring_release(&q->ring, &d->vk);
+ free(q);
+ }
+ PFN_vkDestroyDevice destroy = d->destroy;
+ free(d);
+ if (destroy) destroy(device, alloc);
+}
+
+// ---- dispatch --------------------------------------------------------------------------------------------------
+
+EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name);
+
+static PFN_vkVoidFunction device_hook(struct dev *d, const char *name) {
+ if (!g_dir) return NULL;
+ if (!strcmp(name, "vkGetDeviceQueue") && d->get_queue) return (PFN_vkVoidFunction)GetDeviceQueue;
+ if (!strcmp(name, "vkGetDeviceQueue2") && d->get_queue2) return (PFN_vkVoidFunction)GetDeviceQueue2;
+ if (!strcmp(name, "vkQueueSubmit") && d->submit) return (PFN_vkVoidFunction)QueueSubmit;
+ if (!strcmp(name, "vkQueueSubmit2") && d->submit2) return (PFN_vkVoidFunction)QueueSubmit2;
+ if (!strcmp(name, "vkQueueSubmit2KHR") && d->submit2_khr) return (PFN_vkVoidFunction)QueueSubmit2KHR;
+ return NULL;
+}
+
+EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetInstanceProcAddr(VkInstance instance, const char *name) {
+ if (!strcmp(name, "vkCreateInstance")) return (PFN_vkVoidFunction)CreateInstance;
+ if (!strcmp(name, "vkDestroyInstance")) return (PFN_vkVoidFunction)DestroyInstance;
+ if (!strcmp(name, "vkCreateDevice")) return (PFN_vkVoidFunction)CreateDevice;
+ if (!strcmp(name, "vkGetInstanceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetInstanceProcAddr;
+ if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr;
+ if (!instance) return NULL;
+ struct inst *in = inst_for(instance);
+ return in ? in->gipa(instance, name) : NULL;
+}
+
+EXPORT VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL DroidDeckGpuLoad_GetDeviceProcAddr(VkDevice device, const char *name) {
+ if (!strcmp(name, "vkGetDeviceProcAddr")) return (PFN_vkVoidFunction)DroidDeckGpuLoad_GetDeviceProcAddr;
+ if (!strcmp(name, "vkDestroyDevice")) return (PFN_vkVoidFunction)DestroyDevice;
+ struct dev *d = dev_for(device);
+ if (!d) return NULL;
+ PFN_vkVoidFunction mine = device_hook(d, name);
+ return mine ? mine : d->gdpa(device, name);
+}
+
+EXPORT VKAPI_ATTR VkResult VKAPI_CALL DroidDeckGpuLoad_NegotiateLoaderLayerInterfaceVersion(VkNegotiateLayerInterface *v) {
+ if (v->loaderLayerInterfaceVersion > 2) v->loaderLayerInterfaceVersion = 2;
+ v->pfnGetInstanceProcAddr = DroidDeckGpuLoad_GetInstanceProcAddr;
+ v->pfnGetDeviceProcAddr = DroidDeckGpuLoad_GetDeviceProcAddr;
+ v->pfnGetPhysicalDeviceProcAddr = NULL;
+ return VK_SUCCESS;
+}
diff --git a/tools/gpuload/tests/test_gpuload_busy.c b/tools/gpuload/tests/test_gpuload_busy.c
new file mode 100644
index 000000000..706580e90
--- /dev/null
+++ b/tools/gpuload/tests/test_gpuload_busy.c
@@ -0,0 +1,24 @@
+#include
+#include
+
+#include "gpuload_busy.h"
+
+int main(void) {
+ const uint64_t max = 1000;
+ assert(gpuload_interval(10, 20, max) == 10);
+ // Each batch counts on its own, even when the counter restarted in between (later batch has smaller ticks).
+ assert(gpuload_interval(900000, 900400, max) == 400);
+ assert(gpuload_interval(50, 350, max) == 300);
+ // Backwards, empty or too long: nothing.
+ assert(gpuload_interval(60, 50, max) == 0);
+ assert(gpuload_interval(70, 70, max) == 0);
+ assert(gpuload_interval(100, 100 + max + 1, max) == 0);
+ assert(gpuload_interval(100, 100 + max, max) == max);
+ // Query results: both halves available and an end that differs from the last harvested one.
+ assert(gpuload_result_is_new(1, 1, 500, 400));
+ assert(!gpuload_result_is_new(1, 1, 400, 400));
+ assert(!gpuload_result_is_new(1, 0, 500, 400));
+ assert(!gpuload_result_is_new(0, 1, 500, 400));
+ puts("test_gpuload_busy: ok");
+ return 0;
+}
diff --git a/tools/gpuload/tests/test_gpuload_ring.c b/tools/gpuload/tests/test_gpuload_ring.c
new file mode 100644
index 000000000..4c5bc1395
--- /dev/null
+++ b/tools/gpuload/tests/test_gpuload_ring.c
@@ -0,0 +1,96 @@
+#include
+#include
+#include
+
+#include "gpuload_ring.h"
+
+// A fake device: query results come from this table, [start, start available, end, end available] per slot.
+static uint64_t g_result[GPULOAD_RING][4];
+static uintptr_t g_next_handle = 0x1000;
+
+static VkResult VKAPI_CALL create_pool(VkDevice d, const VkCommandPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkCommandPool *out) {
+ (void)d; (void)ci; (void)a; *out = (VkCommandPool)(g_next_handle++); return VK_SUCCESS;
+}
+static void VKAPI_CALL destroy_pool(VkDevice d, VkCommandPool p, const VkAllocationCallbacks *a) { (void)d; (void)p; (void)a; }
+static VkResult VKAPI_CALL alloc_cmds(VkDevice d, const VkCommandBufferAllocateInfo *ai, VkCommandBuffer *out) {
+ (void)d;
+ for (uint32_t i = 0; i < ai->commandBufferCount; i++) out[i] = (VkCommandBuffer)(g_next_handle++);
+ return VK_SUCCESS;
+}
+static VkResult VKAPI_CALL begin(VkCommandBuffer c, const VkCommandBufferBeginInfo *bi) { (void)c; (void)bi; return VK_SUCCESS; }
+static VkResult VKAPI_CALL end(VkCommandBuffer c) { (void)c; return VK_SUCCESS; }
+static void VKAPI_CALL cmd_reset(VkCommandBuffer c, VkQueryPool q, uint32_t first, uint32_t n) { (void)c; (void)q; (void)first; (void)n; }
+static void VKAPI_CALL cmd_timestamp(VkCommandBuffer c, VkPipelineStageFlagBits s, VkQueryPool q, uint32_t i) { (void)c; (void)s; (void)q; (void)i; }
+static VkResult VKAPI_CALL create_queries(VkDevice d, const VkQueryPoolCreateInfo *ci, const VkAllocationCallbacks *a, VkQueryPool *out) {
+ (void)d; (void)a; assert(ci->queryCount == 2 * GPULOAD_RING); *out = (VkQueryPool)(g_next_handle++); return VK_SUCCESS;
+}
+static void VKAPI_CALL destroy_queries(VkDevice d, VkQueryPool q, const VkAllocationCallbacks *a) { (void)d; (void)q; (void)a; }
+static VkResult VKAPI_CALL results(VkDevice d, VkQueryPool q, uint32_t first, uint32_t n, size_t size, void *data,
+ VkDeviceSize stride, VkQueryResultFlags flags) {
+ (void)d; (void)q; (void)size;
+ assert(n == 2 && stride == 2 * sizeof(uint64_t) && (flags & VK_QUERY_RESULT_WITH_AVAILABILITY_BIT));
+ uint64_t *out = data;
+ for (int k = 0; k < 4; k++) out[k] = g_result[first / 2][k];
+ return out[1] && out[3] ? VK_SUCCESS : VK_NOT_READY;
+}
+
+static void finish(uint32_t slot, uint64_t start, uint64_t end) {
+ g_result[slot][0] = start; g_result[slot][1] = 1; g_result[slot][2] = end; g_result[slot][3] = 1;
+}
+
+int main(void) {
+ struct gpuload_vk v = {
+ .device = (VkDevice)(uintptr_t)1, .create_pool = create_pool, .destroy_pool = destroy_pool, .alloc_cmds = alloc_cmds,
+ .begin = begin, .end = end, .cmd_reset = cmd_reset, .cmd_timestamp = cmd_timestamp,
+ .create_queries = create_queries, .destroy_queries = destroy_queries, .results = results,
+ };
+ struct gpuload_ring r = {0};
+ assert(!gpuload_ring_setup(&r, &v, 0, 64) && r.state == -1); // no timestamp period yet
+ gpuload_vk_period(&v, 52.083f);
+ assert(!gpuload_ring_setup(&r, &v, 0, 0) && r.state == -1); // family without timestamps
+ assert(gpuload_ring_setup(&r, &v, 0, 64) && r.state == 1);
+
+ VkCommandBuffer app[2] = {(VkCommandBuffer)(uintptr_t)0xa1, (VkCommandBuffer)(uintptr_t)0xa2};
+ VkProtectedSubmitInfo prot = {.sType = VK_STRUCTURE_TYPE_PROTECTED_SUBMIT_INFO, .protectedSubmit = VK_TRUE};
+ VkSubmitInfo batches[3] = {
+ {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 2, .pCommandBuffers = app},
+ {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO}, // semaphores only
+ {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .pNext = &prot, .commandBufferCount = 1, .pCommandBuffers = app},
+ };
+ uint32_t used = 0;
+ VkSubmitInfo *mine = gpuload_ring_wrap(&r, 3, batches, &used);
+ assert(mine && used == 1 && r.inflight == 1);
+ assert(mine[0].commandBufferCount == 4 && mine[0].pCommandBuffers[0] == r.slot[0].start &&
+ mine[0].pCommandBuffers[1] == app[0] && mine[0].pCommandBuffers[2] == app[1] &&
+ mine[0].pCommandBuffers[3] == r.slot[0].end);
+ assert(mine[1].commandBufferCount == 0 && mine[2].commandBufferCount == 1 && mine[2].pCommandBuffers == app);
+ free(mine);
+
+ assert(gpuload_ring_harvest(&r, &v) == 0 && r.inflight == 1); // not finished yet
+ finish(0, 1000, 1192); // 192 ticks = 10 us
+ assert(gpuload_ring_harvest(&r, &v) == 9999 && r.inflight == 0);
+
+ // A failed submit gives its slot back.
+ mine = gpuload_ring_wrap(&r, 1, batches, &used);
+ assert(used == 1 && r.inflight == 1);
+ gpuload_ring_unwind(&r, used);
+ assert(r.inflight == 0 && r.next == 1);
+ free(mine);
+
+ // Fill the ring: the 65th batch goes out untimed.
+ for (int i = 0; i < GPULOAD_RING; i++) { mine = gpuload_ring_wrap(&r, 1, batches, &used); assert(used == 1); free(mine); }
+ mine = gpuload_ring_wrap(&r, 1, batches, &used);
+ assert(used == 0 && mine[0].commandBufferCount == 2);
+ free(mine);
+
+ // Slot 0 comes round again: until the GPU resets it, it still shows the old pair, which must not count twice.
+ // Slots 1..63 finish after a counter restart (smaller ticks than slot 0's old pair); each still counts.
+ for (uint32_t s = 1; s < GPULOAD_RING; s++) finish(s, 10 * s, 10 * s + 4);
+ assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(63 * 4 * 52.083f) && r.inflight == 1 && r.oldest == 0);
+ finish(0, 5, 9);
+ assert(gpuload_ring_harvest(&r, &v) == (uint64_t)(4 * 52.083f) && r.inflight == 0);
+
+ gpuload_ring_release(&r, &v);
+ puts("test_gpuload_ring: ok");
+ return 0;
+}
diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py
index 4aefaa3da..9122707be 100644
--- a/tools/release/runtime_inputs.py
+++ b/tools/release/runtime_inputs.py
@@ -26,6 +26,7 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app
'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/dirt-ws2', 'tools/components', 'tools/ci', 'tools/build_local.sh',
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal',
'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh',
+ 'tools/gpuload',
'app/src/main/cpp/waylandcomp/src/headpose_shm.h',
'app/src/main/cpp/venc/venc_proto.h',
'app/src/main/cpp/waylandcomp/src/spatialpose_shm.h',
@@ -92,6 +93,8 @@ def required():
'droiddeck/resources/settings/default.vrsettings', 'layer/libVkLayer_droiddeck_vr.so',
'layer/VK_LAYER_DROIDDECK_vr.json', 'layer/VK_LAYER_DROIDDECK_vr_implicit.json',
'layer/libdroiddeck_vr_scheduling.so']]
+ paths += ['assets/linuxfs/usr/local/lib/droiddeck-gpuload/' + name for name in
+ ['libVkLayer_droiddeck_gpuload.so', 'VK_LAYER_DROIDDECK_gpu_load.json']]
for side in ['left', 'right']:
paths += [f'assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/resources/rendermodels/droiddeck_hand_{side}/{name}'
for name in [f'droiddeck_hand_{side}.json', 'hand.mtl', 'hand.png', 'hand_open.obj', 'hand_fist.obj',
diff --git a/tools/tests/kgsl_busy_harness.c b/tools/tests/kgsl_busy_harness.c
new file mode 100644
index 000000000..14e96bf64
--- /dev/null
+++ b/tools/tests/kgsl_busy_harness.c
@@ -0,0 +1,56 @@
+#include
+#include
+#include
+
+#include "kgsl_busy.h"
+
+#define ENTRIES 819
+
+static uint32_t memstore[ENTRIES * KGSL_BUSY_ENTRY_WORDS];
+
+static void stamp(int entry, uint32_t sop, uint32_t eop) {
+ memstore[entry * KGSL_BUSY_ENTRY_WORDS + KGSL_BUSY_SOP] = sop;
+ memstore[entry * KGSL_BUSY_ENTRY_WORDS + KGSL_BUSY_EOP] = eop;
+}
+
+int main(void) {
+ assert(kgsl_busy_first_slot(8 * 4096) == 813);
+ assert(kgsl_busy_first_slot(8 * 16384) == 3270);
+ assert(kgsl_busy_first_slot(7 * 40) == -1);
+
+ int first = kgsl_busy_first_slot(sizeof(memstore));
+ uint32_t last[KGSL_BUSY_SLOTS] = {0};
+ int moved = 0;
+
+ stamp(first, 100, 100);
+ stamp(first + 2, 7, 7);
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 0);
+ assert(moved == 1 && last[0] == 100 && last[2] == 7);
+
+ moved = 0;
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 0);
+ assert(moved == 0);
+
+ stamp(first + 2, 8, 7);
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 1);
+ assert(moved == 1);
+
+ moved = 0;
+ stamp(first + 2, 8, 8);
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 0);
+ assert(moved == 0);
+
+ stamp(first + 4, 3, 2);
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 1);
+
+ memset(memstore, 0, sizeof(memstore));
+ stamp(first - 1, 5, 4);
+ stamp(first + KGSL_BUSY_SLOTS, 9, 8);
+ memset(last, 0, sizeof(last));
+ moved = 0;
+ assert(kgsl_busy_sample(memstore, first, last, &moved) == 0);
+ assert(moved == 0);
+
+ puts("PASS: kgsl memstore busy");
+ return 0;
+}
diff --git a/tools/tests/test_kgsl_busy.py b/tools/tests/test_kgsl_busy.py
new file mode 100644
index 000000000..7655b9873
--- /dev/null
+++ b/tools/tests/test_kgsl_busy.py
@@ -0,0 +1,33 @@
+"""Exercise the HUD's kgsl memstore busy sampling (app/src/main/cpp/gpuclock/kgsl_busy.h)."""
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+REPO = Path(__file__).resolve().parents[2]
+
+
+class KgslBusyTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ compiler = shutil.which(os.environ.get('CC', 'cc'))
+ if compiler is None:
+ raise unittest.SkipTest('A host C compiler is required')
+ cls.temp = tempfile.TemporaryDirectory(prefix='droiddeck-kgsl-busy-')
+ cls.addClassCleanup(cls.temp.cleanup)
+ cls.binary = Path(cls.temp.name) / 'kgsl-busy-test'
+ subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror',
+ '-I', str(REPO / 'app/src/main/cpp/gpuclock'),
+ str(REPO / 'tools/tests/kgsl_busy_harness.c'), '-o', str(cls.binary)],
+ check=True, capture_output=True, text=True)
+
+ def test_ringbuffer_slots_and_busy_rule(self):
+ result = subprocess.run([str(self.binary)], capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertIn('PASS: kgsl memstore busy', result.stdout)
+
+
+if __name__ == '__main__':
+ unittest.main()
From bf9435cd36baa6c3f20c9607c6958655cb5dc5cf Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 16:13:52 -0500
Subject: [PATCH 08/17] feat: KDE desktop on the bottom screen beside the
session (#1966)
* feat: KDE desktop as a bottom-screen mode beside the top-screen session
* test: bottom-desktop launch test sets DISPLAY instead of inheriting it
* fix: drop the bottom desktop's launch when the session stops while preparing it
* fix: bottom desktop restarts on a fresh surface, trackpad/touch toggle, loading throbber, DroidDeck's display driver
* fix: bottom desktop pauses with the session and opens the keyboard for a tapped text field
* fix: no bottom desktop guest starts or keeps running unfrozen while the session is paused
* fix: bottom desktop treats a freeze in progress as paused
* fix: only the session that began a freeze clears its pausing flag
* fix: bottom desktop launch is stored and cleared only for its own session generation
* fix: a stopped session tears down the bottom desktop it started even after a newer launch is prepared
* fix: a new session's bottom desktop replaces one an earlier session left running
* fix: release the bottom desktop's keyboard watcher when its compositor dies
* fix: bottom desktop starts its guest only for the launch its compositor was attached for
* fix: bottom desktop ignores compositor replies to an earlier session's attach
---
app/src/main/AndroidManifest.xml | 5 +
.../main/java/com/droiddeck/launcher/App.kt | 2 +-
.../com/droiddeck/launcher/SessionActivity.kt | 30 +-
.../com/droiddeck/launcher/input/GuestKeys.kt | 31 ++
.../launcher/input/SecondScreenMode.kt | 8 +-
.../launcher/session/BottomDesktop.kt | 353 ++++++++++++++++++
.../launcher/session/SessionFiles.kt | 1 +
.../launcher/session/SessionPrefs.kt | 7 +
.../launcher/session/SessionService.kt | 14 +-
.../launcher/session/SessionState.kt | 2 +
.../launcher/ui/BottomDesktopPanel.kt | 345 +++++++++++++++++
.../launcher/ui/BottomDesktopStatus.kt | 55 +++
.../launcher/ui/DeckControlsPanel.kt | 9 +-
.../launcher/ui/SecondScreenPresentation.kt | 10 +-
.../droiddeck/launcher/ui/SessionOverlay.kt | 6 +-
.../wayland/BottomDesktopCompositorService.kt | 117 ++++++
.../main/res/drawable/ic_bottom_keyboard.xml | 5 +
app/src/main/res/drawable/ic_bottom_mouse.xml | 5 +
app/src/main/res/drawable/ic_bottom_touch.xml | 5 +
app/src/main/res/values/strings.xml | 11 +
.../launcher/input/SecondScreenModeTest.kt | 11 +
tools/linuxfs/desktop/droiddeck-desktop | 2 +-
.../usr/local/bin/droiddeck-bottom-desktop | 51 +++
.../overlay/usr/local/bin/droiddeck-ime | 2 +-
.../usr/local/bin/droiddeck-steam-launch | 8 +
tools/tests/test_steam_desktop_launch.py | 24 ++
26 files changed, 1083 insertions(+), 36 deletions(-)
create mode 100644 app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt
create mode 100644 app/src/main/res/drawable/ic_bottom_keyboard.xml
create mode 100644 app/src/main/res/drawable/ic_bottom_mouse.xml
create mode 100644 app/src/main/res/drawable/ic_bottom_touch.xml
create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7d064b198..e5b17b50e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -300,6 +300,11 @@
android:name=".session.SessionService"
android:exported="false"
android:stopWithTask="false" />
+
+
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index 7bf90d635..de94c41a5 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -37,7 +37,7 @@ class App : Application() {
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
- process == "$packageName:shortcuts_sync") return
+ process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 7ba6f5243..3e8809134 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -67,7 +67,6 @@ import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
-import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.HomeGuideInput
import com.droiddeck.launcher.input.InputSpace
import com.droiddeck.launcher.input.KeyboardHost
@@ -1198,6 +1197,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
dashboardAvailable = bottomPages().isNotEmpty(),
+ desktopAvailable = com.droiddeck.launcher.session.BottomDesktop.available(),
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
@@ -2613,27 +2613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down && event.repeatCount > 0) return true
- if (down || event.action == KeyEvent.ACTION_UP) {
- var evdev = EvdevKeys.fromKeyCode(event.keyCode)
- val ch = event.unicodeChar
- val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0
- if (evdev <= 0 && ch > 0) {
- val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch))
- if (code > 0) evdev = code
- }
- val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0
- if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
- if (evdev > 0) {
- if (down) {
- if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true)
- sendGuestKey(evdev, true)
- } else {
- sendGuestKey(evdev, false)
- if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false)
- }
- return true
- }
- }
+ if (com.droiddeck.launcher.input.GuestKeys.send(event, ::sendGuestKey)) return true
}
return super.dispatchKeyEvent(event)
}
@@ -3083,7 +3063,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (secondScreenDisplays.none { it.id == target.displayId }) return
SessionState.secondScreenAutoOpened = true
selectedSecondScreenDisplay = target.displayId
- selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty()))
+ selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(),
+ com.droiddeck.launcher.session.BottomDesktop.available()))
dualControlsProvisional = ::loading.isInitialized && loading.visible
}
@@ -3091,7 +3072,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return
dualControlsProvisional = false
if (secondScreenMode == SecondScreenMode.NONE) return
- val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty())
+ val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(),
+ com.droiddeck.launcher.session.BottomDesktop.available())
if (wanted != secondScreenMode) selectSecondScreenMode(wanted) else secondScreenPresentation?.retryTerminal()
}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt
new file mode 100644
index 000000000..fbc3739cd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/GuestKeys.kt
@@ -0,0 +1,31 @@
+package com.droiddeck.launcher.input
+
+import android.view.KeyEvent
+
+object GuestKeys {
+ /** Sends [event] as evdev presses, with Shift around characters typed by a keyboard that has no Shift key; false when it has no evdev code. */
+ fun send(event: KeyEvent, sendKey: (code: Int, down: Boolean) -> Unit): Boolean {
+ val down = event.action == KeyEvent.ACTION_DOWN
+ if (!down && event.action != KeyEvent.ACTION_UP) return false
+ var evdev = EvdevKeys.fromKeyCode(event.keyCode)
+ val ch = event.unicodeChar
+ val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0
+ if (evdev <= 0 && ch > 0) {
+ val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch))
+ if (code > 0) evdev = code
+ }
+ val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) KEY_LEFTSHIFT else 0
+ if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
+ if (evdev <= 0) return false
+ if (down) {
+ if (shiftEvdev != 0) sendKey(shiftEvdev, true)
+ sendKey(evdev, true)
+ } else {
+ sendKey(evdev, false)
+ if (shiftEvdev != 0) sendKey(shiftEvdev, false)
+ }
+ return true
+ }
+
+ private const val KEY_LEFTSHIFT = 42
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
index f030565ff..38f7f76d9 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
@@ -10,12 +10,14 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
KEYBOARD_TRACKPAD("keyboard-trackpad", R.string.second_mode_keyboard_trackpad),
TERMINAL("terminal", R.string.second_mode_terminal),
DECK_CONTROLS("deck-controls", R.string.second_mode_deck_controls),
- DASHBOARD("dashboard", R.string.second_mode_dashboard);
+ DASHBOARD("dashboard", R.string.second_mode_dashboard),
+ DESKTOP("desktop", R.string.second_mode_desktop);
companion object {
- fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false): SecondScreenMode {
+ fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, desktop: Boolean = false): SecondScreenMode {
entries.firstOrNull {
- it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard)
+ it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
+ (it != DESKTOP || desktop)
}?.let { return it }
// No pick yet: the dashboard. A pick that can't open right now keeps the old fallback.
val picked = entries.any { it != NONE && it.id == remembered }
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt
new file mode 100644
index 000000000..923ee2999
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/BottomDesktop.kt
@@ -0,0 +1,353 @@
+package com.droiddeck.launcher.session
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Environment
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.util.Log
+import android.view.Surface
+import com.droiddeck.launcher.core.HostProcess
+import com.droiddeck.launcher.runtime.DesktopCatalog
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.wayland.BottomDesktopCompositorService as Comp
+import java.io.File
+
+/**
+ * KDE Plasma on the bottom screen while the session keeps the top one: its own compositor process
+ * ([Comp]) and a second guest started with the session's environment, like the bottom-screen terminal.
+ */
+object BottomDesktop {
+ private const val TAG = "BottomDesktop"
+ const val SCRIPT = "/usr/local/bin/droiddeck-bottom-desktop"
+ private const val TARGET_SHORT_SIDE = 720f
+
+ enum class State { OFF, STARTING, RUNNING, ENDED, FAILED }
+
+ private class Launch(
+ val guest: List,
+ val binds: List,
+ val sessionRoot: File,
+ val hostEnvironment: Array,
+ val root: File,
+ val sessionDir: File,
+ val generation: Int,
+ val installed: Boolean,
+ )
+
+ @Volatile private var launch: Launch? = null
+ private val launchLock = Any()
+ private val main = Handler(Looper.getMainLooper())
+ private val listeners = LinkedHashSet<() -> Unit>()
+
+ @Volatile var state = State.OFF
+ private set
+ private var onTextInput: ((Boolean) -> Unit)? = null
+ private var imeWatcher: android.os.FileObserver? = null
+
+ private var appContext: Context? = null
+ private var service: Messenger? = null
+ private var binding: ServiceConnection? = null
+ private var pendingAttach: Message? = null
+ /** The desktop guest's proot: -1 when there is none, 0 while it is being started. */
+ @Volatile private var guestPid = -1
+
+ /** The proot to freeze with the session when it pauses. */
+ val suspendPid: Int get() = guestPid
+ private var guestGeneration = 0
+ private var outputSize = 0 to 0
+ private var cursorShown = false
+ /** The session generation whose launch the running compositor and guest belong to; -1 when none. */
+ private var owner = -1
+
+ private val replies = Messenger(object : Handler(Looper.getMainLooper()) {
+ override fun handleMessage(msg: Message) {
+ if (msg.arg1 != owner) return
+ when (msg.what) {
+ Comp.MSG_ATTACHED -> {
+ service?.let { send(it, Message.obtain(null, Comp.MSG_CURSOR, if (cursorShown) 1 else 0, 0)) }
+ if (guestPid < 0 && state == State.STARTING) startGuest()
+ }
+ Comp.MSG_FIRST_FRAME -> if (state == State.STARTING) setState(State.RUNNING)
+ }
+ }
+ })
+
+ fun prepare(
+ guest: List,
+ binds: List,
+ sessionRoot: File,
+ hostEnvironment: Array,
+ root: File,
+ sessionDir: File,
+ generation: Int,
+ stillCurrent: () -> Boolean,
+ ) {
+ val prepared = Launch(ArrayList(guest), ArrayList(binds), sessionRoot, hostEnvironment.copyOf(), root, sessionDir, generation,
+ DesktopCatalog.desktopInstalled(root))
+ synchronized(launchLock) { if (stillCurrent()) launch = prepared }
+ }
+
+ fun available(): Boolean =
+ launch?.installed == true && SessionState.running && SessionState.mode != SessionService.MODE_DESKTOP
+
+ /**
+ * The desktop renders below the panel's resolution: about 720 pixels on the short side keeps
+ * Plasma's text big enough to touch and its software rendering cheap. A running desktop keeps its size.
+ */
+ fun outputSizeFor(width: Int, height: Int): Pair {
+ if ((state == State.STARTING || state == State.RUNNING) && outputSize.first > 0) return outputSize
+ if (width <= 0 || height <= 0) return 0 to 0
+ val scale = maxOf(1f, Math.round(minOf(width, height) / TARGET_SHORT_SIDE * 2f) / 2f)
+ return ((width / scale).toInt() and 1.inv()) to ((height / scale).toInt() and 1.inv())
+ }
+
+ fun addListener(listener: () -> Unit) { listeners.add(listener) }
+
+ fun removeListener(listener: () -> Unit) { listeners.remove(listener) }
+
+ /** Called on the main thread when a text field in the desktop gains or loses focus (droiddeck-ime's report). */
+ fun setTextInputListener(listener: ((Boolean) -> Unit)?) { onTextInput = listener }
+
+ private fun watchIme(dir: File) {
+ imeWatcher?.stopWatching()
+ dir.deleteRecursively()
+ dir.mkdirs()
+ @Suppress("DEPRECATION")
+ imeWatcher = object : android.os.FileObserver(dir.path, MOVED_TO or CLOSE_WRITE) {
+ override fun onEvent(event: Int, path: String?) {
+ if (path != "ime.json") return
+ val focused = runCatching { org.json.JSONObject(File(dir, path).readText()).optBoolean("textField") }
+ .getOrNull() ?: return
+ main.post { if (imeWatcher === this) onTextInput?.invoke(focused) }
+ }
+ }.also { it.startWatching() }
+ }
+
+ private fun setState(next: State) {
+ if (Looper.myLooper() != Looper.getMainLooper()) { main.post { setState(next) }; return }
+ if (state == next) return
+ Log.i(TAG, "state $state -> $next")
+ state = next
+ listeners.toList().forEach { it() }
+ }
+
+ fun attach(context: Context, surface: Surface, width: Int, height: Int, refreshHz: Float) {
+ val current = launch
+ if (current == null || !SessionState.running) {
+ setState(State.FAILED)
+ return
+ }
+ if (owner != -1 && owner != current.generation) stop(context, owner)
+ appContext = context.applicationContext
+ owner = current.generation
+ outputSize = width to height
+ val dir = File(current.sessionDir, "bottom-desktop").apply { mkdirs() }
+ val msg = Message.obtain(null, Comp.MSG_ATTACH).apply {
+ arg1 = owner
+ obj = surface
+ replyTo = replies
+ data = Bundle().apply {
+ putString(Comp.KEY_RUNTIME_DIR, runtimeDir(context).path)
+ putInt(Comp.KEY_WIDTH, width)
+ putInt(Comp.KEY_HEIGHT, height)
+ putFloat(Comp.KEY_REFRESH, refreshHz)
+ putString(Comp.KEY_LOG, File(dir, "wayland.log").path)
+ }
+ }
+ if (state == State.OFF || state == State.ENDED || state == State.FAILED) setState(State.STARTING)
+ val target = service
+ if (target != null) send(target, msg) else {
+ pendingAttach = msg
+ bind(context.applicationContext)
+ }
+ }
+
+ fun detach() {
+ pendingAttach = null
+ service?.let { send(it, Message.obtain(null, Comp.MSG_DETACH)) }
+ }
+
+ fun sendTouch(action: Int, pointerId: Int, x: Int, y: Int) {
+ val target = service ?: return
+ send(target, Message.obtain(null, Comp.MSG_TOUCH, (pointerId shl 8) or (action and 0xff),
+ (x.coerceIn(0, 0xffff) shl 16) or y.coerceIn(0, 0xffff)))
+ }
+
+ fun sendPointer(x: Int, y: Int) {
+ val target = service ?: return
+ send(target, Message.obtain(null, Comp.MSG_POINTER, x, y))
+ }
+
+ /** Button (3), wheel (4) and smooth-scroll (12) input, as `WaylandCompositor.nativeSendSceneInput` takes it. */
+ fun sendScene(type: Int, a: Int, b: Int = 0) {
+ val target = service ?: return
+ send(target, Message.obtain(null, Comp.MSG_SCENE, type, a).apply {
+ if (b != 0) data = Bundle().apply { putInt(Comp.KEY_B, b) }
+ })
+ }
+
+ fun showCursor(on: Boolean) {
+ cursorShown = on
+ service?.let { send(it, Message.obtain(null, Comp.MSG_CURSOR, if (on) 1 else 0, 0)) }
+ }
+
+ /** False when the next attach starts a new compositor process, which needs a surface no earlier one used. */
+ val connected: Boolean get() = service != null
+
+ fun sendKey(evdev: Int, down: Boolean) {
+ val target = service ?: return
+ send(target, Message.obtain(null, Comp.MSG_KEY, evdev, if (down) 1 else 0))
+ }
+
+ fun sendText(text: String) {
+ val target = service ?: return
+ send(target, Message.obtain(null, Comp.MSG_TEXT).apply { data = Bundle().apply { putString(Comp.KEY_TEXT, text) } })
+ }
+
+ fun stop(context: Context, generation: Int? = null) {
+ if (Looper.myLooper() != Looper.getMainLooper()) { main.post { stop(context, generation) }; return }
+ synchronized(launchLock) {
+ if (generation != null && launch?.generation == generation) launch = null
+ }
+ // A newer session may already have prepared its launch; only the desktop the stopped session started goes.
+ if (generation != null && owner != generation) return
+ owner = -1
+ val pid = guestPid
+ guestPid = -1
+ guestGeneration++
+ if (pid > 1) SessionService.stopTerminalProcess(context, pid)
+ service?.let { send(it, Message.obtain(null, Comp.MSG_STOP)) }
+ binding?.let { runCatching { context.applicationContext.unbindService(it) } }
+ binding = null
+ service = null
+ pendingAttach = null
+ imeWatcher?.stopWatching()
+ imeWatcher = null
+ setState(State.OFF)
+ }
+
+ private fun bind(context: Context) {
+ if (binding != null) return
+ val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ if (binding !== this || binder == null) return
+ val messenger = Messenger(binder)
+ service = messenger
+ pendingAttach?.let { send(messenger, it) }
+ pendingAttach = null
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ if (binding !== this) return
+ Log.w(TAG, "the desktop's compositor process died")
+ service = null
+ binding = null
+ runCatching { context.unbindService(this) }
+ val pid = guestPid
+ guestPid = -1
+ guestGeneration++
+ if (pid > 1) SessionService.stopTerminalProcess(context, pid)
+ imeWatcher?.stopWatching()
+ imeWatcher = null
+ owner = -1
+ setState(State.FAILED)
+ }
+ }
+ binding = connection
+ val bound = runCatching {
+ context.bindService(Intent(context, Comp::class.java), connection,
+ Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT)
+ }.getOrDefault(false)
+ if (!bound) {
+ Log.e(TAG, "could not bind the desktop's compositor")
+ binding = null
+ setState(State.FAILED)
+ }
+ }
+
+ private fun send(target: Messenger, msg: Message) {
+ runCatching { target.send(msg) }.onFailure { Log.w(TAG, "compositor message ${msg.what} not delivered", it) }
+ }
+
+ private fun runtimeDir(context: Context) = File(context.filesDir, ".wayland-rt-bottom")
+
+ private fun startGuest() {
+ val context = appContext ?: return
+ val current = launch
+ if (current == null || !SessionState.running || current.generation != owner) {
+ setState(State.FAILED)
+ return
+ }
+ // A freeze collects its helpers once, when it begins; a guest started after that would run on unfrozen.
+ if (SessionState.suspended || SessionState.suspending) {
+ setState(State.OFF)
+ return
+ }
+ val generation = ++guestGeneration
+ guestPid = 0
+ val (width, height) = outputSize
+ service?.let { send(it, Message.obtain(null, Comp.MSG_NEW_GUEST)) }
+ Thread({
+ if (!DesktopCatalog.desktopInstalled(current.root)) {
+ Log.e(TAG, "the desktop package is not installed")
+ main.post { if (generation == guestGeneration) { guestPid = -1; setState(State.FAILED) } }
+ return@Thread
+ }
+ val dir = File(current.sessionDir, "bottom-desktop").apply { mkdirs() }
+ val runtime = runtimeDir(context).apply { mkdirs() }
+ val imeDir = File(current.sessionRoot, "bottom-desktop-ime")
+ main.post { if (generation == guestGeneration) watchIme(imeDir) }
+ val binds = LinuxRuntime.binds(context, current.sessionRoot, runtime, Environment.getExternalStorageDirectory(), current.binds)
+ val guest = ArrayList(current.guest).apply {
+ add("BL_WIDTH=$width")
+ add("BL_HEIGHT=$height")
+ add("BL_DEBUG_DIR=" + dir.path)
+ add("BL_TOP_SESSION=" + SessionState.mode)
+ add("BL_IME_DIR=" + imeDir.path)
+ add(SCRIPT)
+ }
+ val command = LinuxRuntime.commandWithBinds(context, current.root, binds, guest)
+ val line = command.joinToString(" ") { it.replace("\\", "\\\\").replace(" ", "\\ ") }
+ val pid = HostProcess.start(line, current.hostEnvironment, current.root, { status ->
+ main.post {
+ if (generation != guestGeneration) return@post
+ Log.i(TAG, "the desktop ended ($status)")
+ SessionService.terminalProcessExited(context, guestPid)
+ guestPid = -1
+ guestGeneration++
+ setState(if (state == State.RUNNING || status == 0) State.ENDED else State.FAILED)
+ }
+ }, null, File(dir, "proot.log"))
+ main.post {
+ if (generation != guestGeneration || launch !== current) {
+ if (pid > 1) SessionService.stopTerminalProcess(context, pid)
+ if (generation == guestGeneration) guestPid = -1
+ return@post
+ }
+ if (pid <= 1) {
+ guestPid = -1
+ Log.e(TAG, "the desktop's guest did not start")
+ setState(State.FAILED)
+ return@post
+ }
+ if (SessionState.suspended || SessionState.suspending) {
+ SessionService.stopTerminalProcess(context, pid)
+ guestPid = -1
+ guestGeneration++
+ setState(State.OFF)
+ return@post
+ }
+ guestPid = pid
+ SessionService.registerTerminalProcess(context, pid)
+ Log.i(TAG, "desktop guest pid $pid at ${width}x$height")
+ }
+ }, "bottom-desktop-start").start()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index bcb13d14f..056fc205a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -22,6 +22,7 @@ object SessionFiles {
internal val DESKTOP_FILES = arrayOf(
"usr/local/bin/droiddeck-desktop" to "usr/local/bin/droiddeck-desktop",
+ "usr/local/bin/droiddeck-bottom-desktop" to "usr/local/bin/droiddeck-bottom-desktop",
"usr/local/bin/droiddeck-gpu" to "usr/local/bin/droiddeck-gpu",
"usr/local/bin/droiddeck-desktop-gpu" to "usr/local/bin/droiddeck-desktop-gpu",
"usr/local/bin/droiddeck-desktop-window-rules" to "usr/local/bin/droiddeck-desktop-window-rules",
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index d49230f50..9ed3b6d27 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -100,6 +100,13 @@ object SessionPrefs {
prefs(context).edit().putString("secondScreenPanel", mode).apply()
}
+ /** The bottom-screen desktop takes touch as a trackpad unless the user switched it to direct touch. */
+ fun bottomDesktopDirectTouch(context: Context): Boolean = prefs(context).getBoolean("bottomDesktopDirectTouch", false)
+
+ fun setBottomDesktopDirectTouch(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean("bottomDesktopDirectTouch", on).apply()
+ }
+
fun statsStrip(context: Context): String? = prefs(context).getString("statsStrip", null)
fun setStatsStrip(context: Context, id: String) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index 09bedc4e3..6a631341a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -577,6 +577,7 @@ class SessionService : Service() {
add("-c")
add("cd \"\$HOME\" && exec /bin/bash -i")
}
+ val desktopGuest = ArrayList(guest)
guest.add(LinuxRuntime.SESSION_SCRIPT)
guest.add(SessionState.mode)
if (SessionState.mode == MODE_STEAM) SessionState.steamUrl?.takeIf { it.startsWith("steam://") }?.let {
@@ -643,6 +644,10 @@ class SessionService : Service() {
}
if (gen == sessionGen && SessionState.running) {
SessionTerminal.prepare(terminalCommand, terminalHostEnvironment.map { (key, value) -> "$key=$value" }.toTypedArray(), root, gen)
+ val (fastPathName, _) = ProotFastPath.hostEnv("")
+ BottomDesktop.prepare(desktopGuest, binds, sessionRoot,
+ hostEnv.asArray().filterNot { it.startsWith("$fastPathName=") }.toTypedArray(), root, sessionDir, gen,
+ ) { gen == sessionGen && SessionState.running }
}
if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root)
@@ -704,7 +709,10 @@ class SessionService : Service() {
if (gen == sessionGen && SessionState.running) {
suspendController = SessionSuspendController(
sessionRoot = { sessionPid },
- helperRoots = { components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } },
+ helperRoots = {
+ components.mapNotNull { it.suspendPid().takeIf { pid -> pid > 1 } } +
+ listOfNotNull(BottomDesktop.suspendPid.takeIf { it > 1 })
+ },
suspendAudio = {
if (!pulse.setSinkSuspended(true)) Log.w(TAG, "could not suspend audio sink")
if (!pulse.setMicSuspended(true)) Log.w(TAG, "could not suspend microphone source")
@@ -1610,6 +1618,7 @@ class SessionService : Service() {
))
if (shouldSuspend) {
freezePending = true
+ SessionState.suspending = true
freezeCancelled = false
val freezeSteamToken = steamSleepToken
val freezeNativeToken = nativeSleepToken
@@ -1618,6 +1627,7 @@ class SessionService : Service() {
) { success ->
mainHandler.post {
if (gen != sessionGen || !SessionState.running) return@post
+ SessionState.suspending = false
suspendOperationPending = false
freezePending = false
if (success) {
@@ -1830,6 +1840,7 @@ class SessionService : Service() {
GameThreadHints.stop()
suspendOperationPending = false
freezePending = false
+ SessionState.suspending = false
launchWatcher?.stopWatching()
launchWatcher = null
syncWatcher?.stopWatching()
@@ -1840,6 +1851,7 @@ class SessionService : Service() {
auxiliaryProcesses.toMap().also { auxiliaryProcesses.clear() }
}
SessionTerminal.clear(sessionGen)
+ BottomDesktop.stop(this, stoppedGen)
val controller = suspendController
suspendController = null
if (ended != null) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index 947948f16..c93a2758b 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -54,6 +54,8 @@ object SessionState {
var suspended by mutableStateOf(false)
var resumeRequested by mutableStateOf(false)
var resuming by mutableStateOf(false)
+ /** A freeze has begun and has not finished; it already chose which processes it stops. */
+ @Volatile var suspending = false
var resumeFailures by mutableIntStateOf(0)
var resumeFailed by mutableStateOf(false)
var manualPause by mutableStateOf(false)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt
new file mode 100644
index 000000000..d6b80ac81
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopPanel.kt
@@ -0,0 +1,345 @@
+package com.droiddeck.launcher.ui
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Color
+import android.os.SystemClock
+import android.graphics.Typeface
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.view.View
+import android.view.ViewConfiguration
+import android.graphics.RectF
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.ComposeView
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.ImageView
+import android.widget.TextView
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.input.GuestKeys
+import com.droiddeck.launcher.input.InputSpace
+import com.droiddeck.launcher.input.KeyboardHost
+import com.droiddeck.launcher.input.PadBridge
+import com.droiddeck.launcher.input.PointerGestures
+import com.droiddeck.launcher.input.TouchpadGestures
+import com.droiddeck.launcher.session.SessionPrefs
+import com.droiddeck.launcher.session.BottomDesktop
+
+@SuppressLint("ViewConstructor")
+class BottomDesktopPanel(
+ context: Context,
+ onClose: () -> Unit,
+ onMenu: (() -> Unit)?,
+ steamButtons: Pair<() -> Unit, () -> Unit>?,
+) : LinearLayout(context), SurfaceHolder.Callback {
+ private lateinit var surfaceView: SurfaceView
+ private val area = FrameLayout(context).apply { clipChildren = true }
+ private val state = mutableStateOf(BottomDesktop.state)
+ private val status = ComposeView(context).apply {
+ setContent { DroidDeckTheme { BottomDesktopStatus(state.value, ::startAgain) } }
+ }
+ private val keyboardHost = KeyboardHost(context) { event -> sendKey(event) }
+ private var directTouch = SessionPrefs.bottomDesktopDirectTouch(context)
+ private lateinit var touchButton: ImageView
+ private lateinit var title: TextView
+ private val trackpad = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(), TrackpadInput()).apply {
+ desktop = true
+ }
+ private var attached = false
+ /** The surface was given to a compositor process; a new process cannot connect to it again. */
+ private var surfaceUsed = false
+ private var fullWidth = 0
+ private var fullHeight = 0
+ private var lastTouchY = 0f
+ private var lastPressMs = 0L
+ private var scrollRestX = 0f
+ private var scrollRestY = 0f
+ private val stateListener: () -> Unit = { showState() }
+
+ init {
+ orientation = VERTICAL
+ setBackgroundColor(Color.BLACK)
+ isFocusable = true
+ isFocusableInTouchMode = true
+ addView(bar(onClose, onMenu, steamButtons), LayoutParams(-1, -2))
+ area.addView(keyboardHost)
+ addSurface()
+ area.addView(status, FrameLayout.LayoutParams(-1, -1))
+ addView(area, LayoutParams(-1, 0, 1f))
+ keyboardHost.onImeChanged = { pan() }
+ showTouchMode()
+ }
+
+ private fun addSurface() {
+ surfaceView = SurfaceView(context)
+ surfaceView.holder.addCallback(this)
+ @Suppress("ClickableViewAccessibility")
+ surfaceView.setOnTouchListener { _, event -> onSurfaceTouch(event) }
+ area.addView(surfaceView, 1, FrameLayout.LayoutParams(fullWidth.takeIf { it > 0 } ?: -1, fullHeight.takeIf { it > 0 } ?: -1))
+ }
+
+ private fun bar(onClose: () -> Unit, onMenu: (() -> Unit)?, steamButtons: Pair<() -> Unit, () -> Unit>?): View {
+ val bar = LinearLayout(context).apply {
+ orientation = HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(dp(8), dp(6), dp(8), dp(6))
+ setBackgroundColor(Color.BLACK)
+ }
+ fun gap() = View(context).apply { layoutParams = LayoutParams(dp(6), 1) }
+ bar.addView(DeckControlsPanel.roundButton(context, "×", context.getString(R.string.deck_close), onClose))
+ title = TextView(context).apply {
+ textSize = 14f
+ typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
+ setTextColor(Color.rgb(0x7A, 0x80, 0x8A))
+ setPadding(dp(12), 0, dp(6), 0)
+ isSingleLine = true
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ }
+ bar.addView(title, LayoutParams(0, -2, 1f))
+ touchButton = DeckControlsPanel.iconButton(context, R.drawable.ic_bottom_mouse, "") { setDirectTouch(!directTouch) }
+ bar.addView(touchButton)
+ bar.addView(gap())
+ bar.addView(DeckControlsPanel.iconButton(context, R.drawable.ic_bottom_keyboard,
+ context.getString(R.string.second_show_keyboard)) { keyboardHost.toggle() })
+ onMenu?.let {
+ bar.addView(gap())
+ bar.addView(DeckControlsPanel.menuButton(context, it))
+ }
+ steamButtons?.let { (steam, qam) ->
+ bar.addView(gap())
+ bar.addView(DeckControlsPanel.roundButton(context, "STEAM", context.getString(R.string.deck_open_steam_menu), steam, wide = true))
+ bar.addView(gap())
+ bar.addView(DeckControlsPanel.roundButton(context, "•••", context.getString(R.string.drawer_open_qam), qam, wide = true))
+ }
+ return bar
+ }
+
+ private fun setDirectTouch(on: Boolean) {
+ trackpad.cancel()
+ BottomDesktop.sendTouch(3, -1, 0, 0)
+ directTouch = on
+ SessionPrefs.setBottomDesktopDirectTouch(context, on)
+ showTouchMode()
+ }
+
+ private fun showTouchMode() {
+ touchButton.setImageResource(if (directTouch) R.drawable.ic_bottom_touch else R.drawable.ic_bottom_mouse)
+ touchButton.contentDescription = context.getString(
+ if (directTouch) R.string.bottom_desktop_direct_touch else R.string.bottom_desktop_trackpad)
+ title.text = context.getString(R.string.bottom_desktop_title,
+ context.getString(if (directTouch) R.string.bottom_desktop_mode_touch else R.string.bottom_desktop_mode_trackpad))
+ BottomDesktop.showCursor(!directTouch)
+ if (!directTouch && surfaceView.width > 0) {
+ trackpad.bounds = RectF(0f, 0f, surfaceView.width.toFloat(), surfaceView.height.toFloat())
+ trackpad.place(surfaceView.width / 2f, surfaceView.height / 2f)
+ movePointer(trackpad.x, trackpad.y)
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ BottomDesktop.addListener(stateListener)
+ BottomDesktop.setTextInputListener { focused ->
+ val tapped = SystemClock.uptimeMillis() - lastPressMs < TEXT_FIELD_TAP_MS
+ val hardKeyboard = resources.configuration.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
+ if (focused && tapped && !hardKeyboard && !keyboardHost.shown) keyboardHost.show()
+ else if (!focused && keyboardHost.shown) keyboardHost.hide()
+ }
+ showState()
+ post { if (!keyboardHost.shown) requestFocus() }
+ }
+
+ override fun onDetachedFromWindow() {
+ BottomDesktop.removeListener(stateListener)
+ BottomDesktop.setTextInputListener(null)
+ keyboardHost.takeIf { it.shown }?.hide()
+ trackpad.cancel()
+ status.disposeComposition()
+ super.onDetachedFromWindow()
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean =
+ if (BottomDesktop.state == BottomDesktop.State.RUNNING && !PadBridge.isControllerKeyEvent(event) &&
+ event.keyCode != KeyEvent.KEYCODE_BACK && sendKey(event)) true
+ else super.dispatchKeyEvent(event)
+
+ private fun sendKey(event: KeyEvent): Boolean {
+ if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount > 0) return true
+ if (GuestKeys.send(event, BottomDesktop::sendKey)) return true
+ val ch = event.unicodeChar
+ if (event.action == KeyEvent.ACTION_DOWN && ch > 0) {
+ BottomDesktop.sendText(String(Character.toChars(ch)))
+ return true
+ }
+ return false
+ }
+
+ private fun startAgain() {
+ if (surfaceUsed && !BottomDesktop.connected) {
+ attached = false
+ surfaceUsed = false
+ area.removeView(surfaceView)
+ addSurface()
+ return
+ }
+ if (attached) attach(surfaceView.holder)
+ }
+
+ private fun showState() {
+ state.value = BottomDesktop.state
+ status.visibility = if (state.value == BottomDesktop.State.RUNNING) View.GONE else View.VISIBLE
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ post { holdSurfaceSize() }
+ }
+
+ // The keyboard shrinks the window; the desktop keeps its size and slides up under it instead of being squeezed.
+ private fun holdSurfaceSize() {
+ val w = area.width
+ val h = area.height
+ if (w <= 0 || h <= 0) return
+ if (w != fullWidth || h > fullHeight || !keyboardHost.shown) {
+ fullWidth = w
+ fullHeight = h
+ surfaceView.layoutParams = FrameLayout.LayoutParams(w, h)
+ }
+ pan()
+ }
+
+ private fun pan() {
+ val visible = area.height
+ val hidden = (fullHeight - visible).coerceAtLeast(0)
+ val anchor = if (directTouch) lastTouchY else trackpad.y
+ surfaceView.translationY = if (hidden == 0 || !keyboardHost.shown) 0f
+ else -(anchor - visible * 0.4f).coerceIn(0f, hidden.toFloat())
+ }
+
+ override fun surfaceCreated(holder: SurfaceHolder) = Unit
+
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+ if (!attached) attach(holder)
+ }
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ if (holder !== surfaceView.holder) return
+ attached = false
+ surfaceUsed = false
+ BottomDesktop.detach()
+ }
+
+ private fun attach(holder: SurfaceHolder) {
+ val frame = holder.surfaceFrame
+ val (width, height) = BottomDesktop.outputSizeFor(fullWidth.takeIf { it > 0 } ?: frame.width(),
+ fullHeight.takeIf { it > 0 } ?: frame.height())
+ if (width <= 0 || height <= 0) return
+ if (frame.width() != width || frame.height() != height) {
+ holder.setFixedSize(width, height)
+ return
+ }
+ attached = true
+ surfaceUsed = true
+ BottomDesktop.attach(context, holder.surface, width, height, display?.refreshRate ?: 60f)
+ showTouchMode()
+ }
+
+ private fun onSurfaceTouch(event: MotionEvent): Boolean {
+ val w = surfaceView.width
+ val h = surfaceView.height
+ if (w <= 0 || h <= 0 || BottomDesktop.state != BottomDesktop.State.RUNNING) return true
+ if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_UP)
+ lastPressMs = SystemClock.uptimeMillis()
+ if (directTouch) return forwardTouch(event, w, h)
+ val bounds = RectF(0f, 0f, w.toFloat(), h.toFloat())
+ if (trackpad.bounds != bounds) {
+ trackpad.bounds = bounds
+ trackpad.place(trackpad.x, trackpad.y)
+ }
+ return trackpad.onTouch(event)
+ }
+
+ private fun forwardTouch(event: MotionEvent, w: Int, h: Int): Boolean {
+ val action = when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> 0
+ MotionEvent.ACTION_MOVE -> 1
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> 2
+ MotionEvent.ACTION_CANCEL -> { BottomDesktop.sendTouch(3, -1, 0, 0); return true }
+ else -> return true
+ }
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) lastTouchY = event.y
+ if (action == 1) {
+ for (i in 0 until event.pointerCount) send(1, event.getPointerId(i), event.getX(i), event.getY(i), w, h)
+ } else {
+ val i = event.actionIndex
+ send(action, event.getPointerId(i), event.getX(i), event.getY(i), w, h)
+ }
+ return true
+ }
+
+ private fun send(action: Int, id: Int, x: Float, y: Float, w: Int, h: Int) =
+ BottomDesktop.sendTouch(action, id, InputSpace.x(x, w), InputSpace.y(y, h))
+
+ private fun movePointer(x: Float, y: Float) =
+ BottomDesktop.sendPointer(InputSpace.x(x, surfaceView.width), InputSpace.y(y, surfaceView.height))
+
+ private inner class TrackpadInput : PointerGestures.Listener {
+ override fun onMove(x: Float, y: Float) = movePointer(x, y)
+
+ override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
+ movePointer(x, y)
+ BottomDesktop.sendScene(SCENE_BUTTON, button, if (pressed) 1 else 0)
+ }
+
+ override fun onWheel(steps: Int) = BottomDesktop.sendScene(SCENE_WHEEL, steps)
+
+ override fun onScroll(dx: Float, dy: Float) {
+ val (outW, outH) = BottomDesktop.outputSizeFor(surfaceView.width, surfaceView.height)
+ scrollRestX += dx * outW / surfaceView.width.coerceAtLeast(1) * AXIS_UNITS_PER_PIXEL
+ scrollRestY += dy * outH / surfaceView.height.coerceAtLeast(1) * AXIS_UNITS_PER_PIXEL
+ val h = scrollRestX.toInt()
+ val v = scrollRestY.toInt()
+ if (h == 0 && v == 0) return
+ scrollRestX -= h
+ scrollRestY -= v
+ BottomDesktop.sendScene(SCENE_AXIS, v, h)
+ }
+
+ override fun onScrollEnd() {
+ scrollRestX = 0f
+ scrollRestY = 0f
+ BottomDesktop.sendScene(SCENE_AXIS, 0, 0)
+ }
+
+ override fun onZoom(steps: Int) {
+ BottomDesktop.sendKey(KEY_LEFTCTRL, true)
+ BottomDesktop.sendScene(SCENE_WHEEL, steps)
+ BottomDesktop.sendKey(KEY_LEFTCTRL, false)
+ }
+
+ override fun onThreeFingerTap(): Boolean {
+ post { keyboardHost.toggle() }
+ return true
+ }
+
+ override fun onLongPress() {
+ performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS)
+ }
+ }
+
+ private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
+
+ private companion object {
+ const val SCENE_BUTTON = 3
+ const val SCENE_WHEEL = 4
+ const val SCENE_AXIS = 12
+ const val KEY_LEFTCTRL = 29
+ const val AXIS_UNITS_PER_PIXEL = 3f
+ const val TEXT_FIELD_TAP_MS = 1_000L
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt
new file mode 100644
index 000000000..88bd56776
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDesktopStatus.kt
@@ -0,0 +1,55 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.session.BottomDesktop
+
+/** What the bottom screen shows while the desktop is not drawing: the session loading screen's throbber and a line of text. */
+@Composable
+internal fun BottomDesktopStatus(state: BottomDesktop.State, onStart: () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val starting = state == BottomDesktop.State.STARTING
+ Box(Modifier.fillMaxSize().background(colors.background).blockTouches(), contentAlignment = Alignment.Center) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(horizontal = 32.dp),
+ ) {
+ LogoThrobber(Modifier.width(if (starting) 96.dp else 76.dp), running = starting)
+ Spacer(Modifier.height(24.dp))
+ Text(
+ stringResource(when (state) {
+ BottomDesktop.State.ENDED -> R.string.bottom_desktop_ended
+ BottomDesktop.State.FAILED -> R.string.bottom_desktop_failed
+ BottomDesktop.State.OFF -> R.string.bottom_desktop_off
+ else -> R.string.bottom_desktop_starting
+ }),
+ fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
+ textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 420.dp),
+ )
+ if (!starting) {
+ Spacer(Modifier.height(20.dp))
+ PrimaryButton(stringResource(R.string.bottom_desktop_start), main = true, onClick = onStart)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt
index 1afa23b71..aadacf1f4 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt
@@ -715,10 +715,13 @@ class DeckControlsPanel(
}
}
- internal fun menuButton(context: Context, onClick: () -> Unit) = android.widget.ImageView(context).apply {
- setImageResource(R.drawable.ic_stat_session)
+ internal fun menuButton(context: Context, onClick: () -> Unit) =
+ iconButton(context, R.drawable.ic_stat_session, context.getString(R.string.touch_droiddeck_menu), onClick)
+
+ internal fun iconButton(context: Context, icon: Int, description: String, onClick: () -> Unit) = android.widget.ImageView(context).apply {
+ setImageResource(icon)
setColorFilter(TEXT)
- contentDescription = context.getString(R.string.touch_droiddeck_menu)
+ contentDescription = description
scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
val pad = dp(context, 11)
setPadding(pad, pad, pad, pad)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt
index 18f54cf5d..df4182e0d 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt
@@ -34,6 +34,7 @@ import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.SessionActivity
+import com.droiddeck.launcher.session.BottomDesktop
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.SessionTerminal
@@ -199,7 +200,8 @@ class SecondScreenPresentation(
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- val ownKeys = currentMode == SecondScreenMode.TERMINAL || currentMode == SecondScreenMode.DASHBOARD
+ val ownKeys = currentMode == SecondScreenMode.TERMINAL || currentMode == SecondScreenMode.DASHBOARD ||
+ currentMode == SecondScreenMode.DESKTOP
val forward = menuShown || !ownKeys || PadBridge.isControllerKeyEvent(event)
if (forward && sessionActivity.dispatchKeyEvent(event)) return true
return super.dispatchKeyEvent(event)
@@ -231,6 +233,7 @@ class SecondScreenPresentation(
val mode = when {
currentMode == SecondScreenMode.DECK_CONTROLS && !SessionState.deckPad -> SecondScreenMode.NONE
currentMode == SecondScreenMode.DASHBOARD && dashboard == null -> SecondScreenMode.NONE
+ currentMode == SecondScreenMode.DESKTOP && !BottomDesktop.available() -> SecondScreenMode.NONE
else -> currentMode
}
if (mode == SecondScreenMode.NONE) return
@@ -240,6 +243,11 @@ class SecondScreenPresentation(
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam, pad, onMenu = onMenu), weightParams())
SecondScreenMode.DASHBOARD -> renderDashboard()
+ SecondScreenMode.DESKTOP -> {
+ ownComposeViews()
+ root.addView(BottomDesktopPanel(context, onClose, onMenu,
+ (onSteamMenu to onQam).takeIf { SessionState.mode == SessionService.MODE_STEAM }), weightParams())
+ }
SecondScreenMode.NONE -> Unit
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
index fad54cf81..1a20d0cb6 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material.icons.outlined.Dashboard
+import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.material.icons.outlined.AutoFixHigh
import androidx.compose.material.icons.outlined.ExpandMore
@@ -244,6 +245,7 @@ class DrawerActions(
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
val secondScreenMode: SecondScreenMode,
val dashboardAvailable: Boolean = false,
+ val desktopAvailable: Boolean = false,
val secondScreenDisplays: List,
val selectedSecondScreenDisplay: Int,
val onHud: (Boolean) -> Unit,
@@ -901,6 +903,7 @@ private fun BottomScreenModes(a: DrawerActions, modifier: Modifier, onPick: () -
add(SecondScreenMode.KEYBOARD_TRACKPAD to Icons.Outlined.Keyboard)
add(SecondScreenMode.TERMINAL to Icons.Outlined.Terminal)
if (a.dashboardAvailable) add(SecondScreenMode.DASHBOARD to Icons.Outlined.Dashboard)
+ if (a.desktopAvailable) add(SecondScreenMode.DESKTOP to Icons.Outlined.DesktopWindows)
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1199,7 +1202,8 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
val pal = LocalPalette.current
val modes = listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()) +
- (if (a.dashboardAvailable) listOf(SecondScreenMode.DASHBOARD) else emptyList())
+ (if (a.dashboardAvailable) listOf(SecondScreenMode.DASHBOARD) else emptyList()) +
+ (if (a.desktopAvailable) listOf(SecondScreenMode.DESKTOP) else emptyList())
SettingsGroup(stringResource(R.string.drawer_second_screen_mode)) {
Column(Modifier.fillMaxWidth().selectableGroup()) {
modes.forEach { mode ->
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt b/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt
new file mode 100644
index 000000000..59c182dd3
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/BottomDesktopCompositorService.kt
@@ -0,0 +1,117 @@
+package com.droiddeck.launcher.wayland
+
+import android.app.Service
+import android.content.Intent
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.util.Log
+import android.view.Surface
+import com.droiddeck.launcher.core.FileUtils
+import java.io.File
+
+/**
+ * The bottom-screen desktop's compositor. The compositor's native state is one per process, so the
+ * desktop gets a process of its own while the session's compositor keeps the top screen.
+ */
+class BottomDesktopCompositorService : Service() {
+ private var client: Messenger? = null
+ /** Echoed in every reply, so the session process can tell replies to an earlier attach apart. */
+ @Volatile private var attachTag = 0
+
+ private val handler = object : Handler(Looper.getMainLooper()) {
+ override fun handleMessage(msg: Message) {
+ when (msg.what) {
+ MSG_ATTACH -> attach(msg)
+ MSG_DETACH -> CompositorHost.detach(null)
+ MSG_TOUCH -> WaylandCompositor.nativeSendTouch(msg.arg1 and 0xff, msg.arg1 shr 8,
+ msg.arg2 ushr 16, msg.arg2 and 0xffff)
+ MSG_KEY -> WaylandCompositor.nativeSendKey(msg.arg1, msg.arg2)
+ MSG_POINTER -> WaylandCompositor.nativeSendPointer(1, msg.arg1, msg.arg2)
+ MSG_SCENE -> WaylandCompositor.nativeSendSceneInput(msg.arg1, msg.arg2, msg.peekData()?.getInt(KEY_B) ?: 0)
+ MSG_CURSOR -> if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(msg.arg1 != 0)
+ MSG_NEW_GUEST -> WaylandCompositor.nativeResetFirstFrame()
+ MSG_TEXT -> msg.data?.getString(KEY_TEXT)?.let { WaylandCompositor.textInputCommit(it) }
+ MSG_STOP -> {
+ Log.i(TAG, "bottom-screen desktop closed; ending its compositor process")
+ // A producer that dies still connected keeps the panel's surface from taking another one.
+ CompositorHost.detach(null)
+ postDelayed({ android.os.Process.killProcess(android.os.Process.myPid()) }, 300)
+ }
+ else -> super.handleMessage(msg)
+ }
+ }
+ }
+
+ private val messenger = Messenger(handler)
+
+ override fun onBind(intent: Intent?): IBinder = messenger.binder
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ // The session process went away without saying so; nothing is left to draw for.
+ android.os.Process.killProcess(android.os.Process.myPid())
+ return false
+ }
+
+ private fun attach(msg: Message) {
+ val surface = msg.obj as? Surface ?: return
+ val data = msg.data ?: return
+ client = msg.replyTo
+ attachTag = msg.arg1
+ val runtimeDir = File(data.getString(KEY_RUNTIME_DIR) ?: return).apply { mkdirs() }
+ val width = data.getInt(KEY_WIDTH)
+ val height = data.getInt(KEY_HEIGHT)
+ if (!CompositorHost.isStarted) {
+ FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
+ data.getString(KEY_LOG)?.let { path ->
+ WaylandCompositor.setSessionLogFile(File(path))
+ runCatching { android.system.Os.setenv("BL_WAYLAND_LOG", path, true) }
+ .onFailure { Log.w(TAG, "could not point the compositor's log at $path", it) }
+ }
+ WaylandCompositor.setFirstFrameListener { reply(MSG_FIRST_FRAME) }
+ WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
+ }
+ val turnip = com.droiddeck.launcher.gpu.TurnipDriver(this)
+ val driverId = if (CompositorHost.isStarted) null else turnip.install()
+ val started = CompositorHost.startOrAttach(
+ this, surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
+ applicationInfo.nativeLibraryDir,
+ width, height, data.getFloat(KEY_REFRESH, 60f), 0,
+ )
+ Log.i(TAG, (if (started) "compositor started" else "compositor attached") + " at ${width}x$height in ${runtimeDir.path}")
+ reply(MSG_ATTACHED)
+ }
+
+ private fun reply(what: Int) {
+ val target = client ?: return
+ runCatching { target.send(Message.obtain(null, what, attachTag, 0)) }
+ .onFailure { Log.w(TAG, "the session process did not take message $what", it) }
+ }
+
+ companion object {
+ private const val TAG = "BottomDesktopComp"
+ const val MSG_ATTACH = 1
+ const val MSG_DETACH = 2
+ const val MSG_TOUCH = 3
+ const val MSG_KEY = 4
+ const val MSG_TEXT = 5
+ const val MSG_STOP = 6
+ const val MSG_NEW_GUEST = 7
+ const val MSG_POINTER = 8
+ const val MSG_SCENE = 9
+ const val MSG_CURSOR = 10
+ const val MSG_ATTACHED = 101
+ const val MSG_FIRST_FRAME = 102
+ const val KEY_RUNTIME_DIR = "runtimeDir"
+ const val KEY_WIDTH = "width"
+ const val KEY_HEIGHT = "height"
+ const val KEY_REFRESH = "refresh"
+ const val KEY_LOG = "log"
+ const val KEY_TEXT = "text"
+ const val KEY_B = "b"
+ private const val SCALE_FIT = 1
+ private const val ALIGN_CENTER = 0
+ }
+}
diff --git a/app/src/main/res/drawable/ic_bottom_keyboard.xml b/app/src/main/res/drawable/ic_bottom_keyboard.xml
new file mode 100644
index 000000000..ff8aa7500
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_keyboard.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_mouse.xml b/app/src/main/res/drawable/ic_bottom_mouse.xml
new file mode 100644
index 000000000..7a5584985
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_mouse.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_touch.xml b/app/src/main/res/drawable/ic_bottom_touch.xml
new file mode 100644
index 000000000..ff90202a8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_touch.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d0e4aaa15..ed95133b9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1881,6 +1881,17 @@
Terminal
Deck grips + trackpads
Dashboard
+ Desktop
+ Start the desktop
+ Desktop · %1$s
+ Trackpad
+ Direct touch
+ Trackpad. Tap for direct touch
+ Direct touch. Tap for trackpad
+ Starting the KDE desktop…
+ The desktop closed.
+ The desktop couldn\'t start. Its logs are in this session\'s bottom-desktop folder.
+ The desktop is closed.
Nothing to show here yet.
Downloads
No downloads running.
diff --git a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt
index 8a66101f1..f1ed57778 100644
--- a/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/input/SecondScreenModeTest.kt
@@ -38,4 +38,15 @@ class SecondScreenModeTest {
assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("dashboard", deckPad = true, dashboard = false))
assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("dashboard", deckPad = false))
}
+
+ @Test fun theDesktopOpensAgainOnlyWhileItCanRun() {
+ assertEquals(SecondScreenMode.DESKTOP, SecondScreenMode.opening("desktop", deckPad = true, dashboard = true, desktop = true))
+ assertEquals(SecondScreenMode.DECK_CONTROLS, SecondScreenMode.opening("desktop", deckPad = true, dashboard = true))
+ assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening("desktop", deckPad = false))
+ }
+
+ @Test fun withNothingRememberedTheDesktopIsNeverPicked() {
+ assertEquals(SecondScreenMode.DASHBOARD, SecondScreenMode.opening(null, deckPad = false, dashboard = true, desktop = true))
+ assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.opening(null, deckPad = false, desktop = true))
+ }
}
diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop
index 30e95c70e..f10cff247 100644
--- a/tools/linuxfs/desktop/droiddeck-desktop
+++ b/tools/linuxfs/desktop/droiddeck-desktop
@@ -119,7 +119,7 @@ echo "== droiddeck-desktop: Plasma on $WAYLAND_DISPLAY"
dbus-run-session -- startplasma-wayland
rc=$?
echo "== droiddeck-desktop: Plasma ended ($rc)"
-if [ "$rc" -eq 0 ]; then
+if [ "$rc" -eq 0 ] && [ -z "${BL_BOTTOM_DESKTOP:-}" ]; then
/usr/local/bin/droiddeck-steam-launch bigpicture
sleep 30
fi
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop
new file mode 100755
index 000000000..2c19c23be
--- /dev/null
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bottom-desktop
@@ -0,0 +1,51 @@
+#!/bin/bash
+# KDE Plasma on a dual-screen handheld's bottom screen, next to the session on the top one.
+set -u
+export HOME=${HOME:-/root}
+if [ -n "${BL_DEBUG_DIR:-}" ]; then
+ mkdir -p "$BL_DEBUG_DIR" 2>/dev/null || true
+ exec >>"$BL_DEBUG_DIR/bottom-desktop.log" 2>&1
+fi
+echo "== droiddeck-bottom-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') ${BL_WIDTH:-?}x${BL_HEIGHT:-?} beside a ${BL_TOP_SESSION:-?} session"
+[ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; }
+
+# Launch requests would reach the top session's watcher and could replace it.
+unset BL_LAUNCH_DIR BL_LOG
+export BL_BOTTOM_DESKTOP=1
+
+for _ in $(seq 1 100); do
+ wayland-info >/dev/null 2>&1 && break
+ sleep 0.1
+done
+
+if command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1 \
+ && ! pgrep -f 'dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf' >/dev/null 2>&1; then
+ mkdir -p /run/dbus /usr/local/share/droiddeck
+ cat > /usr/local/share/droiddeck/system-bus.conf <<'BUSCONF'
+
+
+ system
+ unix:path=/run/dbus/system_bus_socket
+ EXTERNAL
+ /usr/share/droiddeck/system-services
+
+
+
+
+
+
+
+BUSCONF
+ rm -f /run/dbus/system_bus_socket
+ dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork --nopidfile &
+ for _ in $(seq 1 50); do
+ [ -S /run/dbus/system_bus_socket ] && break
+ sleep 0.1
+ done
+ [ -S /run/dbus/system_bus_socket ] && droiddeck-login1 --with-netmanager &
+fi
+
+# The game on the top screen keeps the CPU first.
+renice -n 5 $$ >/dev/null 2>&1 || true
+exec /usr/local/bin/droiddeck-desktop
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime b/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime
index c96aa283b..4f110cfbc 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-ime
@@ -85,7 +85,7 @@ def connect():
def main():
launch = os.environ.get("BL_LAUNCH_DIR", "")
- report = Report(os.path.join(launch, "agent") if launch else "")
+ report = Report(os.environ.get("BL_IME_DIR") or (os.path.join(launch, "agent") if launch else ""))
conn = Connection(connect())
conn.send(DISPLAY_ID, 1, struct.pack("/dev/null || true)
+ if [ -p "$client_home/.steam/steam.pipe" ] && [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
+ # The top screen's client takes the request, so games open there.
+ exec "${client[@]}" -nobigpicture -no-cef-sandbox \
+ -cef-ozone-platform=x11 -cef-disable-gpu "${url:-steam://open/library}"
+ fi
+ fi
mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/droiddeck"
exec 9>"${XDG_CACHE_HOME:-$HOME/.cache}/droiddeck/steam-desktop.lock"
if ! flock -n 9; then
diff --git a/tools/tests/test_steam_desktop_launch.py b/tools/tests/test_steam_desktop_launch.py
index 4100b3e79..37e1a3ed8 100644
--- a/tools/tests/test_steam_desktop_launch.py
+++ b/tools/tests/test_steam_desktop_launch.py
@@ -92,6 +92,30 @@ class DesktopSteamLaunchTest(unittest.TestCase):
'-cef-disable-gpu', 'steam://open/settings'])
self.assertFalse((self.session / 'steam-launch').exists())
+ def _running_client(self):
+ steam = self.root / 'data/Steam/steamrtarm64/steam'
+ steam.parent.mkdir(parents=True)
+ steam.write_text('#!/bin/bash\nprintf "%s\\n" "$@"\n')
+ steam.chmod(0o755)
+ (self.root / '.steam').mkdir()
+ os.mkfifo(self.root / '.steam/steam.pipe')
+ (self.root / '.steam/steam.pid').write_text(str(os.getpid()))
+
+ def test_bottom_screen_desktop_hands_games_to_the_running_client(self):
+ self._running_client()
+ self.env['BL_BOTTOM_DESKTOP'] = '1'
+ result = self.launch('desktop', '501590')
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.splitlines(), ['-nobigpicture', '-no-cef-sandbox', '-cef-ozone-platform=x11',
+ '-cef-disable-gpu', 'steam://rungameid/501590'])
+ self.assertFalse((self.root / 'cache/droiddeck/steam-desktop.lock').exists())
+
+ def test_bottom_screen_desktop_starts_its_own_client_when_none_runs(self):
+ self.env.update(BL_BOTTOM_DESKTOP='1', DISPLAY=':7')
+ result = self.launch('desktop')
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.splitlines(), ['1', 'desktop', ':7', 'steam', 'steam://open/library'])
+
def test_second_click_forwards_to_the_x86_64_client_in_its_own_home(self):
lock = self.root / 'cache/droiddeck/steam-desktop.lock'
lock.parent.mkdir(parents=True)
From 4798fadc123fb5864e5013915ec8d490f2413c52 Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 16:18:04 -0500
Subject: [PATCH 09/17] docs: update README tagline (#1977)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index fde79f4e1..9b3f3c443 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
+DroidDeck brings the SteamOS experience to Android: Steam Gaming Mode, full Linux desktop, integration with GOG, Epic Games, Xbox, and other storefronts, Steam VR, and more!
100% open source. No ads. No telemetry. No source withheld.
From d730f0fa8b717145cce1dbb6b888631fb26eaddf Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Sun, 1 Nov 2026 16:20:35 -0500
Subject: [PATCH 10/17] fix: docked HUD tab follows the HUD outline setting
(#1976)
---
.../main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
index 0d0b7b816..919e6ea92 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
@@ -654,7 +654,7 @@ class DroidDeckHudView(context: Context) : View(context) {
Pair(runWidth(tabSpans()) + 2f * sp(9f), capHeight(sp(20f)) + 2f * sp(7f))
private fun drawTab(canvas: Canvas, rect: RectF, edgeOf: DroidDeckHudDock = dock) {
- val r = sp(8f); val stroke = max(outlineWidth(), sp(1.5f)); val h = stroke / 2f
+ val r = sp(8f); val stroke = outlineWidth(); val h = stroke / 2f
val round = floatArrayOf(r, r, r, r, r, r, r, r)
val edge = when (edgeOf) {
DroidDeckHudDock.LEFT -> intArrayOf(0, 3)
@@ -669,6 +669,7 @@ class DroidDeckHudView(context: Context) : View(context) {
headerPaint.shader = null
headerPaint.color = withAlpha(panelColor(), max(Color.alpha(panelColor()), 200))
canvas.drawPath(tabPath, headerPaint)
+ if (stroke <= 0f) return
strokePaint.strokeWidth = stroke
strokePaint.color = outlineColor()
canvas.drawPath(tabPath, strokePaint)
From c5577478caab6af7efc9fa806a706ef49ad6ca2a Mon Sep 17 00:00:00 2001
From: MaxsTechReview
Date: Sun, 1 Nov 2026 16:27:27 -0500
Subject: [PATCH 11/17] chore: remove source comments and unused code,
translate every missing string (#1965)
* chore(i18n): translate every missing string in all locales
Adds the 87 strings the bottom-screen Dashboard and in-game stats pages
introduced to all 32 locales, plus the 51 older settings strings Japanese
and the 50 Chinese locales were still missing. Every locale now has all
3456 translatable strings with the same placeholders as English.
Also translates 36 entries that were still English where the language
does not use the English word, and makes es-419 touch_app_id use the same
%1$s placeholder as English.
* chore(patches): drop comments from the lines downstream patches add
Removes comment lines and trailing comments from lines our patches add,
with hunk headers recounted. Lines that a later patch uses as context
and the upstream backport 0123 are left as they are.
The gamescope, FEX, proot and xodus series still apply to their pinned
sources, and the patched trees differ only in those comments.
test_hud_frames now finds the HUD writer by its first declaration.
* docs: credit Greenlight, Better xCloud, viture-v2, GameNative and DiRTTelemetryErrorFix
These were credited only in source headers and NOTICE files.
* chore: remove source comments and unused functions
Removes explanatory comments from first-party Kotlin, Java, C, C++,
Rust, TypeScript, Python, shell, Gradle, CMake and workflow files.
License and attribution headers stay, and vendored third-party trees,
generated files and license files are untouched. A shellcheck directive
is kept, and udevmon's fallthrough comments became fallthrough attributes.
Syntax trees and token streams match the previous sources apart from the
comments and Python docstrings. Module docstrings that argparse prints
are kept, and device-smoke.sh prints the same --help text from a heredoc.
Also removes 37 functions and 4 fields that nothing referenced.
* chore: remove comments from the Syncthing integration and the latest main sources
* docs: credit Syncthing and the syncthing-android fork
* chore(i18n): translate the Syncthing strings in all locales
The 107 strings the Syncthing integration added are now in all 32 locales.
sync_web_path_locked names the Syncthing tab by its label.
* docs: credit @xXJSONDeruloXx for the HUD metric readers
The CPU, GPU load, thermal and battery readers come from their
PerformanceHudView pull requests #777, #877, #991, #1406 and #1573.
The FusionHUD credit line is unchanged.
* docs: credit the HUD metric readers to @xXJSONDeruloXx's work
---
.gitattributes | 1 -
.github/trusted-test-builds.txt | 1 -
.github/workflows/build-fex.yml | 4 -
.github/workflows/publish-packs.yml | 6 -
README.md | 5 +
app/build.gradle | 9 -
app/src/main/cpp/CMakeLists.txt | 2 -
app/src/main/cpp/fakeinput_steam.cpp | 7 -
.../main/cpp/framegen/ddfg/record_impl.inc | 2 -
app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp | 3 +-
app/src/main/cpp/framegen/lsfg/lsfg_dll.h | 3 +-
app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h | 1 -
app/src/main/cpp/questxr/gpu_frames.h | 2 -
app/src/main/cpp/venc/venc_proto.h | 5 -
app/src/main/cpp/venc/venc_server.cpp | 6 +-
app/src/main/cpp/viture/display_modes.h | 24 +-
app/src/main/cpp/viture/mjpeg_payload.h | 1 -
app/src/main/cpp/viture/sdk_bridge.cpp | 4 -
app/src/main/cpp/waylandcomp/src/cardboard.c | 1 -
.../main/cpp/waylandcomp/src/cardboard_frag.h | 1 -
app/src/main/cpp/waylandcomp/src/compositor.c | 65 +--
.../cpp/waylandcomp/src/framegen_bridge.c | 5 -
.../main/cpp/waylandcomp/src/gpu_clock_pin.c | 2 -
.../main/cpp/waylandcomp/src/headpose_shm.h | 1 -
app/src/main/cpp/waylandcomp/src/hudframes.c | 2 -
app/src/main/cpp/waylandcomp/src/hudframes.h | 2 -
.../main/cpp/waylandcomp/src/quest_device.h | 1 -
app/src/main/cpp/waylandcomp/src/sc_layer.c | 4 +-
app/src/main/cpp/waylandcomp/src/vk_present.c | 15 -
.../cpp/waylandcomp/src/waylandcomp_jni.c | 2 -
.../main/java/com/droiddeck/launcher/App.kt | 3 -
.../droiddeck/launcher/FrontEndActivity.kt | 3 -
.../java/com/droiddeck/launcher/HomePins.kt | 1 -
.../com/droiddeck/launcher/HomeWidgets.kt | 1 -
.../java/com/droiddeck/launcher/ProtonMenu.kt | 1 -
.../com/droiddeck/launcher/QuestXrActivity.kt | 1 -
.../com/droiddeck/launcher/SessionActivity.kt | 48 --
.../audio/DirectAudioRelayComponent.java | 1 -
.../com/droiddeck/launcher/core/AddonPack.kt | 11 -
.../droiddeck/launcher/core/DeviceReport.kt | 1 -
.../droiddeck/launcher/core/DownloadSource.kt | 3 -
.../droiddeck/launcher/core/Downloader.java | 16 +-
.../droiddeck/launcher/core/GlobalSettings.kt | 2 -
.../droiddeck/launcher/core/LogRedactor.kt | 5 -
.../launcher/core/SessionLogCapture.kt | 2 -
.../launcher/core/SteamIntegration.kt | 3 -
.../launcher/files/CollapsibleRail.kt | 84 ----
.../com/droiddeck/launcher/files/FileOps.kt | 1 -
.../droiddeck/launcher/files/StorageRoots.kt | 7 -
.../droiddeck/launcher/frontend/AddedGames.kt | 2 -
.../launcher/frontend/DependencyDetector.kt | 15 -
.../droiddeck/launcher/frontend/Library.kt | 4 -
.../launcher/frontend/LibraryCache.kt | 2 -
.../launcher/frontend/LogoPosition.kt | 4 -
.../droiddeck/launcher/frontend/SteamGrid.kt | 14 -
.../launcher/frontend/SteamRedists.kt | 2 -
.../launcher/frontend/WinCompSources.kt | 2 -
.../com/droiddeck/launcher/glasses/Glasses.kt | 6 -
.../launcher/glasses/GlassesHardware.kt | 4 -
.../launcher/glasses/GlassesMenuSurface.kt | 1 -
.../launcher/glasses/GlassesRendering.kt | 5 -
.../launcher/glasses/GlassesScreen.kt | 1 -
.../launcher/glasses/HandImageBuffer.kt | 1 -
.../launcher/glasses/LumaRgbDiagnostic.kt | 1 -
.../launcher/glasses/RayNeoCalibration.kt | 4 -
.../droiddeck/launcher/glasses/RayNeoLink.kt | 4 -
.../droiddeck/launcher/glasses/RayNeoModel.kt | 6 +-
.../glasses/RayNeoOrientationFilter.kt | 10 +-
.../launcher/glasses/RayNeoProtocol.kt | 6 +-
.../droiddeck/launcher/glasses/RokidLink.kt | 5 +-
.../droiddeck/launcher/glasses/RokidModel.kt | 7 +-
.../launcher/glasses/StereoCalibration.kt | 20 +-
.../launcher/glasses/StereoHandEngine.kt | 4 +-
.../launcher/glasses/StereoInferenceRunner.kt | 1 -
.../launcher/glasses/UvcRgbDiagnostic.kt | 2 -
.../launcher/glasses/VitureProtocol.kt | 1 -
.../droiddeck/launcher/glasses/VitureSdk.kt | 5 -
.../launcher/glasses/VitureSdkLink.kt | 4 -
.../launcher/glasses/VitureV2Link.kt | 3 -
.../launcher/glasses/VitureV2Protocol.kt | 5 +-
.../launcher/glasses/XrealConnection.kt | 1 -
.../droiddeck/launcher/glasses/XrealLink.kt | 13 +-
.../droiddeck/launcher/glasses/XrealModel.kt | 4 +-
.../glasses/XrealOrientationFilter.kt | 8 +-
.../launcher/glasses/XrealProtocol.kt | 4 +-
.../droiddeck/launcher/gpu/AutoDriverTrial.kt | 4 -
.../com/droiddeck/launcher/gpu/FrameGen.kt | 1 -
.../launcher/gpu/LinuxVulkanDriver.java | 8 -
.../launcher/gpu/SteamVrDeviceLossLog.kt | 2 -
.../droiddeck/launcher/gpu/SteamVrRecovery.kt | 3 -
.../launcher/gpu/SteamVrRecoverySettings.kt | 2 -
.../launcher/gpu/SteamVrRestartActivity.kt | 2 -
.../com/droiddeck/launcher/gpu/TpUbwcHint.kt | 3 -
.../launcher/handtracking/TrackingPack.kt | 6 -
.../launcher/hud/DroidDeckHudConfig.kt | 6 -
.../launcher/hud/DroidDeckHudHost.kt | 57 ---
.../launcher/hud/DroidDeckHudModels.kt | 41 +-
.../launcher/hud/DroidDeckHudView.kt | 227 +--------
.../droiddeck/launcher/hud/DroidDeckMark.kt | 2 -
.../com/droiddeck/launcher/hud/GameFrames.kt | 26 +-
.../com/droiddeck/launcher/hud/HudFrames.kt | 2 -
.../launcher/hud/HudLockController.java | 61 +--
.../droiddeck/launcher/hud/HudMetrics.java | 444 +++---------------
.../droiddeck/launcher/hud/KeyValueSet.java | 17 +-
.../droiddeck/launcher/hud/MangoReadings.kt | 13 -
.../launcher/input/FakeInputWriter.java | 1 -
.../droiddeck/launcher/input/GesturePrefs.kt | 1 -
.../launcher/input/GestureTouchStreamGate.kt | 1 -
.../launcher/input/HomeButtonService.kt | 1 -
.../launcher/input/HomeGuideInput.kt | 1 -
.../launcher/input/OnScreenControls.kt | 1 -
.../droiddeck/launcher/input/PadBridge.java | 6 -
.../com/droiddeck/launcher/input/ScreenKey.kt | 11 -
.../droiddeck/launcher/input/ScreenKeys.kt | 1 -
.../launcher/input/SecondScreenMode.kt | 1 -
.../launcher/input/SteamTouchControls.kt | 26 -
.../launcher/input/SteamTouchDevice.kt | 2 -
.../launcher/input/SteamVolumeKeys.kt | 1 -
.../launcher/input/TouchscreenGestures.java | 126 +----
.../droiddeck/launcher/input/VrControllers.kt | 2 -
.../launcher/runtime/DeckyManager.kt | 2 -
.../launcher/runtime/DesktopCatalog.kt | 3 -
.../runtime/DeviceControlsComponent.kt | 1 -
.../launcher/runtime/GuestCommand.kt | 4 -
.../launcher/runtime/LinuxRuntime.java | 10 -
.../runtime/LinuxRuntimeInstaller.java | 9 -
.../launcher/runtime/ProotFastPath.kt | 4 -
.../launcher/runtime/RuntimeFileTree.java | 13 -
.../launcher/runtime/RuntimeHealth.kt | 1 -
.../launcher/runtime/RuntimeInstallService.kt | 1 -
.../launcher/session/BatteryComponent.kt | 4 -
.../droiddeck/launcher/session/BootMovie.kt | 20 -
.../launcher/session/BootMovieLibrary.kt | 44 --
.../launcher/session/BootMovieReport.kt | 6 -
.../launcher/session/BootMovieTranscoder.kt | 5 -
.../launcher/session/BottomNotifications.kt | 5 -
.../launcher/session/BottomScreenshots.kt | 4 -
.../launcher/session/ComponentsManager.kt | 4 -
.../droiddeck/launcher/session/FexCatalog.kt | 7 -
.../droiddeck/launcher/session/GameStorage.kt | 1 -
.../launcher/session/GameTelemetry.kt | 23 -
.../launcher/session/GameThreadHints.kt | 51 +-
.../launcher/session/GpuStatsComponent.kt | 3 -
.../droiddeck/launcher/session/OfflineMode.kt | 1 -
.../launcher/session/RumbleComponent.kt | 3 -
.../launcher/session/SessionArtifacts.kt | 2 -
.../launcher/session/SessionFiles.kt | 35 --
.../launcher/session/SessionLogShare.kt | 1 -
.../launcher/session/SessionPaths.kt | 1 -
.../launcher/session/SessionPrefs.kt | 40 --
.../launcher/session/SessionService.kt | 20 +-
.../launcher/session/SessionState.kt | 4 -
.../session/SessionSuspendController.kt | 26 -
.../droiddeck/launcher/session/StatsLayout.kt | 7 -
.../launcher/session/SteamDeckRepo.kt | 14 -
.../launcher/session/SteamDownloads.kt | 4 -
.../launcher/session/SteamWebLogin.kt | 11 -
.../launcher/session/TelemetryOutput.kt | 11 -
.../launcher/session/VacDiagnostics.kt | 13 -
.../launcher/session/VideoEncodeBridge.kt | 1 -
.../launcher/session/WinComponents.kt | 6 -
.../launcher/storage/SteamStorage.kt | 5 -
.../droiddeck/launcher/storage/StorageScan.kt | 3 -
.../launcher/storage/StorageState.kt | 3 -
.../launcher/stores/CloudSavePaths.kt | 4 -
.../droiddeck/launcher/stores/CloudSaves.kt | 14 -
.../launcher/stores/CloudTransport.kt | 8 -
.../launcher/stores/CredentialCipher.kt | 4 -
.../launcher/stores/SteamLiveShortcuts.kt | 1 -
.../launcher/stores/StoreAccounts.kt | 1 -
.../com/droiddeck/launcher/stores/StoreArt.kt | 1 -
.../droiddeck/launcher/stores/StoreCatalog.kt | 7 -
.../launcher/stores/StoreInstallRoot.kt | 10 -
.../launcher/stores/StoreInstalls.kt | 1 -
.../launcher/stores/StoreLanguage.kt | 24 -
.../launcher/stores/StoreLogFiles.kt | 1 -
.../launcher/stores/StoreLoginActivity.kt | 5 -
.../droiddeck/launcher/stores/StoresState.kt | 6 -
.../launcher/stores/download/DownloadQueue.kt | 2 -
.../launcher/stores/epic/EpicBackend.kt | 1 -
.../droiddeck/launcher/stores/gog/GogAuth.kt | 2 -
.../launcher/stores/gog/GogBackend.kt | 4 -
.../stores/gog/GogDownloadManager.java | 14 -
.../launcher/stores/gog/GogLibrary.kt | 17 -
.../launcher/stores/itch/ItchInstaller.kt | 5 -
.../launcher/stores/xbox/XboxCatalog.kt | 3 -
.../launcher/stores/xbox/XboxCloudActivity.kt | 8 -
.../launcher/stores/xbox/XboxCloudGames.kt | 8 -
.../stores/xbox/XboxCloudPlayerActivity.kt | 7 -
.../launcher/stores/xbox/XboxCloudSession.kt | 6 -
.../launcher/stores/xbox/XboxCloudSettings.kt | 40 +-
.../stores/xbox/XboxCloudSettingsPanel.kt | 15 -
.../stores/xbox/XboxCloudSignalling.kt | 2 -
.../stores/xbox/XboxCloudTokenService.kt | 5 -
.../launcher/stores/xbox/XboxCredentials.kt | 5 -
.../launcher/stores/xbox/XboxGamertag.kt | 4 -
.../launcher/stores/xbox/XboxLoginActivity.kt | 5 -
.../launcher/stores/xbox/XboxNativePad.kt | 11 -
.../stores/xbox/XboxNativePlayerActivity.kt | 78 ---
.../stores/xbox/XboxNativeProtocol.kt | 24 +-
.../launcher/stores/xbox/XboxNativeVideo.kt | 22 -
.../droiddeck/launcher/stores/xbox/Xodus.kt | 1 -
.../com/droiddeck/launcher/sync/SyncPrefs.kt | 1 -
.../com/droiddeck/launcher/sync/Syncthing.kt | 11 -
.../droiddeck/launcher/sync/SyncthingApi.kt | 1 -
.../launcher/sync/SyncthingConfig.kt | 1 -
.../droiddeck/launcher/sync/SyncthingGuard.kt | 2 -
.../droiddeck/launcher/sync/SyncthingHttp.kt | 2 -
.../droiddeck/launcher/sync/SyncthingPorts.kt | 1 -
.../droiddeck/launcher/sync/SyncthingProxy.kt | 7 -
.../launcher/sync/SyncthingService.kt | 1 -
.../launcher/sync/SyncthingStatus.kt | 2 -
.../launcher/sync/SyncthingWebActivity.kt | 3 -
.../droiddeck/launcher/ui/AddedGameEditor.kt | 2 -
.../droiddeck/launcher/ui/BootMovieSection.kt | 13 -
.../droiddeck/launcher/ui/BottomChatPage.kt | 17 -
.../droiddeck/launcher/ui/BottomDashboard.kt | 7 -
.../launcher/ui/BottomDownloadsPage.kt | 1 -
.../launcher/ui/BottomNotificationsPage.kt | 1 -
.../com/droiddeck/launcher/ui/BottomPages.kt | 4 -
.../launcher/ui/BottomTelemetryPage.kt | 4 -
.../droiddeck/launcher/ui/BuildWatermark.kt | 1 -
.../droiddeck/launcher/ui/CardDetailHost.kt | 2 -
.../launcher/ui/ColorPickerDialog.kt | 3 -
.../droiddeck/launcher/ui/ComponentsPage.kt | 2 -
.../launcher/ui/DeckControlsPanel.kt | 3 -
.../launcher/ui/DroidDeckHudSettings.kt | 5 -
.../droiddeck/launcher/ui/DualScreenPanel.kt | 3 -
.../com/droiddeck/launcher/ui/FocusGlide.kt | 1 -
.../droiddeck/launcher/ui/FrontEndGames.kt | 1 -
.../droiddeck/launcher/ui/FrontEndScreen.kt | 9 -
.../droiddeck/launcher/ui/FrontEndSetup.kt | 3 -
.../droiddeck/launcher/ui/FrontEndWidgets.kt | 20 -
.../com/droiddeck/launcher/ui/GameDetail.kt | 9 -
.../com/droiddeck/launcher/ui/GameGlass.kt | 12 -
.../droiddeck/launcher/ui/GestureActions.kt | 1 -
.../launcher/ui/GlassesPhonePanel.kt | 1 -
.../droiddeck/launcher/ui/GpuDriversPanel.kt | 1 -
.../com/droiddeck/launcher/ui/LibraryPage.kt | 4 -
.../launcher/ui/LogoPositionDialog.kt | 7 -
.../launcher/ui/ModeSettingsDialog.kt | 5 -
.../com/droiddeck/launcher/ui/PageSwipe.kt | 1 -
.../launcher/ui/PhysicalShortcutsDialog.kt | 1 -
.../droiddeck/launcher/ui/SessionOverlay.kt | 1 -
.../launcher/ui/SessionPausedOverlay.kt | 2 -
.../com/droiddeck/launcher/ui/StatsDashes.kt | 5 -
.../com/droiddeck/launcher/ui/StatsStrips.kt | 8 -
.../com/droiddeck/launcher/ui/StoragePage.kt | 1 -
.../launcher/ui/StoreAchievementsUi.kt | 16 -
.../droiddeck/launcher/ui/StoreGameFacts.kt | 2 -
.../launcher/ui/StoreGridNavigation.kt | 1 -
.../com/droiddeck/launcher/ui/StoresPage.kt | 18 -
.../droiddeck/launcher/ui/SyncFolderPicker.kt | 2 -
.../com/droiddeck/launcher/ui/SyncPanel.kt | 1 -
.../java/com/droiddeck/launcher/ui/Theme.kt | 8 -
.../com/droiddeck/launcher/ui/ThemeColors.kt | 7 -
.../launcher/ui/TouchGestureDialog.kt | 2 -
.../droiddeck/launcher/update/AppUpdates.kt | 4 -
.../launcher/update/SelfInstaller.kt | 3 -
.../launcher/wayland/CompositorHost.kt | 2 -
.../launcher/wayland/WaylandCompositor.java | 5 +-
.../DroidDeckLowLatencyDecoderFactory.java | 31 --
app/src/main/res/values-ar/strings.xml | 194 ++++++++
app/src/main/res/values-b+es+419/strings.xml | 196 +++++++-
app/src/main/res/values-bg/gestures.xml | 2 +-
app/src/main/res/values-bg/strings.xml | 196 +++++++-
app/src/main/res/values-cs/gestures.xml | 2 +-
app/src/main/res/values-cs/strings.xml | 196 +++++++-
app/src/main/res/values-da/gestures.xml | 2 +-
app/src/main/res/values-da/strings.xml | 198 +++++++-
app/src/main/res/values-de/strings.xml | 194 ++++++++
app/src/main/res/values-el/strings.xml | 196 +++++++-
app/src/main/res/values-es/strings.xml | 194 ++++++++
app/src/main/res/values-fi/strings.xml | 194 ++++++++
app/src/main/res/values-fr/strings.xml | 194 ++++++++
app/src/main/res/values-hi/strings.xml | 204 +++++++-
app/src/main/res/values-hu/strings.xml | 196 +++++++-
app/src/main/res/values-in/strings.xml | 196 +++++++-
app/src/main/res/values-it/strings.xml | 194 ++++++++
app/src/main/res/values-ja/strings.xml | 249 ++++++++++
app/src/main/res/values-ko/strings.xml | 194 ++++++++
app/src/main/res/values-ms/strings.xml | 194 ++++++++
app/src/main/res/values-nb/strings.xml | 196 +++++++-
app/src/main/res/values-nl/strings.xml | 196 +++++++-
app/src/main/res/values-pl/strings.xml | 194 ++++++++
app/src/main/res/values-pt-rPT/strings.xml | 194 ++++++++
app/src/main/res/values-pt/strings.xml | 194 ++++++++
app/src/main/res/values-ro/gestures.xml | 2 +-
app/src/main/res/values-ro/strings.xml | 198 +++++++-
app/src/main/res/values-ru/strings.xml | 194 ++++++++
app/src/main/res/values-sv/gestures.xml | 2 +-
app/src/main/res/values-sv/strings.xml | 204 +++++++-
app/src/main/res/values-th/strings.xml | 208 +++++++-
app/src/main/res/values-tr/strings.xml | 194 ++++++++
app/src/main/res/values-uk/strings.xml | 194 ++++++++
app/src/main/res/values-vi/strings.xml | 194 ++++++++
app/src/main/res/values-zh-rCN/strings.xml | 248 ++++++++++
app/src/main/res/values-zh-rHK/strings.xml | 250 +++++++++-
app/src/main/res/values-zh-rTW/gestures.xml | 2 +-
app/src/main/res/values-zh-rTW/strings.xml | 250 +++++++++-
app/src/main/res/values/strings.xml | 2 +-
app/src/main/rust/stores/src/fetch_core.rs | 5 -
app/src/main/rust/stores/src/fetch_tuning.rs | 2 -
.../test/cpp/viture_display_modes_test.cpp | 1 -
.../launcher/core/DownloaderResumeTest.kt | 10 -
.../launcher/glasses/GlassesDisplayTest.kt | 2 -
.../glasses/GlassesMenuProjectionTest.kt | 1 -
.../glasses/GlassesOutputHandoffTest.kt | 3 +-
.../launcher/glasses/GlassesRenderingTest.kt | 4 +-
.../launcher/glasses/GlassesSteamVrTest.kt | 2 -
.../launcher/glasses/RayNeoCalibrationTest.kt | 2 -
.../launcher/glasses/RayNeoLinkTest.kt | 1 -
.../glasses/RayNeoOrientationFilterTest.kt | 2 -
.../launcher/glasses/RayNeoProtocolTest.kt | 2 +-
.../launcher/glasses/VitureSdkTest.kt | 5 +-
.../launcher/glasses/VitureV2ProtocolTest.kt | 3 +-
.../glasses/XrealOrientationFilterTest.kt | 4 +-
.../launcher/glasses/XrealProtocolTest.kt | 1 -
.../launcher/gpu/GpuDetectionTest.kt | 1 -
.../launcher/gpu/SteamVrDeviceLossLogTest.kt | 2 +-
.../droiddeck/launcher/hud/FrameStatsTest.kt | 1 -
.../launcher/hud/MangoReadingsTest.kt | 2 -
.../launcher/input/TouchscreenGesturesTest.kt | 7 +-
.../launcher/session/BootMovieLibraryTest.kt | 3 -
.../session/ComponentsNightlyCatalogTest.kt | 2 -
.../launcher/session/ForcedPackageTest.kt | 1 -
.../launcher/session/GameTelemetryTest.kt | 7 +-
.../launcher/session/GameThreadHintsTest.kt | 6 +-
.../session/SessionLogShareZipTest.kt | 3 -
.../launcher/session/SteamWebLoginTest.kt | 1 -
.../launcher/session/TelemetryOutputTest.kt | 1 -
.../launcher/stores/StoreCatalogPatchTest.kt | 1 -
.../stores/download/DownloadQueueRetryTest.kt | 1 -
.../launcher/stores/gog/GogListingTest.kt | 1 -
.../launcher/ui/BottomDashboardTest.kt | 1 -
.../launcher/ui/DeckControlsPanelTest.kt | 4 -
.../launcher/ui/TrackingRecoveryViewTest.kt | 1 -
artwork/boot-animation-source/embed_sound.py | 1 -
artwork/boot-animation-source/render.py | 11 -
artwork/boot-animation-source/sound.py | 22 -
tools/aaudio-sink/android-volume.h | 2 -
tools/build_local.sh | 3 -
tools/cardboard/test_cardboard_params.c | 2 -
tools/check_glasses_menu_shader.py | 1 -
tools/components/preloads.py | 2 -
tools/components/prepare.py | 1 -
tools/deploy_fast.py | 6 -
tools/desktop-kde/closure.py | 8 -
...2-select-explicit-nested-render-node.patch | 3 +-
tools/desktop-kde/removal-manifests.py | 11 -
tools/device-smoke.sh | 52 +-
tools/directaudio/sink/da_relay_proto.h | 157 ++-----
.../sink/module-directaudio-native-sink.c | 54 +--
tools/dirt-ws2/build.sh | 1 -
tools/dirt-ws2/gen.py | 5 -
tools/dirt-ws2/ws2_32.c | 10 +-
tools/docker_platform.sh | 1 -
tools/docker_preflight.py | 3 +-
tools/droiddeck-scenario | 1 -
tools/droiddeckctl | 19 +-
tools/fex/build-in-arch.sh | 9 -
.../0001-openat2-enosys-fallback.patch | 14 +-
...0003-allocator32-map32bit-exhaustion.patch | 11 +-
tools/gamescope/build-in-arch.sh | 1 -
.../0116-release-cursor-shm-buffers.patch | 5 +-
.../0117-wsi-cache-present-bypass-check.patch | 6 +-
...119-wsi-default-images-under-limiter.patch | 15 +-
.../0120-nested-refresh-follows-host.patch | 18 +-
...-override-content-skip-toplevel-walk.patch | 8 +-
.../0122-wayland-wake-after-host-events.patch | 3 +-
.../patches/0124-commit-done-at-import.patch | 11 +-
.../0125-droiddeck-hud-frame-times.patch | 18 +-
tools/linuxfs/desktop/droiddeck-desktop-gpu | 3 -
tools/linuxfs/fex/vulkanpin.c | 1 -
.../usr/local/bin/droiddeck-innoextract | 3 -
.../overlay/usr/local/bin/droiddeck-agent | 41 +-
.../overlay/usr/local/bin/droiddeck-bwrap | 2 -
.../usr/local/bin/droiddeck-desktop-bookmarks | 1 -
.../usr/local/bin/droiddeck-desktop-games | 1 -
.../overlay/usr/local/bin/droiddeck-fex | 8 -
.../usr/local/bin/droiddeck-fex-update | 5 -
.../overlay/usr/local/bin/droiddeck-flatpak | 6 -
.../overlay/usr/local/bin/droiddeck-game-env | 52 --
.../usr/local/bin/droiddeck-gog-maintenance | 2 -
.../usr/local/bin/droiddeck-gpu-window | 1 -
.../usr/local/bin/droiddeck-launcher-cores | 25 -
.../overlay/usr/local/bin/droiddeck-login1 | 6 -
.../usr/local/bin/droiddeck-msi-install | 62 ---
.../overlay/usr/local/bin/droiddeck-open | 30 --
.../overlay/usr/local/bin/droiddeck-open-url | 8 -
.../usr/local/bin/droiddeck-pad-defaults | 16 -
.../overlay/usr/local/bin/droiddeck-pipewire | 1 -
.../usr/local/bin/droiddeck-proton-extra | 3 -
.../usr/local/bin/droiddeck-proton-run | 33 --
.../usr/local/bin/droiddeck-seed-redists | 2 -
.../overlay/usr/local/bin/droiddeck-session | 23 -
.../usr/local/bin/droiddeck-steam-compat | 20 -
.../usr/local/bin/droiddeck-steam-desktop-ui | 1 -
.../usr/local/bin/droiddeck-steam-games | 11 -
.../usr/local/bin/droiddeck-steam-install | 1 -
.../usr/local/bin/droiddeck-steam-language | 18 -
.../usr/local/bin/droiddeck-steam-library | 11 -
.../usr/local/bin/droiddeck-steam-shaders | 8 -
.../usr/local/bin/droiddeck-steam-shortcuts | 5 -
.../usr/local/bin/droiddeck-steam-ui-scale | 17 -
.../overlay/usr/local/bin/droiddeck-steam-x64 | 35 --
.../usr/local/bin/droiddeck-steamrt-shim | 5 -
.../overlay/usr/local/bin/droiddeck-steamvr | 5 -
.../usr/local/bin/droiddeck-steamvr-flat | 4 -
.../usr/local/bin/droiddeck-steamvr-game | 2 -
.../usr/local/bin/droiddeck-steamvr-settings | 1 -
.../usr/local/bin/droiddeck-store-launch | 58 ---
.../usr/local/bin/droiddeck-wincomponents | 50 --
.../usr/local/bin/droiddeck-x64-arm-proton | 3 -
.../usr/local/bin/droiddeck-x64-arm-webhelper | 1 -
.../usr/local/bin/droiddeck-x64-webhelper | 3 -
.../overlay/usr/local/bin/droiddeck-xbox | 1 -
.../overlay/usr/local/bin/steam-compatibility | 37 --
tools/linuxfs/preload/inputudev.c | 4 -
tools/linuxfs/preload/pulseguard.c | 3 -
tools/linuxfs/preload/robust.c | 6 -
tools/linuxfs/preload/rptouch.c | 3 -
tools/linuxfs/preload/udevmon.c | 4 +-
tools/linuxfs/preload/venc.c | 2 -
tools/linuxfs/preload/zram.c | 2 -
tools/packs/build.sh | 9 -
tools/packs/proot/release.env | 4 -
tools/pipewire/assemble.sh | 2 -
tools/prepare_windows_runtime.py | 8 -
tools/proot/bench/checks.h | 1 -
tools/proot/bench/equiv2.py | 6 -
tools/proot/bench/equiv3.py | 10 -
tools/proot/fastpath/fastpath.c | 3 -
tools/proot/fastpath/svc-patch.h | 11 +-
tools/proot/fastpath/svc-stack.h | 1 -
.../proot/patches/0015-lost-fork-events.patch | 36 +-
tools/proot/statx_mnt_id.h | 5 -
tools/proot_profile.py | 1 -
tools/quest/build-driver.py | 2 -
tools/quest/test_first_frame.c | 7 -
tools/quest/test_presentation.c | 4 +-
tools/quick_deploy.sh | 2 -
tools/release/check_authorship.py | 2 -
tools/release/check_session_assets.py | 1 -
tools/release/install_authorship_hooks.py | 1 -
tools/release/runtime_inputs.py | 2 -
tools/release/update_catalog.py | 9 -
tools/release/validate_apk.py | 1 -
tools/syncthing/build.sh | 2 -
tools/tests/cursor_composite_harness.c | 3 -
tools/tests/pointer_release_harness.c | 2 -
tools/tests/test_client_core_mask.py | 1 -
tools/tests/test_cursor_composite.py | 1 -
tools/tests/test_desktop_open.py | 1 -
tools/tests/test_droiddeck_agent.py | 3 -
tools/tests/test_droiddeckctl.py | 6 +-
tools/tests/test_frame_limit_sync.py | 1 -
tools/tests/test_game_environment.py | 2 -
tools/tests/test_gamescope_cursor_shm.py | 3 -
tools/tests/test_hud_frames.py | 2 +-
tools/tests/test_launcher_cores.py | 1 -
tools/tests/test_login1.py | 1 -
tools/tests/test_msi_install.py | 1 -
tools/tests/test_netmanager.py | 4 -
tools/tests/test_openat2_preload.py | 4 -
tools/tests/test_picture_position_mapping.py | 1 -
tools/tests/test_pointer_release.py | 1 -
tools/tests/test_preload_snapshots.py | 1 -
tools/tests/test_quest_presentation.py | 2 -
tools/tests/test_run_windows.py | 1 -
tools/tests/test_session_cursor_routing.py | 1 -
tools/tests/test_session_guest_command.py | 6 -
tools/tests/test_session_pacing.py | 1 -
tools/tests/test_sign_apk_renames.py | 2 -
tools/tests/test_steam_cold_launch.py | 2 -
tools/tests/test_steam_compat_x64.py | 4 -
tools/tests/test_steam_desktop_launch.py | 1 -
tools/tests/test_steam_install.py | 2 -
tools/tests/test_store_launch.py | 3 -
tools/tests/test_store_shortcuts.py | 5 -
tools/tests/test_termux_jni.py | 2 -
tools/tests/test_winebus_rumble_preload.py | 1 -
...004-keep-tranches-of-the-main-device.patch | 3 +-
tools/vr/build_windows.py | 3 -
tools/vr/src/driver_droiddeck.cpp | 7 -
tools/vr/src/fence_tracker.h | 10 -
tools/vr/src/hand_skeleton.h | 3 +-
tools/vr/src/pad_controller_config.h | 1 -
tools/vr/src/presenter.cpp | 1 -
tools/vr/src/scheduling.c | 1 -
tools/vr/src/spatial_tracking.h | 1 -
tools/vr/src/vk_layer_droiddeck_vr.c | 6 +-
tools/vr/src/wsi_capture.cpp | 1 -
tools/vr/src/xr_frame_math.h | 1 -
tools/vr/src/xr_gpu_channel.h | 1 -
tools/vr/tests/test_fence_tracker.c | 3 -
tools/vr/tests/test_presenter_image.cpp | 1 -
tools/vr/tests/test_semaphore_layer.c | 2 -
tools/vr/tests/test_xr_shared.cpp | 1 -
tools/xbox/droiddeck-cloud-token.patch | 13 +-
.../droiddeck-credential-diagnostics.patch | 12 +-
.../xbox/droiddeck-credential-fallback.patch | 5 +-
tools/xbox/droiddeck-xbox-ticket.patch | 6 +-
tools/xbox/prepare.py | 1 -
tools/xbox/runtime.py | 4 -
tools/xcloud-player/build.sh | 1 -
tools/xcloud-player/entry.ts | 33 +-
tools/xcloud-player/renderer.ts | 23 +-
tools/xcloud-player/sdp-tweaks.ts | 4 +-
tools/xcloud-player/streamstats.ts | 4 -
tools/xcloud-player/uuid.ts | 1 -
511 files changed, 6813 insertions(+), 4068 deletions(-)
diff --git a/.gitattributes b/.gitattributes
index 3cb68a29e..4c0a6c312 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,7 +3,6 @@ tools/mangoapp/** text=auto eol=lf
tools/comet/LICENSE whitespace=-blank-at-eof
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
-# These helpers are packaged verbatim and executed inside the Linux runtime.
tools/linuxfs/overlay/** text eol=lf
tools/linuxfs/desktop/** text eol=lf
tools/mangoapp/mangoapp text eol=lf
diff --git a/.github/trusted-test-builds.txt b/.github/trusted-test-builds.txt
index 9b09f6132..f91528f39 100644
--- a/.github/trusted-test-builds.txt
+++ b/.github/trusted-test-builds.txt
@@ -1,4 +1,3 @@
-# GitHub PR openers trusted for automatic release-signed test builds.
FX192
K11MCH1
kingokksa
diff --git a/.github/workflows/build-fex.yml b/.github/workflows/build-fex.yml
index 8ec3369a1..deb099100 100644
--- a/.github/workflows/build-fex.yml
+++ b/.github/workflows/build-fex.yml
@@ -1,8 +1,5 @@
name: Build FEX (x86-64 Steam client)
-# Checks tools/fex: the same build DroidDeck-Components' build-fex.yml runs from this repository to publish FEX
-# (its single `fex` release and fex.json, which the app follows). Nothing is published from here.
-
on:
push:
paths:
@@ -31,7 +28,6 @@ jobs:
with:
persist-credentials: false
- name: Build in Arch Linux ARM
- # Inputs reach the shell as environment variables, never pasted into the script.
env:
FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }}
FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }}
diff --git a/.github/workflows/publish-packs.yml b/.github/workflows/publish-packs.yml
index 7bf219ef7..de4adb3b2 100644
--- a/.github/workflows/publish-packs.yml
+++ b/.github/workflows/publish-packs.yml
@@ -1,11 +1,5 @@
name: Publish add-on packs
-# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
-# content-addressed tags (pack--). The APK from that run pins each pack's
-# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
-# Only main and release builds publish here; a PR test build attaches the packs main has not published
-# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
-
on:
workflow_call:
inputs:
diff --git a/README.md b/README.md
index 9b3f3c443..646d70214 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- libblsession runs Wine's arm64 `SIGUSR2` context handler (`dlls/ntdll/unix/signal_arm64.c`, LGPL-2.1-or-later) in place on a frame laid out like [Linux](https://github.com/torvalds/linux)'s `arch/arm64/kernel/signal.c`, and serves `fill_vm_counters` in `dlls/ntdll/unix/process.c` the kernel's own `/proc//status`.
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
+- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
@@ -93,8 +94,10 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
+- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
- The Store uses [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1) through DroidDeck's own bubblewrap shim. Chromium and Electron apps start directly, since [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause) always uses Flatpak's spawn portal.
+- Folder sync runs [Syncthing](https://github.com/syncthing/syncthing) (MPL-2.0), built from [@researchxxl](https://github.com/researchxxl)'s [syncthing-android](https://github.com/researchxxl/syncthing-android) fork (MPL-2.0).
@@ -103,6 +106,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
- Steam audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink`.
- The DroidDeck HUD is [@The412Banner](https://github.com/The412Banner)'s [FusionHUD](https://github.com/The412Banner/FusionHUD) (GPL-3.0 with an attribution term). Its readings match Valve's [MangoHud](https://github.com/flightlessmango/MangoHud) build (MIT), with frame times from gamescope patch 0125.
+- The HUD's CPU, GPU load, thermal and battery readers come from [@xXJSONDeruloXx](https://github.com/xXJSONDeruloXx)'s work.
- LSFG frame generation comes from Camille LaVey and the [Eden](https://eden-emu.dev) project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported by [@maxjivi05](https://github.com/maxjivi05). It needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders.
- Remote Play decodes through Android's MediaCodec or the [FFmpeg](https://ffmpeg.org) (LGPL-2.1-or-later) in Steam's runtime. Qualcomm low latency settings follow [Moonlight](https://github.com/moonlight-stream/moonlight-android) (GPL-3.0).
- [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) sockets use the settings in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions.
@@ -125,6 +129,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
Smart glasses
- Builds on WinNative's VITURE work. Protocol checked against [@wheaney](https://github.com/wheaney)'s [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver), [@badicsalex](https://github.com/badicsalex)'s [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and [go-macos/iokit](https://pkg.go.dev/github.com/go-macos/iokit/viture). The optional VITURE SDK is not included.
+- The VITURE protocol V2 frames adapt [@elasticjava](https://github.com/elasticjava)'s [viture-v2](https://github.com/elasticjava/viture-v2) (MIT) by Holger Bartnick. See `docs/third-party/viture-v2`.
- RayNeo adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0). See [docs/third-party/rayneo](docs/third-party/rayneo/README.md).
- XREAL 1S references XRLinuxDriver and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [@rohitsangwan01](https://github.com/rohitsangwan01)'s [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) (MIT) and [@0xcaff](https://github.com/0xcaff)'s [xr-tools](https://github.com/0xcaff/xr-tools).
- Rokid follows ar-drivers-rs, @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/) and Monado's Rokid driver.
diff --git a/app/build.gradle b/app/build.gradle
index 7459cc637..91fd2ad94 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,7 +4,6 @@ plugins {
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
-// Machine-specific tooling and optional CI-prepared inputs stay out of version control.
def localBuildOptions = new Properties()
def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties')
if (localBuildOptionsFile.asFile.exists()) {
@@ -98,8 +97,6 @@ def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler']
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
- // A source build that did not compile proot still pins a published pack, so the app
- // downloads the patched binary instead of falling back to the rootfs copy.
if (name == 'proot' && prootPackPin.PROOT_PACK_SHA256) {
return [(name): [sha256: prootPackPin.PROOT_PACK_SHA256, size: (prootPackPin.PROOT_PACK_SIZE ?: '0') as long]]
}
@@ -208,7 +205,6 @@ android {
}
androidResources {
- // aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:_*:!CVS:!thumbs.db:!picasa.ini:!*~:droiddeck-xbox:droiddeck-comet:directaudio'
}
@@ -219,7 +215,6 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
- // Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -238,7 +233,6 @@ android {
outputs.upToDateWhen { false }
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
}
- // Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
maxParallelForks = 2
def logTarget = providers.systemProperty('robolectric.logging').orNull
@@ -255,7 +249,6 @@ android {
}
}
-// Vendor binaries remain in the user's SDK directory; never vendor them into Git.
if (vitureSdkDir) {
android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android'))
def stageVitureNotices = tasks.register('stageVitureNotices', Copy) {
@@ -300,7 +293,6 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) {
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
-// CI publishes its packs; a local build pins packs nobody hosts (see addonPacks), so the APK carries them.
if (!ciBuild) {
def stageLocalPacks = tasks.register('stageLocalPacks', Sync) {
from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' }
@@ -374,7 +366,6 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
- // Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity).
implementation 'io.github.webrtc-sdk:android:125.6422.07'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
index 61c7c9c2f..16c751605 100644
--- a/app/src/main/cpp/CMakeLists.txt
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -8,7 +8,6 @@ set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,max-page-size=16384
add_subdirectory(adrenotools)
-# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc)
set(DDFG_UPSTREAM "v0.3.0")
@@ -45,7 +44,6 @@ add_library(ddvenc SHARED venc/venc_server.cpp)
target_compile_features(ddvenc PRIVATE cxx_std_17)
target_link_libraries(ddvenc mediandk log)
-# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
target_compile_features(droiddeck_viture PRIVATE cxx_std_17)
diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp
index 9eb73a001..8906b01f7 100644
--- a/app/src/main/cpp/fakeinput_steam.cpp
+++ b/app/src/main/cpp/fakeinput_steam.cpp
@@ -78,7 +78,6 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8;
static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11;
static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252;
static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2;
-// Rings hold the 64-bit input_event layout that the arm64 writers use; 32-bit guests convert on read.
struct RingEvent {
int64_t sec;
int64_t usec;
@@ -391,7 +390,6 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
syscall(SYS_close, sock);
}
-// Must match RumbleComponent.HOLD_MS: play until the slot's next effect or stop.
static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff;
__attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) {
@@ -1243,7 +1241,6 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1;
static constexpr int TRITON_STATE_BYTES = 45;
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
-// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting.
static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50;
static constexpr const char *TRITON_NAME = "Steam Controller";
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
@@ -2673,9 +2670,6 @@ __attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw &
static constexpr uint32_t TRITON_HWID = 72;
static constexpr uint32_t TRITON_FW_TIMESTAMP = 0x6AC686B3;
-// Steam compares the controller's firmware with TRITON_FW_TS in its bundled hardwareupdater.cfg (and blocks
-// with a required update below MUST_UPDATE_TRITON_FW_TS), so the emulated pad reports at least the build
-// Steam ships.
static uint32_t triton_fw_timestamp() {
static const uint32_t timestamp = [] {
std::vector roots;
@@ -3679,7 +3673,6 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event);
return 0;
} else if (type == 0x6A && !strncmp(event, "event", 5)) {
- // Older SDL2 builds treat any JSIOCGNAME answer as a joydev node and skip EVIOCGID, then drop the pad as 0000:0000.
Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event);
guard.unlock();
errno = EINVAL;
diff --git a/app/src/main/cpp/framegen/ddfg/record_impl.inc b/app/src/main/cpp/framegen/ddfg/record_impl.inc
index 734d94cdc..f366d9a9c 100644
--- a/app/src/main/cpp/framegen/ddfg/record_impl.inc
+++ b/app/src/main/cpp/framegen/ddfg/record_impl.inc
@@ -267,8 +267,6 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
}
- // Every generation of one real frame is recorded into the same command buffer, so the flow
- // between prev and curr is computed once and each generation gets its own synth UBO and set.
if (reuseFlow && !g_scratch.flowReady) return;
if (!reuseFlow) {
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp
index 3a37f032a..f9ee32463 100644
--- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp
+++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp
@@ -1,5 +1,4 @@
-// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS).
-// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
+// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
#include "lsfg_dll.h"
#include "lsfg_dxbc.h"
diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h
index a20a6c631..81bf6781d 100644
--- a/app/src/main/cpp/framegen/lsfg/lsfg_dll.h
+++ b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h
@@ -1,5 +1,4 @@
-// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS).
-// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
+// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
#pragma once
diff --git a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h
index 17ba1876d..2f5286b26 100644
--- a/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h
+++ b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h
@@ -1,5 +1,4 @@
#pragma once
-// DXVK's dxbc subset is zlib licensed; vendored under cpp/thirdparty/dxbc.
#include
#include
diff --git a/app/src/main/cpp/questxr/gpu_frames.h b/app/src/main/cpp/questxr/gpu_frames.h
index dc69dbbfb..77d7a6db6 100644
--- a/app/src/main/cpp/questxr/gpu_frames.h
+++ b/app/src/main/cpp/questxr/gpu_frames.h
@@ -76,7 +76,6 @@ public:
}
lastFrame_ = frame.header.frame_id;
if (state == XrGpuFrameState::Stale) {
- // No GPU read was submitted, so the producer can safely reclaim a dropped frame.
dropped_++;
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
return false;
@@ -88,7 +87,6 @@ public:
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
- // The producer may overwrite this slot only after the native GPU has finished reading it.
glFinish();
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
diff --git a/app/src/main/cpp/venc/venc_proto.h b/app/src/main/cpp/venc/venc_proto.h
index 47fb6ef24..e69bbd822 100644
--- a/app/src/main/cpp/venc/venc_proto.h
+++ b/app/src/main/cpp/venc/venc_proto.h
@@ -3,10 +3,6 @@
#include
-/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
- * MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
- * every frame gets one reply, touches get none. */
-
#define VENC_MAX_BUFFERS 16
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5, VDEC_CONFIG = 6, VDEC_CAPTURE = 7, VDEC_PACKET = 8, VDEC_RESULT = 9 };
@@ -45,7 +41,6 @@ struct venc_bitrate {
uint32_t bitrate;
};
-/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */
struct venc_touch {
uint32_t type;
uint32_t action;
diff --git a/app/src/main/cpp/venc/venc_server.cpp b/app/src/main/cpp/venc/venc_server.cpp
index 17a5d7eed..fd3a9ad13 100644
--- a/app/src/main/cpp/venc/venc_server.cpp
+++ b/app/src/main/cpp/venc/venc_server.cpp
@@ -33,8 +33,8 @@ struct Mapping {
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
constexpr uint32_t kBufferFlagKeyFrame = 1;
constexpr uint32_t kBufferFlagCodecConfig = 2;
-constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
-constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
+constexpr int kTouchWidth = 1920, kTouchHeight = 1080;
+constexpr int kRemoteTouchId = 31;
int32_t avc_profile(int32_t v4l2) {
switch (v4l2) {
@@ -549,7 +549,7 @@ void serve(int listener) {
}
}
-} // namespace
+}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
diff --git a/app/src/main/cpp/viture/display_modes.h b/app/src/main/cpp/viture/display_modes.h
index 3a78e03ae..b6b2288e2 100644
--- a/app/src/main/cpp/viture/display_modes.h
+++ b/app/src/main/cpp/viture/display_modes.h
@@ -1,20 +1,18 @@
#pragma once
-// Our controller uses the standard display-mode vocabulary, even for Beast.
-// Native mode reuses some numbers with different meanings; never pass them through.
namespace droiddeck::viture {
constexpr int to_native_mode(int standard) {
switch (standard) {
- case 0x31: return 0x31; // 1080p 60
- case 0x33: return 0x32; // 1080p 90
- case 0x34: return 0x33; // 1080p 120
- case 0x32: return 0x37; // 1080p SBS 60
- case 0x35: return 0x38; // 1080p SBS 90
- case 0x41: return 0x34; // 1200p 60
- case 0x43: return 0x35; // 1200p 90
- case 0x44: return 0x36; // 1200p 120
- case 0x42: return 0x3a; // 1200p SBS 60
- case 0x45: return 0x3b; // 1200p SBS 90
+ case 0x31: return 0x31;
+ case 0x33: return 0x32;
+ case 0x34: return 0x33;
+ case 0x32: return 0x37;
+ case 0x35: return 0x38;
+ case 0x41: return 0x34;
+ case 0x43: return 0x35;
+ case 0x44: return 0x36;
+ case 0x42: return 0x3a;
+ case 0x45: return 0x3b;
default: return -4;
}
}
@@ -30,7 +28,7 @@ constexpr int from_native_mode(int native) {
case 0x36: return 0x44;
case 0x3a: return 0x42;
case 0x3b: return 0x45;
- default: return -4; // Ultrawide is not stereo.
+ default: return -4;
}
}
}
diff --git a/app/src/main/cpp/viture/mjpeg_payload.h b/app/src/main/cpp/viture/mjpeg_payload.h
index a451a4eee..470ecf550 100644
--- a/app/src/main/cpp/viture/mjpeg_payload.h
+++ b/app/src/main/cpp/viture/mjpeg_payload.h
@@ -5,7 +5,6 @@
inline std::size_t mjpeg_payload_size(const uint8_t* bytes, std::size_t size) {
if (!bytes || size < 4 || size > 4*1024*1024 || bytes[0] != 0xff || bytes[1] != 0xd8) return 0;
std::size_t end = size;
- // Luma Ultra UVC frames can pad the JPEG to an eight-byte boundary with zeros.
while (end > 4 && size-end < 7 && bytes[end-1] == 0) --end;
return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0;
}
diff --git a/app/src/main/cpp/viture/sdk_bridge.cpp b/app/src/main/cpp/viture/sdk_bridge.cpp
index 053c28335..04ee47a35 100644
--- a/app/src/main/cpp/viture/sdk_bridge.cpp
+++ b/app/src/main/cpp/viture/sdk_bridge.cpp
@@ -30,7 +30,6 @@
#include "stereo_frame_gate.h"
namespace {
-// SDK callbacks have no user-data parameter. Only one provider may exist at a time.
std::mutex api_mutex;
std::mutex pose_mutex;
std::array latest_pose{};
@@ -64,7 +63,6 @@ void on_pose(float* data, uint64_t) {
std::lock_guard lock(pose_mutex);
if (!accepting_pose) return;
std::copy_n(data + 3, 4, latest_pose.begin());
- // SDK device ticks are not Android's elapsed-realtime clock.
latest_time = now_ns();
}
@@ -234,8 +232,6 @@ int set_display(Session& s, int standard) {
if (mode == 0) {
int rc = xr_device_provider_set_display_mode(s.device, standard);
if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc;
- // Older Beast firmware exposes fewer bypass modes. Fall back to its
- // separate native table only when the SDK explicitly rejects the mode.
}
const int native = droiddeck::viture::to_native_mode(standard);
if (native < 0) return native;
diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.c b/app/src/main/cpp/waylandcomp/src/cardboard.c
index 03c434316..982c3bc47 100644
--- a/app/src/main/cpp/waylandcomp/src/cardboard.c
+++ b/app/src/main/cpp/waylandcomp/src/cardboard.c
@@ -34,7 +34,6 @@ void cardboard_clamp(struct cardboard_params *p) {
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
p->k1 = clampf(p->k1, -1.0f, 2.0f);
p->k2 = clampf(p->k2, -1.0f, 2.0f);
- // Optical vertical FOV on supported glasses can be as low as ~22 degrees.
p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f);
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
diff --git a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h
index f74676d6a..fb098c4a3 100644
--- a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h
+++ b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h
@@ -1,4 +1,3 @@
- // 1115.1.0
#pragma once
const uint32_t cardboard_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b,
diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c
index 68039b444..471748a6d 100644
--- a/app/src/main/cpp/waylandcomp/src/compositor.c
+++ b/app/src/main/cpp/waylandcomp/src/compositor.c
@@ -289,7 +289,6 @@ static struct wl_event_source *g_fallback_timer;
static int g_fallback_armed;
volatile int g_fps_limit;
-/* fps limit -1 (phone output, not mirrored): replaced buffers go back to the client at once instead of once per refresh. */
volatile int g_release_unpaced;
volatile int g_hide_shell;
@@ -446,15 +445,10 @@ static int g_cursor_w, g_cursor_h;
static int g_cursor_hidden = 1;
static int g_cursor_serial;
-/* The pointer image as a compositor-owned texture, for outputs that cannot show
- * the phone's Compose overlay (the glasses surface owns the picture there). */
-#define CURSOR_MAX_DRAWS 2 /* one per eye when the picture is side-by-side */
+#define CURSOR_MAX_DRAWS 2
static struct vkp_image *g_cursor_img;
static int g_cursor_img_w, g_cursor_img_h, g_cursor_img_serial = -1;
static atomic_int g_cursor_composite;
-/* Last request from the app, used only to skip repeated calls. The applied flag below is
- * written by the caller and read by the render path; message 22 only wakes the event loop, so
- * a queued message can never put back an older value. */
static atomic_int g_cursor_composite_want;
static void cursor_publish_shown(void) {
@@ -1922,8 +1916,6 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra
}
if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; }
hs->hf.tonemap = !droiddeck_color_output();
- /* The pointer draws are appended after this plan: reserve their slots (zeroed,
- * so they count as SDR content above the game) instead of reading past the end. */
if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n + CURSOR_MAX_DRAWS, 1))) return 0;
for (int i = 0; i < dl->n; i++) {
struct surface *s = surface_for_image(dl->d[i].img);
@@ -2026,12 +2018,6 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
}
}
-/*
- * Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one
- * picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once
- * and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects
- * and frame generation apply to it as to a game.
- */
#define STREAM_SLOTS 6
static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
static atomic_int g_stream_on;
@@ -2051,7 +2037,6 @@ static void stream_forget_all(void) {
g_stream_img = NULL;
}
-/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
static void stream_take(void) {
pthread_mutex_lock(&g_stream_lock);
AHardwareBuffer *ahb = g_stream_pending;
@@ -2065,7 +2050,7 @@ static void stream_take(void) {
if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i;
}
if (slot >= 0) {
- AHardwareBuffer_release(ahb); /* the slot keeps its own reference */
+ AHardwareBuffer_release(ahb);
} else {
struct vkp_image *img = vkp_image_from_ahb(ahb);
if (!img) {
@@ -2077,7 +2062,7 @@ static void stream_take(void) {
droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img));
slot = oldest;
if (g_stream_slots[slot].img) {
- vkp_wait_idle(); /* the GPU may still read the picture being replaced */
+ vkp_wait_idle();
if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL;
vkp_image_destroy(g_stream_slots[slot].img);
}
@@ -2123,7 +2108,6 @@ void droiddeck_stream_set(int on) {
(void)n;
}
-/* Any thread: hands over one decoded frame (acquires its own reference). */
void droiddeck_stream_frame(AHardwareBuffer *ahb) {
if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
AHardwareBuffer_acquire(ahb);
@@ -2131,7 +2115,7 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) {
AHardwareBuffer *dropped = g_stream_pending;
g_stream_pending = ahb;
pthread_mutex_unlock(&g_stream_lock);
- if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */
+ if (dropped) { AHardwareBuffer_release(dropped); return; }
struct input_msg m = { 20, 0, 0, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
@@ -2200,11 +2184,6 @@ static void render_scene(void) {
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
}
if (hdr_route == 3) { fx_on = 0; framegen = 0; }
- /* A game the compositor cannot import (an AHardwareBuffer-only surface, or HDR frames it
- * has no texture for) is shown on its own display layer and is not part of the scene, so
- * composing would leave the game out of the picture: the pointer stays out instead, the
- * same trade-off as HDR route 3. Both are decided before the pointer is appended, because
- * the appended draws change the draw list they are matched against. */
struct surface *ahb_only = ahb_layer_only_candidate(w, h);
struct surface *layer_only = ahb_only ? ahb_only : hdr_unimported_fullscreen(&dl, w, h);
const int cursor_on = hdr_route == 3 || layer_only ? 0 : add_cursor(&dl, w);
@@ -2216,9 +2195,6 @@ static void render_scene(void) {
} else if (!layer_only) {
cursor_layer_said = 0;
}
- /* With cardboard the scene is composed anyway (the pass needs the whole picture), so the
- * pointer reaches the output without switching routes: the HDR composition path would drop
- * the cardboard pass, which is 8-bit only. */
static int cursor_hdr_said;
if (cursor_on && hdr_route == 0 && hs.n_hdr > 0 && !cardboard) {
hdr_route = 2;
@@ -2257,8 +2233,6 @@ static void render_scene(void) {
}
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && !cursor_on &&
!ahb_only) {
- /* The complete SDR scene stays on one display layer. Do not hide/re-show GAME
- * on each desktop frame just because it is not a single fullscreen window. */
rendered = vkp_render(w, h, dl.d, dl.n) == 0;
} else if (hdr_route == 0 || hdr_route == 3) {
const int pass_on = fx_on || framegen;
@@ -2367,8 +2341,6 @@ static int on_fallback_timer(void *data) {
return 0;
}
-/* A new frame is drawn when it arrives instead of at the next screen tick, at most once per refresh,
- while frames go straight back to the game (no compositor limit), without streaming or frame generation. */
static int commit_renders_on(void) {
return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active();
}
@@ -2427,10 +2399,6 @@ static void on_vsync(int64_t frame_time_ns) {
if (vkp_effects_sync()) tick_change = 1;
if (vkp_cardboard_pose_changed()) tick_change = 1;
if (tick_change) g_dirty = 1;
- /* A frame that arrived after this refresh was already drawn is left for the next arrival to draw;
- drawing it here would block that arrival too and keep every frame waiting for the tick. A client
- that draws again only after its frame callback sends no such arrival: once a held frame is still
- waiting at the next tick, the tick draws every frame for a second before holding one again. */
if (g_tick_held && g_dirty) g_hold_missed_ns = now;
g_tick_held = 0;
if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns &&
@@ -2665,15 +2633,11 @@ static int pointer_locked_for_cursor(void) {
static int cursor_scene_positions(int scene_w, int source, int ptr_x, int hx, int out[2]) {
out[0] = ptr_x - hx; out[1] = -1;
if (source != CARDBOARD_SOURCE_SBS || scene_w <= 1) return 1;
- /* The pointer decides which eye it is in; the hotspot only shifts the image drawn there,
- * so a hotspot that crosses the seam cannot put the second copy in the wrong eye. */
const int half = scene_w / 2;
out[1] = (ptr_x < half ? ptr_x + half : ptr_x - half) - hx;
return 2;
}
-/* Crops one pointer copy to the eye it belongs to (eye < 0 crops nothing). Returns 0 when no
- * part of the copy falls inside that eye, so the caller skips it. */
static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int *sx, int *dw) {
int left = x0, right = x0 + w;
if (eye >= 0 && scene_w > 1) {
@@ -2689,7 +2653,6 @@ static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int
return 1;
}
-/* Appends the guest pointer as the topmost draw of the scene. */
static int add_cursor(struct draw_list *dl, int scene_w) {
if (!atomic_load(&g_cursor_composite) || !g_ptr_known || pointer_locked_for_cursor()) return 0;
@@ -2740,9 +2703,6 @@ static int add_cursor(struct draw_list *dl, int scene_w) {
if (!d) return 0;
dl->d = d; dl->cap = cap;
}
- /* The cardboard pass samples each half of the scene as one eye, so a copy that crosses the
- * seam would show up as a fragment at the opposite edge of the other eye: crop each copy to
- * the eye it belongs to and drop it when nothing of it lands there. */
const int sbs = source == CARDBOARD_SOURCE_SBS && scene_w > 1;
const int pointer_eye = ptr_x < scene_w / 2 ? 0 : 1;
int appended = 0;
@@ -2765,8 +2725,6 @@ static void sync_lock_notify(void) {
if (locked == last) return;
last = locked;
droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
- /* The drawn pointer follows the lock state, so a game that stops presenting after it grabs
- * the pointer would keep the last composited cursor on screen without a redraw here. */
if (atomic_load(&g_cursor_composite)) schedule_render();
}
@@ -2849,8 +2807,6 @@ static void constraint_activate(struct constraint *k) {
g_active_constraint = k;
const double was_x = g_ptr_x, was_y = g_ptr_y;
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
- /* Confining can move the pointer while the lock state stays the same, so sync_lock_notify()
- * below stays quiet; the drawn pointer follows the position, so repaint here. */
if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite))
schedule_render();
constraint_origin(k, &ox, &oy);
@@ -2935,8 +2891,6 @@ static void constraints_surface_commit(struct surface *s) {
if (k->active && !k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
}
}
- /* A position hint or a newly committed confine region can move the pointer while the lock
- * state stays the same, so nothing else would repaint the composited cursor. */
if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite))
schedule_render();
}
@@ -3147,10 +3101,6 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
- // A button-only input must not replay a cached absolute position. A game
- // may have warped its cursor since the last motion (before taking lock,
- // or while releasing it), so that replay can become an unintended pan.
- // Absolute touch events still carry their position, including on clicks.
int rel_consumed = send_relative_motion(client, dx, dy);
if (!(k && k->is_lock) && !(relative && rel_consumed) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
@@ -3182,9 +3132,6 @@ static void pointer_button(uint32_t button, int pressed) {
pointer_input(0, 0, 1, button, pressed);
return;
}
- // Releases also clean up interrupted gestures. Deliver them to the existing
- // pointer focus without motion or hit-testing: native touch has independent
- // coordinates, so replaying g_ptr_x/y here can warp the game cursor.
uint32_t t = now_ms();
for (int i = 0; i < g_nptrs; i++) {
struct seat_pointer *sp = &g_ptrs[i];
@@ -3679,8 +3626,6 @@ void droiddeck_wayland_send_scene_input(int type, int a, int b) {
(void)n;
}
-/* Any thread: draw the pointer into the compositor's own output (the glasses
- * surface owns the picture, so the phone's Compose overlay never reaches it). */
void droiddeck_wayland_set_cursor_composite(int on) {
const int want = on ? 1 : 0;
if (atomic_exchange(&g_cursor_composite_want, want) == want) return;
@@ -3871,8 +3816,6 @@ int droiddeck_wayland_run(void) {
open_session_log();
if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn");
- /* Presentation compatibility is not HDR capability. Set the initial layer mode before
- * clients bind, including when Desktop is the first session in this app process. */
if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1;
struct wl_display *display = wl_display_create();
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
index 9de2824d7..eceb79639 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
@@ -374,16 +374,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
mark_failed(kind);
return 0;
}
- /* The ring and the engine's images are new on this frame. Dispatching into them now
- * null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
- * Show this frame as it is; generation starts once the size has settled. */
if (size_changed || ring == 2) {
FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
return 0;
}
- /* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
- * generations would otherwise read as a slower game and justify even more generations. */
++g_source_frames;
const uint64_t game_frames = droiddeck_game_frames();
uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate);
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
index 0f8e1f61b..8ce226922 100644
--- a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
+++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
@@ -103,7 +103,6 @@ static bool prepare_pin(void) {
(off_t)pin.memory << 12);
if (commands == MAP_FAILED) return false;
pin.commands = commands;
- // A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
uint32_t *words = commands;
words[0] = pin.nop;
words[1] = 0;
@@ -124,7 +123,6 @@ static void *hold_pin(void *unused) {
if (errno == EINTR || errno == ETIMEDOUT) continue;
break;
}
- // Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
usleep(25000);
if (atomic_load(&running) && !submit_pin()) break;
}
diff --git a/app/src/main/cpp/waylandcomp/src/headpose_shm.h b/app/src/main/cpp/waylandcomp/src/headpose_shm.h
index 824232b43..22088dc6b 100644
--- a/app/src/main/cpp/waylandcomp/src/headpose_shm.h
+++ b/app/src/main/cpp/waylandcomp/src/headpose_shm.h
@@ -24,7 +24,6 @@ struct droiddeck_headpose {
float eye_aspect;
uint32_t eye_w, eye_h;
float display_hz;
- // Uses a formerly reserved word; older publishers leave this zero.
uint32_t render_height;
uint32_t reserved[10];
};
diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.c b/app/src/main/cpp/waylandcomp/src/hudframes.c
index 5a6fc4e99..ffbb657a5 100644
--- a/app/src/main/cpp/waylandcomp/src/hudframes.c
+++ b/app/src/main/cpp/waylandcomp/src/hudframes.c
@@ -8,7 +8,6 @@
#include "hudframes.h"
-/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */
#define HUD_FRAMES_MAGIC 0x46484444u
#define HUD_FRAMES_HEADER 128u
#define HUD_FRAMES_ENTRY 16u
@@ -69,7 +68,6 @@ int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, i
memcpy(&frametimes[n], e, 8);
memcpy(×[n], e + 8, 8);
}
- /* The writer may have lapped the ring while we copied: drop what it overwrote. */
uint64_t after = load64(h->map + 16);
uint64_t valid = after > cap ? after - cap : 0;
if (from < valid) {
diff --git a/app/src/main/cpp/waylandcomp/src/hudframes.h b/app/src/main/cpp/waylandcomp/src/hudframes.h
index d0b1ff5bd..518f7be52 100644
--- a/app/src/main/cpp/waylandcomp/src/hudframes.h
+++ b/app/src/main/cpp/waylandcomp/src/hudframes.h
@@ -8,9 +8,7 @@ struct hud_frames;
struct hud_frames *hud_frames_open(const char *path);
void hud_frames_close(struct hud_frames *h);
-/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max);
-/* info: pid, output width, output height, refresh mHz, Steam focused. */
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len);
#endif
diff --git a/app/src/main/cpp/waylandcomp/src/quest_device.h b/app/src/main/cpp/waylandcomp/src/quest_device.h
index ee9c37b10..bcdcee239 100644
--- a/app/src/main/cpp/waylandcomp/src/quest_device.h
+++ b/app/src/main/cpp/waylandcomp/src/quest_device.h
@@ -3,7 +3,6 @@
#include
#include
-/* Presentation only; this does not select a GPU driver or expand driver support. */
static inline int quest_device_matches(const char *model, const char *device, const char *maker) {
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
!strcasecmp(model, "Quest 3S") ||
diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.c b/app/src/main/cpp/waylandcomp/src/sc_layer.c
index f9d203ba1..ff90d37dc 100644
--- a/app/src/main/cpp/waylandcomp/src/sc_layer.c
+++ b/app/src/main/cpp/waylandcomp/src/sc_layer.c
@@ -291,7 +291,7 @@ int sc_layer_base_black(int width, int height) {
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
api.setBuffer(tx, g_base_sc, black, -1);
api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE);
- api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */
+ api.setZOrder(tx, g_base_sc, 0);
api.setGeometry(tx, g_base_sc, &src, &dst, 0);
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
api.txApply(tx); api.txDelete(tx);
@@ -973,7 +973,7 @@ void sc_layer_hide_overlay(void) {
}
void sc_layer_window_gone(void) {
- base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */
+ base_hide(1);
if (api.state != 1) return;
layers_init();
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index ffdc5cb1d..df1c0d817 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -71,8 +71,6 @@ static int g_first_frame_done;
static int g_base_black;
int vkp_quest_layer_presentation(void) {
- /* Compositor thread only; this is a process-wide device property, not a session setting.
- * Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */
static int enabled = -1;
if (enabled < 0) {
char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0};
@@ -477,7 +475,6 @@ static int dev_init(void) {
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
- /* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */
for (unsigned i = 0; i < 4; i++) {
if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i];
else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]);
@@ -489,7 +486,6 @@ static int dev_init(void) {
}
const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
- /* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */
g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) &&
has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
if (g_ahb_import) {
@@ -1701,9 +1697,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
if (fx) {
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
- /* Frame generation samples every output pixel. Upscaling first made it interpolate
- * the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
- * Grade at the game's size instead; the screen blit still scales to the panel. */
if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
vkp_effects_set_formats(scene_fmt, scene_fmt);
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
@@ -1803,9 +1796,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) {
- /* The desktop (multiple windows), overlays and an empty scene must also avoid WSI.
- * Merely enabling zero-copy handles only a single fullscreen window and leaves both
- * the letterbox and every fallback frame going through the corrupted swapchain. */
vkp_apply_window_request();
sc_layer_hide_overlay();
if (n > 0) {
@@ -1817,9 +1807,6 @@ int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
} else {
sc_layer_hide();
}
- /* sc_layer_present_pass signals only after submitting client content.
- * A backdrop alone, or a successful no-op while a layer slot is unavailable,
- * must not dismiss the app's loading UI or mark Steam's window as shown. */
return vkp_base_black(scene_w, scene_h);
}
return render_impl(scene_w, scene_h, draws, n, NULL, NULL);
@@ -1856,8 +1843,6 @@ void vkp_signal_first_frame(void) {
int vkp_update_map(int scene_w, int scene_h) {
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
if (vkp_quest_layer_presentation()) {
- /* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do
- * not create/present a swapchain just to paint black under an otherwise clean layer. */
int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window);
if (w <= 0 || h <= 0) return -1;
g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h};
diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
index adc2372de..5d69098a6 100644
--- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
@@ -698,7 +698,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *
jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException");
if (error) (*env)->ThrowNew(env, error, strerror(saved_errno));
}
- // Clear a legacy global pin when recovering its marker after an app upgrade or crash.
if (on != JNI_TRUE) adrenotools_set_turbo(false);
}
@@ -725,7 +724,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
}
-/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
droiddeck_stream_set(on ? 1 : 0);
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index de94c41a5..01657d289 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -19,7 +19,6 @@ class App : Application() {
com.droiddeck.launcher.ui.Themes.loadGradient(this)
com.droiddeck.launcher.ui.BuildWatermark.install(this)
HomeApp.trackRecents(this)
- // Helper processes must not initialize the main USB provider or guest graphics context.
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
@@ -32,7 +31,6 @@ class App : Application() {
return
}
if (process == "$packageName:xcloudnative") {
- // The native cloud player: the stores log and nothing that touches the Linux session.
com.droiddeck.launcher.stores.StoresState.init(this)
return
}
@@ -62,7 +60,6 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
}
- /** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index ec4c069f4..5fb8eea12 100644
--- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -717,7 +717,6 @@ class FrontEndActivity : ComponentActivity() {
LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false
- // Android finishes activities started through the alias that was just disabled.
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
@@ -1797,7 +1796,6 @@ class FrontEndActivity : ComponentActivity() {
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
- // The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
dualScreenMode = SessionPrefs.dualScreenMode(this)
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
@@ -2491,7 +2489,6 @@ class FrontEndActivity : ComponentActivity() {
!SessionPaths.currentSessionLogsRetained(this)
private fun refresh() {
- // Until the scan finishes, the other client's games would launch in this one.
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
diff --git a/app/src/main/java/com/droiddeck/launcher/HomePins.kt b/app/src/main/java/com/droiddeck/launcher/HomePins.kt
index 1040995e8..38acb1813 100644
--- a/app/src/main/java/com/droiddeck/launcher/HomePins.kt
+++ b/app/src/main/java/com/droiddeck/launcher/HomePins.kt
@@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
-/** Shortcuts other apps asked to put on the home screen. The system keeps the pinned set per launcher. */
object HomePins {
class Pinned(val info: ShortcutInfo, val icon: Bitmap?, val appLabel: String, val userSerial: Long) {
val key get() = "pin:${info.`package`}/${info.id}" + if (userSerial != 0L) "#$userSerial" else ""
diff --git a/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt
index 8f79f4354..886f5f0e8 100644
--- a/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt
+++ b/app/src/main/java/com/droiddeck/launcher/HomeWidgets.kt
@@ -197,7 +197,6 @@ object HomeWidgets {
if (pendingId == AppWidgetManager.INVALID_APPWIDGET_ID) add(activity, provider.info, null)
}
- /** Places a widget another app asked for; its own app has already configured it. */
fun addPinned(activity: Activity, request: LauncherApps.PinItemRequest): Boolean {
if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false
val info = request.getAppWidgetProviderInfo(activity) ?: return false
diff --git a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
index 8e8efc80c..5251b50da 100644
--- a/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ProtonMenu.kt
@@ -20,7 +20,6 @@ internal class ProtonMenu(
private val ui: Handler,
private val idle: () -> Boolean = { true },
) {
- // Install workers and their results outlive the activity that starts them.
private object Operation {
val rows = mutableStateOf>(emptyList())
val busyId = mutableStateOf(null)
diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt
index 28270e195..98e5a0b22 100644
--- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt
@@ -71,7 +71,6 @@ class QuestXrActivity : NativeActivity() {
}
} finally {
synchronized(steamVrLock) {
- // A retiring activity must not stop a replacement's SteamVR session.
if (steamVrOwner === this) {
if (sessionAlive()) SteamVr.run(this, "stop")
steamVrOwner = null
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 3e8809134..d3b018dc0 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -265,7 +265,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (locked) openEvdevMouse() else closeEvdevMouse()
padMouseCheckedAt = 0L
if (::padMouse.isInitialized) padMouseMode()
- // Touchpad deltas are measured from the middle while locked (moveLockedPointer).
if (locked && ::touchpad.isInitialized) {
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
cursorVisible = false
@@ -513,8 +512,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
- // A higher refresh rate allowed in Android's settings does not change the display while this window
- // still asks for a lower mode, so the mode is chosen again when the setting changes.
private val refreshSettingObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
applyOutputPacing()
@@ -571,7 +568,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
finish()
return
}
- // A cancelled install keeps its thread until the download stops; a new launch would reuse its stop flag and close itself.
if (savedInstanceState == null && SessionState.installing != null && !SessionState.running) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
@@ -582,7 +578,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
- // begin() moves the phase on, so the block below never runs for a new session; reset per-session flags here.
SessionState.secondScreenAutoOpened = false
SessionState.secondScreenProvisional = false
}
@@ -718,8 +713,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
- // A real touch target lets FrameLayout split fingers between the
- // game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
if (!event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
@@ -925,7 +918,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
- // Also runs when a second screen appears, so Automatic applies then with the running game known.
LaunchedEffect(AgentGuest.focusRevision, secondScreenDisplays.isNotEmpty()) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
@@ -1119,7 +1111,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
}
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
- // Auto and Native resume on their own as the session comes back; only a failed resume needs the button.
val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE
LaunchedEffect(SessionState.manualPause, SessionState.suspended) {
if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false
@@ -1480,7 +1471,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
))
}
- /** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */
private fun switchVrSticks() {
val chord = { down: Boolean -> padBridge?.applyTouch { state ->
state.press(com.droiddeck.launcher.input.PadState.L3, down)
@@ -2215,8 +2205,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
- // Hiding the phone surface after a handoff delivers a late destruction
- // callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
@@ -2637,10 +2625,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true
if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen) {
- /* The app's own controls get the pointer first. With the picture on the glasses the
- * phone shows its panel, and hovering or clicking a control there must reach it
- * instead of moving and clicking the guest pointer on the glasses. Whatever the UI
- * leaves alone — the rest of the screen — still drives the guest pointer. */
if (appUiOwnsPointer()) {
if (super.dispatchGenericMotionEvent(event)) return true
if (onMouse(event)) return true
@@ -2654,7 +2638,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
- /** Whether one of the on-screen control layers takes a finger going down at this screen point. */
private fun controlTakesTouchAt(rawX: Float, rawY: Float): Boolean {
val at = IntArray(2)
fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean {
@@ -2832,9 +2815,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
- // Gamescope retains the last absolute position while a game can warp
- // its own cursor to the center. Its first unlocked motion must start
- // from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
@@ -3156,7 +3136,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}),
)
- /** Pages for the bottom-screen dashboard; the dashboard is offered only while at least one exists. */
private fun bottomPages(): List = sessionBottomPages(BottomPageActions(
takeScreenshot = ::captureScreenshot,
fps = { if (::hud.isInitialized) hud.liveFps else null },
@@ -3164,9 +3143,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
runningGame = { gestureGame },
))
- // A settings page opened on the bottom screen sits under the dashboard window, so the dashboard hides
- // until the setting is on, or for at most SETTINGS_WATCH_CHECKS seconds when it is left off (another
- // app's page closing cannot be seen); choosing Dashboard in the menu again shows it sooner.
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
val presentation = secondScreenPresentation ?: return
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
@@ -3178,7 +3154,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!opened) return
presentation.hide()
var checks = 0
- // Show throws if the display went away while the settings page was open, as in showSecondScreen.
fun showAgain() {
try {
presentation.show()
@@ -3199,9 +3174,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, SETTINGS_WATCH_MS)
}
- /** Copies the game frame (no overlay) at the size it is shown and saves it to Pictures/DroidDeck. */
private fun captureScreenshot(onDone: (Boolean) -> Unit) {
- // With the glasses as the output the game is drawn on their surface; the phone's surface is hidden then.
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (::surfaceView.isInitialized) surfaceView else null
val width = source?.width ?: 0
val height = source?.height ?: 0
@@ -3643,23 +3616,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
- /** Null until the first application, so the initial state is applied rather than skipped. */
private var platformPointerShown: Boolean? = null
- /**
- * The app's own UI is what the mouse is over, so it needs a pointer of its own: the drawer,
- * keyboard, achievements and PiP controls, and — once the glasses own the picture — the whole
- * phone, which then shows [GlassesPhonePanel] instead of the game.
- */
private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive
- /**
- * Over the game neither this app nor Android draws a pointer: the compositor's cursor is the
- * only one (Compose's on the phone, inside the picture on the glasses). Over the app's own UI
- * the game gets no pointer events at all, so without this nothing visible follows the mouse
- * while buttons still highlight and click.
- */
private fun applyPlatformPointer(shown: Boolean) {
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return
if (shown == platformPointerShown) return
@@ -3880,7 +3841,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
- // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
private fun capturePausedFrame(manual: Boolean = false) {
if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
if (SessionState.suspended && pausedFrame != null) return
@@ -3925,8 +3885,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.pause(this)
}
- // Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the
- // compositor shows the frozen frame again once its output is back, so copy it from there.
private fun requestPausedFrame() {
if (pausedFrame != null) return
pausedFrameWanted = true
@@ -4163,8 +4121,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Glasses.cancelSteamVrStart()
}
if (glasses.present) {
- // Optional stereo starts only after RUNNING is observed. READY can
- // arrive repeatedly while the launch worker is still starting VR.
if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING
runSteamVrAction(action, ::currentRequest) {
val ok = SteamVr.run(this, action)
@@ -4357,8 +4313,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (holder !== outputHolder()) return
glassesOutputActive = glassesScreen?.holder === holder
if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(glassesOutputActive)
- // Keep the phone's layout for touch/controller coordinates, but remove
- // its SurfaceView so it cannot retain the last game frame under the hint.
surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE
Log.i(TAG, "compositor output attached: " +
if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone")
@@ -4437,8 +4391,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (target != null) {
val screen = GlassesScreen(this, target, Glasses.outputHz(), this, Glasses.outputResolution())
- // show() can create the surface synchronously. Select its holder
- // first, otherwise surfaceCreated() rejects the glasses callback.
glassesScreen = screen
applyOutputPacing()
surfaceW = 0
diff --git a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
index f8f1b2a3e..8d9ddcb1f 100644
--- a/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
+++ b/app/src/main/java/com/droiddeck/launcher/audio/DirectAudioRelayComponent.java
@@ -118,7 +118,6 @@ public class DirectAudioRelayComponent extends SessionPart {
}
}
- // Android keeps the input stream's wake lock while a frozen helper still has it started.
public boolean awaitMicReleased(long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
synchronized (micLock) {
diff --git a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt
index cc6a1007a..383dd98bb 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/AddonPack.kt
@@ -13,12 +13,6 @@ import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
-/**
- * A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
- * DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
- * yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
- * was made with install, whichever host serves them.
- */
internal class AddonPack(
val name: String, private val sha256: String, val size: Long, private val required: List,
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
@@ -43,13 +37,11 @@ internal class AddonPack(
)
private fun root(context: Context) = File(context.filesDir, "addons/$name")
- /** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), version(context))
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
- // A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
@@ -66,7 +58,6 @@ internal class AddonPack(
.also { mutableState.value = it }
}
- /** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
@@ -81,7 +72,6 @@ internal class AddonPack(
}
}
- /** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
@@ -127,7 +117,6 @@ internal class AddonPack(
return true
}
- // A local build pins the packs it compiled, which no release hosts, so it ships them as assets.
internal fun bundled(context: Context, archive: File): Boolean {
val copied = runCatching {
bundle(context).use { input -> archive.outputStream().use { input.copyTo(it) } }
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
index 4163eb4a3..3a40300dc 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
@@ -42,7 +42,6 @@ object DeviceReport {
}
}
- /** Name of an ApplicationExitInfo reason code, so a report says "LOW_MEMORY" instead of "3". */
internal fun exitReasonName(reason: Int): String = when (reason) {
0 -> "UNKNOWN"
1 -> "EXIT_SELF"
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
index b11e3113a..d71f45120 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
@@ -126,11 +126,9 @@ object DownloadSource {
fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") }
- /** Catalogs carry the digests everything else is checked against, so a mirror must never supply one. */
fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) }
fun candidates(target: String): List {
- // GitHub API metadata carries executable URLs and digests; never proxy it.
if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target)
val prefs = prefs() ?: return listOf(target)
if (!isGithub(target)) return listOf(target)
@@ -184,7 +182,6 @@ object DownloadSource {
prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply()
}
- /** An HTTP proxy every Downloader connection uses, or null when none is configured. */
fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "")
internal fun proxyOf(value: String): Proxy? {
diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
index 31e8f45f5..19cec5acb 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
+++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
@@ -91,7 +91,6 @@ public final class Downloader {
if (isCancelled(cancelled)) return null;
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
Fetch fetch = fetch(sources.get(i), cancelled);
- // A cancelled fetch says nothing about the source, so it must not count as a failure.
if (isCancelled(cancelled)) return null;
if (fetch.body != null) {
DownloadSource.INSTANCE.succeeded(sources.get(i));
@@ -103,7 +102,6 @@ public final class Downloader {
return null;
}
- /** Fetches without the mirror list, for a URL whose body is a digest the payload is checked against. */
public static String downloadDirect(String url) {
return fetch(url).body;
}
@@ -113,13 +111,11 @@ public final class Downloader {
return downloadFile(url, destination, resume, null, progress);
}
- /** Verify each candidate before accepting it, so a bad mirror can fall back. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.Consumer progress) {
return downloadFile(url, destination, resume, expectedSha256, null, progress);
}
- /** {@code cancelled} is polled while bytes arrive; a cancel stops at once and leaves the partial file for the caller. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.BooleanSupplier cancelled,
java.util.function.Consumer progress) {
@@ -128,13 +124,11 @@ public final class Downloader {
return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, progress);
}
- /** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */
static List sources(String url, String expectedSha256) {
return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url);
}
- // Package-private for the local multi-source HTTP test.
static boolean downloadCandidates(String original, List sources, File destination, boolean resume,
String expectedSha256, java.util.function.Consumer progress) {
return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress);
@@ -149,7 +143,6 @@ public final class Downloader {
if (isCancelled(cancelled)) return false;
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress);
- // A cancel is not a source failure: no other mirror, and no DownloadSource.failed().
if (outcome == Outcome.CANCELLED) return false;
if (outcome == Outcome.DONE) {
if (expectedSha256 != null) {
@@ -192,7 +185,6 @@ public final class Downloader {
return value != null && !value.isEmpty() && !value.startsWith("W/");
}
- /** Only reuse saved bytes when we persisted a strong validator and their source. */
private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) {
Identity identity = new Identity();
File marker = resumeFile(dest);
@@ -213,7 +205,6 @@ public final class Downloader {
return identity;
}
} catch (IOException e) {
- // Legacy partials or interrupted metadata writes have no trustworthy identity.
}
if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest);
return identity;
@@ -239,7 +230,7 @@ public final class Downloader {
try (FileOutputStream out = new FileOutputStream(marker)) {
p.store(out, "DroidDeck partial download validator");
} catch (IOException e) {
- marker.delete(); // No valid metadata means no future resume.
+ marker.delete();
Log.w(TAG, "cannot persist download validator", e);
}
}
@@ -252,7 +243,6 @@ public final class Downloader {
long have = resume && destination.isFile() ? destination.length() : 0;
try {
if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) {
- // A new source or unvalidated bytes must restart from byte zero.
if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0;
}
@@ -286,7 +276,6 @@ public final class Downloader {
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
}
if (!appending && have > 0) {
- // If-Range may legitimately return full 200 when representation changed.
if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0;
}
@@ -333,14 +322,12 @@ public final class Downloader {
}
}
- /** True when a `Content-Range: bytes START-END/TOTAL` header starts exactly at {@code start}. */
static boolean rangeStartsAt(String contentRange, long start) {
if (contentRange == null) return false;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange);
return m.matches() && Long.parseLong(m.group(1)) == start;
}
- /** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
static long rangeTotal(String contentRange) {
if (contentRange == null) return -1;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
@@ -390,7 +377,6 @@ public final class Downloader {
}
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
- // An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
Proxy proxy = DownloadSource.INSTANCE.httpProxy();
HttpURLConnection connection = (HttpURLConnection) (proxy != null
? new URL(url).openConnection(proxy) : new URL(url).openConnection());
diff --git a/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt b/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt
index 8cbf9bf39..3e4bb5305 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/GlobalSettings.kt
@@ -5,7 +5,6 @@ import android.net.Uri
import android.provider.Settings
object GlobalSettings {
- // Settings.Global caches values in-process and misses deletions made over adb; ask the provider directly.
fun read(context: Context, name: String): String? {
val uri = Uri.withAppendedPath(Settings.Global.CONTENT_URI, name)
context.contentResolver.query(uri, arrayOf(Settings.NameValueTable.VALUE), null, null, null)?.use { cursor ->
@@ -19,6 +18,5 @@ object GlobalSettings {
fun developerOptionsOn(context: Context): Boolean = isOn(context, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED)
- // Settings.Global.ADB_WIFI_ENABLED is hidden; the key is stable since Android 11.
fun wirelessDebuggingOn(context: Context): Boolean = isOn(context, "adb_wifi_enabled")
}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
index f579adff0..3230e8ea4 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt
@@ -11,7 +11,6 @@ object LogRedactor {
size > 32
}
- // Android matchers own native state, so reuse them instead of creating one for every log line.
private val matchers = object : ThreadLocal() {
override fun initialValue(): MatcherCache = MatcherCache()
}
@@ -145,7 +144,6 @@ object LogRedactor {
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
Learned(Regex("(?i)(?,
- modifier: Modifier = Modifier,
- links: List = emptyList(),
-) {
- Column(modifier = modifier.fillMaxWidth()) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- items.forEach { item ->
- TopTabCell(
- icon = item.icon,
- label = item.label,
- selected = item.selected,
- onClick = item.onClick,
- modifier = Modifier.weight(1f),
- )
- }
- if (links.isNotEmpty()) {
- Box(
- modifier = Modifier
- .padding(horizontal = 2.dp)
- .width(1.dp)
- .height(30.dp)
- .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)),
- )
- links.forEach { link ->
- TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, isTab = false, modifier = Modifier.weight(1f))
- }
- }
- }
- HorizontalDivider()
- }
-}
-
-@Composable
-private fun TopTabCell(
- icon: ImageVector,
- label: String,
- selected: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- isTab: Boolean = true,
-) {
- val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = modifier
- .clip(RoundedCornerShape(9.dp))
- .background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent)
- .focusGlide(RoundedCornerShape(9.dp))
- .then(
- if (isTab) {
- Modifier.selectable(selected = selected, role = androidx.compose.ui.semantics.Role.Tab, onClick = onClick)
- } else {
- Modifier.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
- },
- )
- .padding(vertical = 6.dp, horizontal = 1.dp),
- ) {
- Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(20.dp))
- Spacer(Modifier.height(3.dp))
- Text(
- label,
- color = tint,
- fontSize = 8.5.sp,
- fontWeight = FontWeight.Bold,
- letterSpacing = 0.2.sp,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- textAlign = TextAlign.Center,
- )
- Spacer(Modifier.height(2.dp))
- Box(
- modifier = Modifier
- .height(2.dp)
- .width(if (selected) 16.dp else 0.dp)
- .clip(RoundedCornerShape(3.dp))
- .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent),
- )
- }
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
index 05d467ef5..6f5988fc1 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
@@ -57,7 +57,6 @@ object FileOps {
return true
}
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
- // Once the destination is committed, finish deleting the source.
return delete(src)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
index 273917b00..b19a2e987 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt
@@ -63,7 +63,6 @@ object StorageRoots {
}
return true
}
- // vold-managed locations only hold removable volumes, whatever the fstype is.
return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/")
}
@@ -92,8 +91,6 @@ object StorageRoots {
.forEachIndexed { index, volume ->
val uuid = volume.uuid?.takeIf { it.isNotBlank() }
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
- // Unmounted volumes often report neither uuid nor directory; never drop the
- // volume or a plugged-in drive stays invisible with no explanation.
val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
@@ -108,8 +105,6 @@ object StorageRoots {
if (name != null) {
entry.candidates += File("/storage/$name")
entry.candidates += File("/mnt/media_rw/$name")
- // Only strict uuid matches own an app directory; a loose match would
- // attach another volume's folder to this entry.
if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) }
.forEach { entry.seeds += it }
}
@@ -122,8 +117,6 @@ object StorageRoots {
File("/mnt/media_rw").listFiles().orEmpty().forEach { child ->
if (child.isDirectory) volumeOf(child.name).candidates += child
}
- // USB OTG drives do not always appear as storage volumes or under /storage for
- // third-party apps, but their kernel mounts are still visible in /proc/mounts.
for (mount in externalMounts()) {
val entry = volumeOf(mount.name)
if (entry.label == null) entry.label = mount.name
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index 52806833a..20f158bc5 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -180,7 +180,6 @@ object AddedGames {
private fun entryFolders(context: Context): Set = AddedExes.list(context).map { it.folder }.toSet()
- /** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
@@ -315,7 +314,6 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult {
val app = context.applicationContext
- // Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt b/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt
index 330265a0f..0983a2997 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/DependencyDetector.kt
@@ -54,21 +54,6 @@ object DependencyDetector {
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
)
- fun detectForExe(exeFile: File): List {
- val start = exeFile.absoluteFile.parentFile ?: return emptyList()
- var d: File? = start
- var depth = 0
- while (d != null && depth < 4) {
- val hasRedistContainer = try {
- d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
- } catch (_: Throwable) { false }
- if (hasRedistContainer) return detect(d)
- d = d.parentFile
- depth++
- }
- return detect(start)
- }
-
fun detect(gameDir: File): List {
if (!gameDir.isDirectory) return emptyList()
val hits = LinkedHashMap()
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
index c623e4223..896d32d8d 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
@@ -87,7 +87,6 @@ object Library {
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List> {
val root = SessionPrefs.steamRoot(context, arch)
- // The session gives the x86-64 client only its card folder; the games folders stay the arm64 client's.
val card = GameStorage.effective(context)
if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label })
return listOfNotNull(
@@ -106,7 +105,6 @@ object Library {
.firstOrNull { it.isDirectory }
}
- /** Every steamapps/compatdata folder, in the libraries [protonPrefix] searches. */
fun compatdataDirs(context: Context, libraries: List = steamLibraries(context).map { it.first }): List =
prefixRoots(context, libraries).map { File(it, "steamapps/compatdata") }.filter { it.isDirectory }
@@ -179,7 +177,6 @@ object Library {
}
}
- /** Apps the user hid (the "hidden" collection) or marked private in Steam. */
internal fun concealedSteamApps(root: File): Set {
val config = AddedGames.accountConfig(root) ?: return emptySet()
val hidden = runCatching {
@@ -220,7 +217,6 @@ object Library {
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
}
- /** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */
internal fun shortcutsLastPlayed(root: File): Map = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt
index cd30e400c..6fa35c753 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt
@@ -10,7 +10,6 @@ object LibraryCache {
private const val TAG = "LibraryCache"
private fun file(context: Context) = File(context.filesDir, "library-cache.json")
- /** The cached games and whether they are the x86-64 client's; a cache written as a bare list is arm64's. */
@Synchronized
fun snapshot(context: Context): Pair, Boolean> = runCatching {
val f = file(context)
@@ -36,7 +35,6 @@ object LibraryCache {
fun listedX64(context: Context): Boolean = snapshot(context).second
- /** Without [x64] the list keeps the client already recorded. */
@Synchronized
fun save(context: Context, games: List, x64: Boolean? = null) {
runCatching {
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt
index a047da09e..64491df82 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/LogoPosition.kt
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.frontend
import org.json.JSONObject
-/** Where Steam pins a game's logo on its hero image and how big it draws it; the same JSON Steam keeps in `.json`. */
object LogoPosition {
enum class Pin(val steam: String) {
UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter");
@@ -17,7 +16,6 @@ object LogoPosition {
const val MIN_PCT = 10
const val MAX_PCT = 100
- /** What Steam draws when a game has no custom position. */
val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50)
private const val VERSION = 1
@@ -25,7 +23,6 @@ object LogoPosition {
fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) }
- /** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */
fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into
.put("nVersion", VERSION)
.put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct))
@@ -39,7 +36,6 @@ object LogoPosition {
return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT))
}
- /** [existing] without the logo position; null when nothing but the version number would be left. */
fun without(existing: JSONObject): JSONObject? {
existing.remove(KEY)
val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList()
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt
index 88526f1bf..e52defb7d 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt
@@ -7,11 +7,9 @@ import org.json.JSONObject
import java.io.File
import java.util.UUID
-/** Steam keeps a game's custom art in `userdata//config/grid`; this is where DroidDeck's choices and Steam's meet. */
object SteamGrid {
private const val TAG = "SteamGrid"
- /** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
internal val lock = Any()
private const val MANIFEST = ".droiddeck-art.json"
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
@@ -36,7 +34,6 @@ object SteamGrid {
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
- /** Each Steam client has its own grid folder, so what was last seen there is kept per client. */
private fun sigName(context: Context, key: String) =
if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key"
@@ -45,10 +42,6 @@ object SteamGrid {
private const val GRID_ID = ".droiddeck-grid"
private const val UNKNOWN_GRID = "?"
- /**
- * A random id kept in the grid folder, so a record made against another account's folder, or one wiped with the
- * runtime, reads as "not seen yet" instead of "removed in Steam". "?" never matches, so nothing is dropped.
- */
private fun gridId(grid: File): String = runCatching {
val f = File(grid, GRID_ID)
f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id ->
@@ -83,7 +76,6 @@ object SteamGrid {
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
}.getOrDefault(emptySet())
- /** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
@@ -116,7 +108,6 @@ object SteamGrid {
private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse)
- /** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
private fun positionRecorded(context: Context, grid: File, target: Target): String? =
@@ -132,12 +123,10 @@ object SteamGrid {
return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess
}
- /** Sets (or with null, clears) a game's logo position here and in the client's grid folder, whether or not Steam is running. */
fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) {
placePosition(context, target, value) && storePosition(target, value)
}
- /** Steam keeps the position in `.json`; anything else that file holds is left as it was. */
private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean {
val grid = dir(context) ?: return true
val file = positionFile(grid, target.id)
@@ -160,7 +149,6 @@ object SteamGrid {
return true
}
- /** A `logoPosition` this version cannot read (a pin it does not know, say) is left alone rather than read as "removed". */
private fun hasUnknownPosition(file: File): Boolean {
val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false
return json.has("logoPosition") && LogoPosition.parse(json) == null
@@ -176,7 +164,6 @@ object SteamGrid {
when {
before == null && own != null && theirs == null -> placePosition(context, target, own)
before == null || now != before -> {
- // Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
if (theirs != null) {
if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now)
} else {
@@ -187,7 +174,6 @@ object SteamGrid {
}
}
- /** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
fun sync(context: Context, targets: List): Boolean = synchronized(lock) { reconcile(context, targets) }
private fun reconcile(context: Context, targets: List): Boolean {
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt
index e022ebc8e..a287c2299 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamRedists.kt
@@ -15,7 +15,6 @@ object SteamRedists {
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"),
)
- /** Redists a game's own install script runs on Windows that its shared depots don't name. */
val SCRIPTED = mapOf(
72850 to listOf("xact" to "DirectX XACT audio"),
489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"),
@@ -28,7 +27,6 @@ object SteamRedists {
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
}
- /** Redists the game's own install script has Steam run on Windows, from its "Run Process" entries. */
fun installScript(gameDir: File?): List> =
gameDir?.listFiles { f ->
f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") }
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
index 8e91102f3..22044c76d 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
@@ -51,10 +51,8 @@ object WinCompSources {
fun installer(path: String): List = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
private val XBOX_KNOWN = Regex("""> {
val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 }
val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty()
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt
index fe09a7809..f14d92da2 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt
@@ -563,7 +563,6 @@ object Glasses {
val device = selected?.first
val m = selected?.second
val controllable = m?.let(::canOpenModel) == true
- // XREAL uses Android's Ethernet sockets, without opening or claiming USB interfaces.
val permitted = controllable && device != null && (m === XrealModel || usb.hasPermission(device))
val identity = device?.let {
GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null)
@@ -677,8 +676,6 @@ object Glasses {
internal fun selectGlassesDisplay(displays: List, usbPresent: Boolean): Display? {
val candidates = displays
.filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 }
- // Android can expose the glasses name on either the logical display or
- // its product metadata. A generic label on one must not hide the other.
return candidates.firstOrNull { display ->
isGlassesDisplayName(display.name) ||
(Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product ->
@@ -728,7 +725,6 @@ object Glasses {
if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) {
VitureV2Link.open(usb, device, v2Listener(generation))
} else if ((model as? VitureProtocol.Model)?.needsSdk == true) {
- // Android can evict cacheDir while the SDK is running.
Log.i(TAG, "opening VITURE SDK pid=0x%04X spatial=%s".format(device.productId, spatial))
VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation),
(model as? VitureProtocol.Model)?.family, spatial)
@@ -854,7 +850,6 @@ object Glasses {
if (generation != linkGeneration) return@post
closeLink()
publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) }
- // Let a reconnect or explicit Allow retry restart the SDK, not an endless failure loop.
}
}
@@ -974,7 +969,6 @@ object Glasses {
}
if (!s.usable || s.sbs || modeState.pendingStereo) return
val hz = _settings.value.refreshHz
- // Native 30 Hz is selected by the Android presentation, without a vendor mode command.
if (hz == 30 && GlassesRendering.native30HzAvailable(_settings.value, s)) return
if (glassesMode == m.mode(hz, false, outputResolution())) return
if (!force) {
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt
index 5de0f03f5..3bf992cbd 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.glasses
-/** Hardware properties; display refresh is selected from Android's advertised modes. */
interface GlassesModel {
val name: String
val controllable: Boolean
@@ -18,9 +17,6 @@ interface GlassesModel {
fun modeForStep(step: Int): Int?
}
-/** The command IDs here are the existing glasses controller's internal vocabulary.
- * Each transport translates them to its own wire protocol; unsupported controls return false.
- */
internal interface GlassesLink {
val hasImu: Boolean
val badFrames: Long get() = 0L
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt
index cefe3d932..501aaae1b 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesMenuSurface.kt
@@ -63,7 +63,6 @@ class GlassesMenuSurface(
override fun onDetachedFromWindow() {
close()
super.onDetachedFromWindow()
- // GLSurfaceView may exit its GL thread before queued cleanup is consumed.
releaseInput()
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt
index b07221556..01df9fe56 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.gpu.Cardboard
-/** Share the actual picture mode with settings so controls match the rendered view. */
internal object GlassesRendering {
enum class Picture { FLAT, DUPLICATED, VIRTUAL_SCREEN, STEREO }
@@ -51,11 +50,7 @@ internal object GlassesRendering {
outputActive: Boolean,
steamVrRunning: Boolean,
): Cardboard {
- // Mirror output still uses the glasses' picture and sensors during VR.
if (!usesGlasses(state, outputActive, steamVrRunning)) {
- // SteamVR uses the renderer's enabled flag as headset proximity.
- // A flat phone preview still needs an active HMD and one SBS eye;
- // otherwise SteamVR enters standby and leaves a frozen frame.
return if (steamVrRunning && !base.enabled) base.copy(
enabled = true,
source = Cardboard.SOURCE_SBS,
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt
index c15e9c623..43f80bfc3 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt
@@ -36,7 +36,6 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private
fun prefer(hz: Int, resolution: GlassesResolution? = null) {
wantHz = hz
wantResolution = resolution
- // A 2D/SBS transition can replace the active mode without changing Hz.
applyMode()
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt b/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt
index b6aa9844a..a3d94cd6a 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/HandImageBuffer.kt
@@ -51,7 +51,6 @@ internal class HandImageBuffer {
pixels.clear()
pixels.put(rgb)
pixels.rewind()
- // MediaPipe applies the model metadata's float normalization to these RGB bytes.
return ByteBufferImageBuilder(pixels, width, height, MPImage.IMAGE_FORMAT_RGB).build()
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt b/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt
index 6a483a1a0..5f6175474 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/LumaRgbDiagnostic.kt
@@ -150,7 +150,6 @@ class LumaRgbCaptureService : Service() {
intent?.getIntExtra("durationMs",5000) ?: 5000) }
finally {
stopSelf()
- // SDK 2.4.0 can crash while cancelling failed USB transfers; isolate each diagnostic.
android.os.Process.killProcess(android.os.Process.myPid())
}
}, "luma-rgb-diagnostic").start()
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt
index 8e579aee3..23ebcce81 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt
@@ -40,12 +40,9 @@ internal data class RayNeoCalibration(val gyroBiasDps: Vec3, val level: Quaterni
internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) {
companion object {
- // GT and GT Max can share USB identity fields. Keep the non-Max mounting
- // correction separate from existing Max profiles and their legacy keys.
fun profileKey(base: String, gtUsb: Boolean, boardId: Int): String =
if (gtUsb && boardId == RayNeoProtocol.BOARD_GT) "$base:gt-standard-v1" else base
}
- // A serial isolates physical units. Devices without one share their model profile.
fun key(vid: Int, pid: Int, serial: String?, modelName: String = ""): String =
"rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}"
fun load(key: String): RayNeoCalibration? = RayNeoCalibration.decode(prefs.getString(key, null))
@@ -55,7 +52,6 @@ internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) {
}
}
-/** One upside-down stationary pose estimates gyro bias and tilt, never yaw or accelerometer scale. */
internal class RayNeoCalibrationCollector(private val axes: RayNeoSensorAxes, private val beganMs: Long) {
var phase = CalibrationPhase.SETTLING; private set
var progress = 0; private set
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt
index c9770ed22..78c2275b4 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt
@@ -6,7 +6,6 @@ import android.hardware.usb.UsbManager
import android.os.SystemClock
import android.util.Log
-/** RayNeo command/response and IMU share one endpoint. Keep reads on a single worker. */
internal class RayNeoLink private constructor(
private val connection: UsbDeviceConnection,
private val endpoints: RayNeoEndpointSelection,
@@ -52,7 +51,6 @@ internal class RayNeoLink private constructor(
ready && (mode == RayNeoProtocol.CMD_SWITCH_TO_3D || mode == RayNeoProtocol.CMD_SWITCH_TO_2D) &&
command(mode)
}
- // No VITURE brightness, volume, film, rate or raw command is sent to RayNeo.
else -> false
}
}
@@ -159,7 +157,6 @@ internal class RayNeoLink private constructor(
}
val wxyz = filter.update(packet.sample)
if (wantImu && collector == null && lastSensor >= warmupUntil) {
- // Normalize basis and component order at the hardware boundary.
listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz),
SystemClock.elapsedRealtimeNanos())
}
@@ -176,7 +173,6 @@ internal class RayNeoLink private constructor(
override fun close() {
synchronized(writeLock) {
if (!running) return
- // Best effort: stop the sensor before releasing the claimed interface.
if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) }
running = false
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt
index ccb19f744..72c88b34b 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt
@@ -8,8 +8,6 @@ object RayNeoModel {
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (!isVendor(vid)) return null
- // GT's identity is confirmed by verncat's SDK; do not claim other products
- // from that vendor just because their name resembles a supported model.
val gt = RayNeoProtocol.isGt(vid, pid)
if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null
if (!(vid == RayNeoProtocol.VENDOR_ID && pid == RayNeoProtocol.PRODUCT_ID) &&
@@ -23,8 +21,6 @@ object RayNeoModel {
name.contains("rayneo", ignoreCase = true) || name.contains("ray neo", ignoreCase = true) ||
modelName.containsMatchIn(name)
- // Observed on GT Max: Android exposes an unbranded EDID monitor name.
- // Match its product metadata, not every display called SmartGlasses/TCL.
fun isDisplayProduct(name: String?, manufacturer: String?, productId: String?): Boolean =
name?.trim().equals("SmartGlasses", ignoreCase = true) &&
manufacturer.equals("TCL", ignoreCase = true) && productId == "980"
@@ -33,7 +29,7 @@ object RayNeoModel {
override val controllable = true
override val brightnessMax = 0
override val volumeMax = 0
- override val diagonalFovDeg = 46f // Conservative existing default, not a calibrated model profile.
+ override val diagonalFovDeg = 46f
override val sbs90 = false
override fun mode(hz: Int, sbs: Boolean) =
if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt
index 96bff8500..22a3a8965 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt
@@ -1,5 +1,4 @@
-// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0).
-// See docs/third-party/rayneo/. Returns scaffold quaternions in (w, x, y, z) order.
+// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0); see docs/third-party/rayneo/ for license and attribution.
package com.droiddeck.launcher.glasses
import kotlin.math.cos
@@ -123,10 +122,6 @@ internal class RayNeoOrientationFilter(
private var lastRealtimeNs: Long = 0L
companion object {
- /** Scaffold: right +X, up +Y, back +Z. VITURE: right -Y, up +Z, back -X.
- * Conjugate by HeadPose.GLASSES_AXES before entering the unchanged VITURE pose path.
- * This is a basis conversion, not a projection or seated-origin adjustment.
- */
fun glassesPose(wxyz: FloatArray): FloatArray =
floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0])
}
@@ -168,8 +163,6 @@ internal class RayNeoOrientationFilter(
if (accelNorm > 1e-3f) {
val measuredUp = accel.normalized()
if (!gravityInitialized) {
- // Align gravity before publishing the first pose. Starting at
- // identity and settling after recenter can rotate the horizon.
val axis = measuredUp.cross(worldUp)
q = if (measuredUp.y < -0.9999f) {
Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
@@ -179,7 +172,6 @@ internal class RayNeoOrientationFilter(
gravityInitialized = true
}
val predictedUp = q.conjugate().rotate(worldUp).normalized()
- // q maps body to world: measured x predicted reduces gravity error.
val correction = measuredUp.cross(predictedUp)
gyro += correction * accelGain
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt
index 9f65b04df..781f0555d 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt
@@ -40,14 +40,13 @@ data class RayNeoEndpointSelection(
object RayNeoProtocol {
const val VENDOR_ID = 0x1BBB
const val PRODUCT_ID = 0xAF50
- // verncat/RayNeo-Air-3S-Pro-OpenVR PR #3 (MIT), tested on GT hardware.
const val GT_VENDOR_ID = 0x3941
private const val GT_CONTROL_INTERFACE = 5
fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID
const val BOARD_AIR_4_PRO = 0x3A
- const val BOARD_GT = 0x40 // Observed on the user's non-Max GT.
+ const val BOARD_GT = 0x40
const val PKT_MAGIC = 0x99
const val PKT_SENSOR = 0x65
@@ -131,7 +130,6 @@ object RayNeoProtocol {
endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
continue
}
- // Exclude small button reports from GT's protocol transport.
if (gt && endpoint.maxPacketSize < 64) continue
if (endpoint.direction == UsbConstants.USB_DIR_IN && inEndpoint == null) {
@@ -159,8 +157,6 @@ object RayNeoProtocol {
}
}
- // Interface numbers can change with firmware. Use a unique protocol-sized
- // pair when interface 5 is absent, never an arbitrary composite interface.
return if (gt) gtCandidates.singleOrNull() else fallback
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt
index dbf10a6e6..3b1002eba 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidLink.kt
@@ -9,7 +9,6 @@ import android.hardware.usb.UsbManager
import android.os.SystemClock
import android.util.Log
-/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
internal class RokidLink private constructor(
private val connection: UsbDeviceConnection,
private val usbInterface: UsbInterface,
@@ -44,7 +43,7 @@ internal class RokidLink private constructor(
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
- else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
+ else -> false
}
}
@@ -54,7 +53,6 @@ internal class RokidLink private constructor(
if (!running) return null
val buffer = ByteArray(64)
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
- // Display mode is reported in the second byte, brightness in the first.
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
if (count > offset) buffer[offset].toInt() and 0xff else null
}
@@ -90,7 +88,6 @@ internal class RokidLink private constructor(
if (enabled && !wasTracking) fusion = ImuFusion()
wasTracking = enabled
if (!enabled) continue
- // Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt
index 35227206b..62d5ab81d 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/RokidModel.kt
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer
import java.nio.ByteOrder
-/** Rokid Air/Max share one USB identity; Max 2 has a separate product ID. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS
@@ -36,7 +35,6 @@ internal class RokidModel private constructor(override val name: String, private
const val MAX2_PRODUCT_ID = 0x2002
const val BRIGHTNESS_STEPS = 10
- // Vendor control requests, device recipient.
const val REQUEST_SET_DISPLAY = 0x01
const val REQUEST_GET_DISPLAY = 0x81
const val REQUEST_SET_BRIGHTNESS = 0x02
@@ -51,7 +49,6 @@ internal class RokidModel private constructor(override val name: String, private
const val MODE_SBS_1200_60 = 5
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
- // 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
@@ -60,7 +57,6 @@ internal class RokidModel private constructor(override val name: String, private
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
- /** Device brightness is 0..100; the shared slider uses ten steps. */
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
}
@@ -74,7 +70,6 @@ internal object RokidProtocol {
private const val SENSOR_ACCEL = 1
private const val SENSOR_GYRO = 2
- /** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
class Parser {
private var accel: Vec3? = null
private var accelTime = -1L
@@ -87,7 +82,7 @@ internal object RokidProtocol {
return when (packet[0].toInt() and 0xff) {
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
SEPARATE -> {
- val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
+ val time = b.getLong(9) * 1000
when (packet[1].toInt() and 0xff) {
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt
index 027f0b92e..8c08316d5 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoCalibration.kt
@@ -26,7 +26,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
val source get() = if (bundledBase && !verified) "luma-ultra-base" else "device"
private val p1 = Mat.zeros(3, 4, CvType.CV_64F)
private val p2 = Mat.zeros(3, 4, CvType.CV_64F)
- private val undistortionMaps = Array(2) { arrayOf(Mat(),Mat()) }
init {
val a = Mat.eye(3, 4, CvType.CV_64F)
val b = Mat.zeros(3, 4, CvType.CV_64F)
@@ -35,18 +34,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
Core.gemm(k1, a, 1.0, empty, 0.0, p1); Core.gemm(k2, b, 1.0, empty, 0.0, p2)
a.release(); b.release(); empty.release()
}
- fun undistort(src: Mat, dst: Mat, right: Boolean) {
- val k = if (right) k2 else k1; val d = if (right) d2 else d1
- val maps=undistortionMaps[if(right) 1 else 0]
- if(maps[0].empty()) {
- val identity=Mat.eye(3,3,CvType.CV_64F)
- try {
- if(fisheye) Calib3d.fisheye_initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1])
- else Calib3d.initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1])
- } finally { identity.release() }
- }
- Imgproc.remap(src,dst,maps[0],maps[1],Imgproc.INTER_LINEAR)
- }
fun triangulateRaw(left: List, right: List): List? {
val points = triangulateLandmarksRaw(left,right) ?: return null
return if(points.any { it == null }) null else points.filterNotNull()
@@ -74,10 +61,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
return triangulatePoints(l.toList(),r.toList())
} finally { l.release(); r.release(); identity.release() }
}
- fun triangulate(left: List, right: List): List? {
- val points = triangulatePoints(left,right) ?: return null
- return if(points.any { it == null }) null else points.filterNotNull()
- }
private fun triangulatePoints(left: List, right: List): List? {
if (left.size != right.size || left.isEmpty()) return null
val a = Mat(2, left.size, CvType.CV_64F); val b = Mat(2, right.size, CvType.CV_64F); val out = Mat()
@@ -105,7 +88,7 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
val tmp = File(file.parentFile,file.name+".tmp"); tmp.writeText(j.toString())
java.nio.file.Files.move(tmp.toPath(),file.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
- override fun close() { (listOf(k1,d1,k2,d2,r,t,p1,p2)+undistortionMaps.flatMap { it.toList() }).forEach { it.release() } }
+ override fun close() { listOf(k1,d1,k2,d2,r,t,p1,p2).forEach { it.release() } }
companion object {
fun load(file: File, identity: String, width: Int, height: Int): StereoCalibration? =
runCatching { StereoCalibrationProfile.parse(file.readText(), identity, width, height)?.let(::fromProfile) }.getOrNull()
@@ -286,7 +269,6 @@ internal class StereoCalibrator(private val context: Context) : AutoCloseable {
Mat().also { output -> corrected.add(output); Calib3d.fisheye_undistortPoints(pixels,output,k,d,identity,k) }
}
try {
- // OpenCV 4.13 fisheye stereo fitting assumes equal corner counts in every view.
Calib3d.stereoCalibrate(objects,images(left,k1,d1),images(right,k2,d2),k1,if(fish)zero else d1,k2,if(fish)zero else d2,
size,r,t,e,f,Calib3d.CALIB_FIX_INTRINSIC)
} finally { (corrected+listOf(e,f,identity,zero)).forEach(Mat::release) }
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt
index becad63a0..78aad3f61 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoHandEngine.kt
@@ -383,7 +383,6 @@ internal class StereoHandEngine(private val context: Context, private val token:
message=context.getString(R.string.spatial_frame_changed)
calibrationProgress = calibrationProgress.copy(collecting = false, capturing = false, captureMessage = message)
}
- // The callback pose's basis is undocumented; use canonical GL pose history.
val observation=HandObservation(time,generation,trackingFrame)
var captureHead=observation.headPose(SpatialTracking.session,metadata[6]>0)
val w=metadata[0].toInt();val h=metadata[1].toInt()
@@ -499,7 +498,6 @@ internal class StereoHandEngine(private val context: Context, private val token:
dl=pair.first.hands;dr=pair.second.hands
if(scheduled) inferenceSchedule.observe(stamp,dl.map { it.side },dr.map { it.side },pair.first.rawCount,pair.second.rawCount)
val inferenceMs=(System.nanoTime()-inferenceStart)/1e6f
- // VIO callbacks can arrive during inference; verify this exact camera stamp before publishing.
captureHead=observation.headPose(SpatialTracking.session,
metadata[6]>0 || VitureSdk.nativeCameraSynchronized(token,metadata[2]))
val hands=Array(2) { SpatialHand() }
@@ -631,7 +629,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.publish(System.nanoTime())
}
}
- } catch(e: InterruptedException) { /* Shutdown interrupts the idle wait. */ }
+ } catch(e: InterruptedException) { }
catch(e: Throwable) {
message=context.getString(R.string.spatial_hand_error, e.message.orEmpty())
val unavailable=HandObservationDiagnostic(HandObservationOutcome.INFERENCE_UNAVAILABLE)
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt b/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt
index 351fe332e..a8c994504 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/StereoInferenceRunner.kt
@@ -31,7 +31,6 @@ internal class StereoInferenceRunner : AutoCloseable {
catch (e: ExecutionException) { throw e.cause ?: e }
}
} finally {
- // Native inference must finish before either camera's pixels or detector can be reused.
if (interrupted) Thread.currentThread().interrupt()
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt b/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt
index 6bd9c6b15..ce119c044 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/UvcRgbDiagnostic.kt
@@ -12,8 +12,6 @@ internal object UvcRgbDiagnostic {
internal data class UvcStreamHealth(val observationMs: Double, val observedFps: Double,
val maximumGapMs: Double, val healthy: Boolean) {
companion object {
- // Health requires the full requested window, 80% of requested FPS, and no gap
- // (including the final silent interval) above max(250 ms, three frame periods).
fun assess(metadata: LongArray, requestedFps: Int, durationMs: Int): UvcStreamHealth {
require(metadata.size == 14 && requestedFps > 0 && durationMs > 0)
val first = metadata[7]
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt
index 573f0f8dc..55302dfc3 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt
@@ -81,7 +81,6 @@ object VitureProtocol {
}
}
- /** SDK validates the PID before returning this market name. Never guess a new PID. */
fun sdkModel(marketName: String): Model? {
val family = when (marketName) {
"One", "Lite" -> Family.ONE
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt
index 1b5f09e3d..bdad575c1 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
import android.util.Log
import kotlin.math.sqrt
-/** JNI adapter to VITURE's Android C SDK. The vendor binary is a local build input. */
internal object VitureSdk {
val available: Boolean by lazy {
runCatching {
@@ -27,7 +26,6 @@ internal object VitureSdk {
external fun nativeSpatialDiagnostics(token: Long): DoubleArray?
external fun nativeTracking(token: Long, enabled: Boolean): Int
external fun nativeCommand(token: Long, command: Int, value: Int): Int
- /** Returns CLOCK_BOOTTIME receipt time, zero for no new pose, or a negative SDK error. */
external fun nativePose(token: Long, wxyz: FloatArray): Long
external fun nativeNextState(token: Long, event: IntArray): Int
external fun nativeClose(token: Long)
@@ -48,12 +46,9 @@ internal object VitureSpatialIdentity {
}
internal object VitureSdkPose {
- // Pro 2 hardware captures use OpenGL axes despite the generic Gen2 callback's
- // NWU documentation. Keep that model-specific exception out of other Gen2 devices.
fun usesOpenGlAxes(carina: Boolean, family: VitureProtocol.Family?) =
carina || family == VitureProtocol.Family.PRO2
- /** SDK quaternions are wxyz; normalize their coordinate basis at the boundary. */
fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? {
if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null
val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat())
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt
index 36ceedf6e..fe42adc71 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt
@@ -7,7 +7,6 @@ import android.os.SystemClock
import android.util.Log
import java.io.File
-/** SDK owns interface selection (including Ultra bulk endpoints) and all USB I/O. */
internal class VitureSdkLink private constructor(
private val connection: UsbDeviceConnection,
private val token: Long,
@@ -47,7 +46,6 @@ internal class VitureSdkLink private constructor(
if (rc == 0 || !wanted) tracking = wanted
return result("tracking", rc)
}
- // Frequency is negotiated against SDK capabilities, including Pro 2's pose limit.
VitureProtocol.MSG_IMU_RATE -> return tracking
VitureProtocol.MSG_GET_IMU -> return true
}
@@ -89,7 +87,6 @@ internal class VitureSdkLink private constructor(
if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) {
0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, event[1])
1 -> listener.onValue(VitureProtocol.MSG_GET_VOLUME, event[1])
- // A device event can use a different display-mode namespace.
2 -> send(VitureProtocol.MSG_GET_DISPLAY)
3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1])
}
@@ -122,7 +119,6 @@ internal class VitureSdkLink private constructor(
if (!running) return
running = false
VitureSdk.nativeClose(token)
- // Keep Java's descriptor alive until the SDK and its worker threads have stopped.
connection.close()
}
if (Thread.currentThread() !== worker) worker.join(1000)
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt
index d46c246b7..16bd1195a 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Link.kt
@@ -69,7 +69,6 @@ internal class VitureV2Link internal constructor(
return true
}
if (msgId == VitureProtocol.MSG_GET_IMU) return true
- // Keep the measured 120 Hz mode; the generic controller requests 240 Hz.
if (msgId == VitureProtocol.MSG_IMU_RATE) return false
val query = when (msgId) {
VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS
@@ -146,9 +145,7 @@ internal class VitureV2Link internal constructor(
tracking(true)
if (becameReady) listener.onReady()
val q = pose.quaternion
- // Upstream right/up/back pose converted to the shared VITURE legacy basis.
val shared = floatArrayOf(-q[2], -q[0], q[1], q[3])
- // Upstream does not establish device tick units; use host monotonic receipt time.
if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs)
return
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt
index 548ad6b91..e2e4fdbfc 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureV2Protocol.kt
@@ -1,5 +1,4 @@
-// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick.
-// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
+// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
package com.droiddeck.launcher.glasses
import kotlin.math.sqrt
@@ -37,7 +36,6 @@ object VitureV2Protocol {
val success: Boolean get() = status == 0
}
- // Device ticks have no established time unit; quaternion is xyzw with no axis remapping.
class Pose internal constructor(
val deviceTick: Long,
val quaternion: FloatArray,
@@ -64,7 +62,6 @@ object VitureV2Protocol {
}
fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray {
- // Upstream measured that stream mask 3 ACKs but produces no events on Pro 2.
require(streams == STREAM_OFF || streams == STREAM_POSE || streams == STREAM_RAW)
require(streams != STREAM_POSE || rate.hz <= 240)
val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt
index 3dbd22eb3..e972cd450 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt
@@ -24,7 +24,6 @@ internal interface XrealConnector : Closeable {
internal class XrealNetworkMissing : IOException("XREAL USB Ethernet network is not available")
-/** Bind individual sockets, never the process: Steam must retain its Internet network. */
internal class AndroidXrealConnector(context: Context) : XrealConnector {
private val manager = context.getSystemService(ConnectivityManager::class.java)
private val address = InetAddress.getByName(XrealProtocol.HOST)
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt
index 86f93fbef..18ce636ee 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt
@@ -7,7 +7,6 @@ import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
-/** One connection owns two TCP channels. All control requests are serialized off the UI thread. */
internal class XrealLink internal constructor(
private val connector: XrealConnector,
private val listener: Listener,
@@ -47,7 +46,7 @@ internal class XrealLink internal constructor(
VitureProtocol.MSG_DISPLAY -> value in 2..5 || value == XrealModel.NATIVE_60HZ
VitureProtocol.MSG_BRIGHTNESS -> value in 0..9
VitureProtocol.MSG_FILM -> value in 0..1
- else -> false // No guessed volume, sensor-rate, or VITURE wire commands.
+ else -> false
}
return valid && commands.offer(Command(msgId, value))
}
@@ -88,7 +87,7 @@ internal class XrealLink internal constructor(
}
} catch (e: Exception) {
failure.set(e)
- current.close() // Wake a control read immediately.
+ current.close()
}
}, "xreal-reports").apply { isDaemon = true; start() }
ready = true
@@ -145,12 +144,11 @@ internal class XrealLink internal constructor(
tracking = false
commands.clear()
connection.getAndSet(null)?.close()
- connector.close() // Includes sockets still inside connect().
+ connector.close()
worker.interrupt()
}
}
-/** Kept independent of Android so the real framing/acknowledgement path is testable. */
internal class XrealControl(private val channels: XrealChannels, private val settleDelayMs: Long = 150) {
private var transaction = 0
private fun exchange(command: Int, value: Int? = null): ByteArray {
@@ -158,7 +156,7 @@ internal class XrealControl(private val channels: XrealChannels, private val set
channels.controlOutput.write(XrealProtocol.request(command, transaction, value))
channels.controlOutput.flush()
val deadline = System.nanoTime() + 3_000_000_000L
- repeat(32) { // Bound unsolicited button/status packets and stale responses.
+ repeat(32) {
if (System.nanoTime() > deadline) throw IOException("XREAL control timed out")
XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it }
}
@@ -172,9 +170,6 @@ internal class XrealControl(private val channels: XrealChannels, private val set
fun changeDisplay(mode: Int): Int {
val stereo = mode == 5
set(XrealProtocol.SET_INPUT, if (stereo) 1 else 0)
- // On 1S, SET_INPUT itself changes EDID. Sending SET_DISPLAY immediately
- // receives error 5001 while that change is pending. GET_DISPLAY returns
- // an empty property during the transition; wait for a real mode report.
val current = awaitDisplay { (it == 5) == stereo }
if (current == mode) return current
set(XrealProtocol.SET_DISPLAY, mode)
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt
index 19cd2097c..2de2236c7 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt
@@ -1,10 +1,9 @@
package com.droiddeck.launcher.glasses
-/** First One-family target. Recovery and Air USB products must not use this transport. */
object XrealModel : GlassesModel {
const val VENDOR_ID = 0x3318
const val PRODUCT_1S = 0x043e
- const val NATIVE_60HZ = 29 // Captured on 1S: 1920 x 1200 at 60 Hz.
+ const val NATIVE_60HZ = 29
override val name = "XREAL 1S"
override val controllable = true
override val brightnessMax = 9
@@ -16,7 +15,6 @@ object XrealModel : GlassesModel {
fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S
fun isDisplay(name: String) = name.contains("XREAL", ignoreCase = true)
- // One-family EDID configuration values, not VITURE's display mode IDs.
override fun mode(hz: Int, sbs: Boolean) = when {
sbs -> 5
hz >= 120 -> 4
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt
index e8d34f44a..c10bb9473 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt
@@ -2,19 +2,14 @@ package com.droiddeck.launcher.glasses
import kotlin.math.abs
-/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
internal class XrealOrientationFilter {
private val fusion = ImuFusion()
- // 1S reports right/down/forward. Rotate 180 degrees about X into the
- // shared right/up/back basis before either gravity alignment or fusion.
- // Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
}
-/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
internal class ImuFusion {
private var previous = 0L
private var rotation = Quaternion.IDENTITY
@@ -27,7 +22,7 @@ internal class ImuFusion {
previous = timeNs
val norm = accel.length()
val gravityValid = abs(norm - 9.81f) < 1.5f
- if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
+ if (dt > 0.25f) initialized = false
if (!initialized) {
if (!gravityValid) return null
val up = accel.normalized()
@@ -47,7 +42,6 @@ internal class ImuFusion {
}
rotation = rotation.integrateBodyRate(rate, dt).normalized()
}
- // Same right/up/back -> shared glasses basis used by the RayNeo adapter.
return RayNeoOrientationFilter.glassesPose(floatArrayOf(rotation.w, rotation.x, rotation.y, rotation.z))
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt
index d18ca1a94..09bed0179 100644
--- a/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt
+++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt
@@ -6,7 +6,6 @@ import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
-/** Independent implementation of the One-family wire format; see xreal-investigation.md. */
internal object XrealProtocol {
const val HOST = "169.254.2.1"
const val REPORT_PORT = 52998
@@ -46,7 +45,7 @@ internal object XrealProtocol {
fun sample(frame: Frame): Sample? {
if (frame.command != REPORT || frame.body.size != 128) return null
val b = ByteBuffer.wrap(frame.body).order(ByteOrder.LITTLE_ENDIAN)
- if (b.getInt(24) != 11) return null // Magnetometer reports are a different type.
+ if (b.getInt(24) != 11) return null
val time = b.getLong(8)
val gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36))
val accel = Vec3(b.getFloat(40), b.getFloat(44), b.getFloat(48))
@@ -64,7 +63,6 @@ internal object XrealProtocol {
.putInt(4 + payload.size).putInt(transaction or Int.MIN_VALUE).put(payload).array()
}
- /** No acknowledgement is inferred from a write; match both command and transaction. */
fun response(frame: Frame, command: Int, transaction: Int): ByteArray? {
if (frame.command != command || frame.body.size < 4 ||
ByteBuffer.wrap(frame.body).int != transaction) return null
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt
index a63a80249..fc55247a9 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/AutoDriverTrial.kt
@@ -48,10 +48,6 @@ object AutoDriverTrial {
fun bad(context: Context): Set = prefs(context).getStringSet("bad", emptySet()).orEmpty()
- /**
- * Counted before the compositor loads the trial display driver, so a driver that kills the process during
- * Vulkan init still counts towards a rollback; sessionStarting runs too late for that.
- */
fun displayStarting(context: Context, display: String) {
val p = prefs(context)
if (p.getString("release", null) == null || p.getString("display", "") != display) return
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
index 3d493c606..c31cd348e 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
@@ -52,7 +52,6 @@ object FrameGen {
fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz)
- /** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */
fun apply(context: Context, mode: Mode, refreshHz: Float): String? {
when (mode.engine) {
ENGINE_LSFG -> {
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java
index d74b5a3f1..42dce3d64 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/LinuxVulkanDriver.java
@@ -25,14 +25,6 @@ public final class LinuxVulkanDriver {
return values;
}
- public static String optionLabel(Context context, String value) {
- if (value == null || value.isEmpty()) return "Runtime default (built into the runtime)";
- LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context);
- if (!m.isInstalled(value)) return value + " (imported, missing - uses the runtime default)";
- String ver = m.getDriverVersion(value);
- return m.getDriverName(value) + (ver.isEmpty() ? "" : " " + ver) + " (imported)";
- }
-
public static String resolveIcdPath(Context context, String value) {
if (value == null || value.isEmpty()) return null;
LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context);
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt
index 4d94b6628..8709c3a74 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt
@@ -6,7 +6,6 @@ import java.io.RandomAccessFile
import java.nio.file.Files
import java.nio.file.attribute.BasicFileAttributes
-/** Only new compositor output is evidence of a fault in the current session. */
internal class SteamVrDeviceLossLog(private val file: File) {
private var identity: Any? = identity()
private var position = file.length()
@@ -34,7 +33,6 @@ internal class SteamVrDeviceLossLog(private val file: File) {
}
identity = current
prefix = head
- // Fault loops can produce megabytes per second. Bound work and keep the newest output.
if (input.length() - position > MAX_READ) {
position = input.length() - MAX_READ
partial = ""
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt
index 8c045b68e..d8a546eb5 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt
@@ -11,7 +11,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents
import java.io.File
-/** Process-wide cursor survives Activity recreation; a new session ignores older log errors. */
internal object SteamVrHealth {
private var session: String? = null
private var log: SteamVrDeviceLossLog? = null
@@ -49,7 +48,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
.create()
prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null }
dialog = prompt
- // A touch meant for the game must not silently dismiss the recovery controls.
prompt.setCanceledOnTouchOutside(false)
prompt.show()
if (automatic) {
@@ -66,7 +64,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
private fun restart() {
if (!foreground()) return
- // Commit before crossing processes so a repeated fault cannot create a restart loop.
if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return
SessionEvents.record("steamvr.recovery_requested")
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt
index c05cc2129..63efb3c87 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt
@@ -5,13 +5,11 @@ import org.json.JSONObject
import java.io.File
internal object SteamVrRecoverySettings {
- /** After a graphics crash SteamVR can quarantine our HMD, leaving a server with no VR output. */
fun unblockHeadset(file: File): Boolean {
if (!file.isFile) return false
val settings = JSONObject(file.readText())
val driver = settings.optJSONObject("driver_droiddeck") ?: return false
if (!driver.optBoolean("blocked_by_safe_mode")) return false
- // Preserve every other setting, including safe mode and blocks on other drivers.
driver.put("blocked_by_safe_mode", false)
val atomic = AtomicFile(file)
val output = atomic.startWrite()
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt
index 671132c4f..0cf01a9d6 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt
@@ -14,7 +14,6 @@ import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.session.SessionService
import org.json.JSONObject
-/** A visible, private process survives destruction of the app's native graphics context. */
class SteamVrRestartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -41,7 +40,6 @@ class SteamVrRestartActivity : Activity() {
}, SystemClock::elapsedRealtime, SystemClock::sleep)
if (shutdown != SteamVrRecoveryPolicy.Shutdown.COMPLETE)
android.util.Log.w("SteamVrRecovery", "Restarting before the session finished stopping: $shutdown")
- // Only the verified main process, never a caller-supplied PID or an external app.
Process.killProcess(main.pid)
val exited = SystemClock.elapsedRealtime() + 5000
while (java.io.File("/proc/${main.pid}").exists() && SystemClock.elapsedRealtime() < exited) SystemClock.sleep(50)
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
index 893d569a6..0ac66004b 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
@@ -13,9 +13,6 @@ object TpUbwcHint {
@Volatile var activeMode: String? = null
private set
- // On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
- // match the system driver. A mismatch corrupts Android UI.
- // Samsung's One UI firmware sets it whatever blob version it reports.
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false,
mode: String = SessionPrefs.TP_UBWC_AUTO): String? {
if (model != 740) return null
diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt
index 85a1e3844..d58a5a881 100644
--- a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt
+++ b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingPack.kt
@@ -8,7 +8,6 @@ import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
-/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
@@ -32,10 +31,6 @@ internal object TrackingPack {
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
- /**
- * Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
- * already-loaded library by soname, so this must run before any MediaPipe task class initializes.
- */
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
@@ -50,7 +45,6 @@ internal object TrackingPack {
}
}
- /** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
index 7a565cb95..86e9f8f39 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudConfig.kt
@@ -3,12 +3,7 @@ package com.droiddeck.launcher.hud
import android.content.Context
import com.droiddeck.launcher.session.SessionPrefs
-/** DroidDeck HUD's settings as one "key=value,…" string (DroidDeckHudView.applyConfig), stored in SessionPrefs. */
internal object DroidDeckHudConfig {
- /**
- * Bannerlator's defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts
- * locked, so like the old HUD it lets touches through until it is unlocked to move it.
- */
val DEFAULTS: Map = linkedMapOf(
"hudSize" to "minimal", "hudLocked" to "1", "hudSnap" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid",
"hudOutline" to "40", "hudOutlineAccent" to "1",
@@ -20,7 +15,6 @@ internal object DroidDeckHudConfig {
"tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48",
)
- /** Switches Bannerlator sets together (DeckOverlaySettings twins). */
private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer")
fun load(context: Context): MutableMap {
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
index 296e0487f..a3c217e4b 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudHost.kt
@@ -19,10 +19,6 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import kotlin.math.roundToInt
-/**
- * DroidDeck HUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope
- * (MangoHud's frame times) and MangoHud's readings, started with the session's first frame.
- */
class DroidDeckHudHost(context: Context) {
private val context = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
@@ -36,8 +32,6 @@ class DroidDeckHudHost(context: Context) {
private var polling = false
private var wantX = Float.NaN
private var wantY = Float.NaN
- // With snapping on, the side the HUD keeps to (0 = left, 1 = right), so it stays against that side when its
- // size changes. NaN = not snapped.
private var anchorX = Float.NaN
private var snaps = true
private var snapping = false
@@ -51,13 +45,10 @@ class DroidDeckHudHost(context: Context) {
var onPresentingWindowChanged: (() -> Unit)? = null
- /** The frame rate the HUD shows; null while the HUD is off and not counting frames. */
val liveFps: Float? get() = if (polling) feed.stats.fps else null
- /** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */
var onConfigChanged: (() -> Unit)? = null
- /** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */
var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
private val listener = object : WaylandCompositor.GameListener {
@@ -86,7 +77,6 @@ class DroidDeckHudHost(context: Context) {
get() = SessionPrefs.hudEnabled(context) &&
!File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
- /** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */
fun attach(root: FrameLayout, index: Int) {
if (view != null) return
val hud = DroidDeckHudView(root.context)
@@ -171,13 +161,11 @@ class DroidDeckHudHost(context: Context) {
view?.visibility = View.GONE
}
- /** Picture-in-picture and similar: keep running, draw nothing. */
fun setHidden(hide: Boolean) {
hidden = hide
applyShown()
}
- /** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */
fun setBackgrounded(stopped: Boolean) {
backgrounded = stopped
applyShown()
@@ -188,7 +176,6 @@ class DroidDeckHudHost(context: Context) {
val show = !hidden && !backgrounded
view?.visibility = if (show) View.VISIBLE else View.GONE
showing = show
- // Frames are read once per display frame only while the HUD is drawn.
if (show != polling) {
polling = show
if (show) Choreographer.getInstance().postFrameCallback(frameCallback)
@@ -196,14 +183,11 @@ class DroidDeckHudHost(context: Context) {
}
}
- /** A sensor report (DroidDeck HUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */
fun exportDiagnostics(): File? = runCatching {
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck")
dir.mkdirs()
val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date())
val file = File(dir, "HUD-diagnostics-$stamp.txt")
- // Its own reader: the HUD's CPU load is the change since its previous sample, which an export must
- // not move. Two samples half a second apart, MangoHud's sampling period.
val metrics = HudMetrics(context)
metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context)))
metrics.snapshot()
@@ -243,17 +227,14 @@ class DroidDeckHudHost(context: Context) {
return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f
}
- /** The edge the HUD was dropped over, once a good part of it hangs past that edge; null when it stays. */
private fun dockEdgeFor(v: View): DroidDeckHudDock? {
val over = overhang(v) ?: return null
return over.key.takeIf { over.value >= DOCK_OVERHANG }
}
- /** The edge the HUD hangs furthest past, with how much of it does, as a share of its own size. */
private fun overhang(v: View): Map.Entry? {
val p = v.parent as? View ?: return null
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return null
- // The Strip spans the screen and is short: measure its pull in a set distance rather than its own size.
val strip = (v as? DroidDeckHudView)?.isStrip == true
val density = v.resources.displayMetrics.density
val w = if (strip) minOf(v.width.toFloat(), STRIP_PULL_ACROSS_DP * density) else v.width.toFloat()
@@ -266,7 +247,6 @@ class DroidDeckHudHost(context: Context) {
).maxByOrNull { it.value }
}
- /** The tab follows the finger along its edge and away from it, staying on screen. */
private fun dragTab(hud: DroidDeckHudView, x: Float, y: Float) {
val p = hud.parent as? View ?: return
stopMotion(hud)
@@ -275,7 +255,6 @@ class DroidDeckHudHost(context: Context) {
hud.y = y.coerceIn(0f, (p.height - hud.height).toFloat().coerceAtLeast(0f))
}
- /** A tab pulled out by more than its own size comes out as the HUD there; a shorter pull slides it back onto its edge. */
private fun dropTab(hud: DroidDeckHudView) {
val p = hud.parent as? View ?: return
val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
@@ -298,13 +277,11 @@ class DroidDeckHudHost(context: Context) {
.withEndAction { snapping = false }.start()
}
- /** Stops the HUD's running move, and the per-frame listener a dock animation hangs on it, which outlives a cancel. */
private fun stopMotion(hud: DroidDeckHudView) {
hud.animate().setUpdateListener(null)
hud.animate().cancel()
}
- /** Glides the HUD from (fromX, fromY) to where it now is, back at full size. */
private fun glideFrom(hud: DroidDeckHudView, fromX: Float, fromY: Float) {
if (Motion.scale == 0f || (fromX == hud.x && fromY == hud.y && hud.scaleX == 1f && hud.scaleY == 1f)) {
hud.scaleX = 1f; hud.scaleY = 1f
@@ -317,7 +294,6 @@ class DroidDeckHudHost(context: Context) {
.withEndAction { snapping = false; keepOnScreen(hud) }.start()
}
- /** A drop short of docking fades the tab preview back out. */
private fun fadeDockPreview(hud: DroidDeckHudView) {
val from = hud.dockPreviewBlend
if (from <= 0f) return
@@ -331,10 +307,6 @@ class DroidDeckHudHost(context: Context) {
}
}
- /**
- * Dropped, the HUD glides to the nearer side of the screen at the height it was let go, as a picture-in-picture
- * window does, carried to the side a fling throws it toward.
- */
private fun snapToAnchor(hud: DroidDeckHudView, vx: Float) {
val p = hud.parent as? View ?: return
val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
@@ -354,10 +326,6 @@ class DroidDeckHudHost(context: Context) {
.withEndAction { snapping = false; keepOnScreen(hud) }.start()
}
- /**
- * Dragged past an edge, the HUD shrinks around the finger toward the tab it docks as, reaching the tab's
- * size where a drop would dock it, so the drop finishes a contraction the drag already started.
- */
private fun shrinkPastEdge(hud: DroidDeckHudView, rawX: Float, rawY: Float) {
if (hud.dock != DroidDeckHudDock.NONE) return
val p = hud.parent as? View ?: return
@@ -372,7 +340,6 @@ class DroidDeckHudHost(context: Context) {
hud.pivotX = rawX - at[0] - hud.x
hud.pivotY = rawY - at[1] - hud.y
if (hud.isStrip) {
- // The bar draws in sideways toward the tab's width; scaled evenly it would thin to a line.
hud.scaleX = 1f + (tabW / hud.width - 1f) * e
hud.scaleY = 1f
return
@@ -382,7 +349,6 @@ class DroidDeckHudHost(context: Context) {
hud.scaleX = s; hud.scaleY = s
}
- /** Tucks the HUD against [edge] as its FPS tab, or with [DroidDeckHudDock.NONE] brings it back out from where it was docked. */
private fun setDock(hud: DroidDeckHudView, edge: DroidDeckHudDock) {
if (edge == hud.dock) return
animateDock(hud, edge) { commitDock(hud, edge) }
@@ -405,10 +371,6 @@ class DroidDeckHudHost(context: Context) {
onConfigChanged?.invoke()
}
- /**
- * Where the HUD comes out of its tab, decided before it expands so it grows straight there: where the tab is
- * (against its edge, or wherever it was pulled to), and with snapping on, against the nearer side.
- */
private fun settleOutOfTab(hud: DroidDeckHudView, tabX: Float, tabY: Float) {
val p = hud.parent as? View
val maxX = if (p == null) Float.MAX_VALUE else (p.width - hud.contentWidth).coerceAtLeast(0f)
@@ -419,11 +381,6 @@ class DroidDeckHudHost(context: Context) {
SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY)
}
- /**
- * One shape contracts into the tab, and the tab expands back out to the HUD: the view is scaled onto the
- * tab's exact rectangle, and the layouts swap at that same size and place. Timing and curve are the
- * launcher's own ([Motion]), and off when animations are off.
- */
private fun animateDock(hud: DroidDeckHudView, edge: DroidDeckHudDock, commit: () -> Unit) {
stopMotion(hud)
previewFade?.cancel(); previewFade = null
@@ -436,7 +393,6 @@ class DroidDeckHudHost(context: Context) {
commit(); return
}
if (edge != DroidDeckHudDock.NONE) {
- // Continue from what the drag left on screen: the shrunk HUD, and the tab where it is along the edge.
val left = hud.x + hud.pivotX * (1f - hud.scaleX); val top = hud.y + hud.pivotY * (1f - hud.scaleY)
val shownW = hud.width * hud.scaleX; val shownH = hud.height * hud.scaleY
hud.pivotX = 0f; hud.pivotY = 0f
@@ -480,11 +436,9 @@ class DroidDeckHudHost(context: Context) {
}
}
- /** The pivot, in parent coordinates, that scaling by [scale] takes a span starting at [from] onto one starting at [to]. */
private fun pivotFor(from: Float, to: Float, scale: Float): Float =
if (scale > 0.999f) from else (to - from * scale) / (1f - scale)
- /** Where a [w] x [h] HUD or tab sits once [keepOnScreen] has settled it against [dock]. */
private fun settled(p: View, dock: DroidDeckHudDock, w: Float, h: Float): Pair {
val maxX = (p.width - w).coerceAtLeast(0f); val maxY = (p.height - h).coerceAtLeast(0f)
var x = wantX.coerceIn(0f, maxX); var y = wantY.coerceIn(0f, maxY)
@@ -498,9 +452,6 @@ class DroidDeckHudHost(context: Context) {
return Pair(x, y)
}
- // The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where
- // it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the
- // corner the small window pushed it to.
private fun keepOnScreen(v: View) {
if (snapping) return
val p = v.parent as? View ?: return
@@ -528,27 +479,19 @@ class DroidDeckHudHost(context: Context) {
}
companion object {
- /** How much of the HUD, as a share of its own size, must hang past a screen edge for it to dock there. */
private const val DOCK_OVERHANG = 0.4f
- /** How far ahead a fling carries the HUD when picking the spot it snaps to, as seconds of its speed. */
private const val FLING_REACH_S = 0.18f
- /** How far toward docking a drag goes, as a share of the way, before the tab starts to fade in over the HUD. */
private const val PREVIEW_FROM = 0.55f
- /** How far the Strip is pulled sideways, or off its edge, for its overhang to count as its whole size. */
private const val STRIP_PULL_ACROSS_DP = 600f
private const val STRIP_PULL_OFF_DP = 150f
- /** The share of the expand animation over which the tab fades out to the HUD. */
private const val EXPAND_FADE = 0.5f
private val EASE = PathInterpolator(0.2f, 0.8f, 0.2f, 1f)
- /** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */
@Volatile @JvmStatic var showing = false
private set
- /** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */
internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim()
- /** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */
internal fun protonName(pid: Int): String? = runCatching {
val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000')
val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt
index ab61d670b..9f59512e5 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudModels.kt
@@ -8,16 +8,11 @@
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
- * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
+ * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
*/
package com.droiddeck.launcher.hud
-/**
- * Size modes for the DroidDeck HUD (the 4th selectable in-game overlay style). One shared color-coded
- * visual language, four amounts of detail — see the approved mockup. The config value is the lower-case
- * token stored under the `hudSize` key; the label is the user-facing string.
- */
enum class DroidDeckHudSize(val token: String, val label: String) {
FULL("full", "Full"),
TILES("tiles", "Tiles"),
@@ -26,18 +21,11 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
MEGA("mega", "Mega"),
STRIP("strip", "Strip");
- /** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Strip → Full. */
fun next(): DroidDeckHudSize {
val all = values()
return all[(ordinal + 1) % all.size]
}
- /**
- * The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH
- * config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the
- * chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI.
- * (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.)
- */
fun supportedChips(): Set = when (this) {
MINIMAL -> MINIMAL_CHIPS
PILL -> PILL_CHIPS
@@ -49,11 +37,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
fun from(token: String?): DroidDeckHudSize =
values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL
- // Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle
- // corner readout available in EVERY size, so it's in all sets.
- // The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets.
- // "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph,
- // and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label.
val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock")
val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock")
val STANDARD_CHIPS = setOf(
@@ -67,7 +50,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
}
}
-/** The screen edge the HUD is tucked against, where only its FPS tab shows ([NONE] = floating free). */
enum class DroidDeckHudDock(val token: String) {
NONE("none"), LEFT("left"), RIGHT("right"), TOP("top"), BOTTOM("bottom");
@@ -76,21 +58,12 @@ enum class DroidDeckHudDock(val token: String) {
}
}
-/**
- * The Wayland HDR state the DroidDeck HUD shows on its own line, directly under the latency · display-server
- * line (the host sets it with [DroidDeckHudView.setHdrState]). [NONE] = no line at all: every session whose
- * HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was.
- */
object DroidDeckHudHdr {
const val NONE = 0
- const val ON = 1 // HDR frames on screen with HDR headroom
- const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+)
- const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR)
- const val READY = 4 // HDR open for the session, no HDR frames on screen right now
- const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation
- // on a screen that offers no HDR10 swapchain)
- const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW
- // reports no HDR10 (the TV was unplugged mid-game). The offer to the
- // game cannot be withdrawn once made, so the gate stays open for the
- // session — but "ready" would be a lie on a panel that cannot show it.
+ const val ON = 1
+ const val NO_HEADROOM = 2
+ const val OFF = 3
+ const val READY = 4
+ const val TONEMAPPED = 5
+ const val NOT_ON_THIS_SCREEN = 6
}
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
index 919e6ea92..80d3deb88 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckHudView.kt
@@ -8,7 +8,7 @@
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
- * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
+ * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
*/
package com.droiddeck.launcher.hud
@@ -53,28 +53,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
-/**
- * DroidDeck HUD — the 4th selectable in-game overlay. One color-coded visual language rendered at four
- * SIZE modes (Full / Tiles / Pill / Minimal / Mega), all driven by [FrameStats] (gamescope's frame times
- * read by MangoHud's rules) + a single cached [HudMetrics.Snapshot]. Pure Canvas drawing.
- *
- * DroidDeck: the readouts refresh every 500 ms (MangoHud's sampling period) on a Main-dispatcher
- * coroutine, collecting the snapshot on Dispatchers.IO; the frame graph is redrawn whenever new frames
- * arrive ([onFrames]), as MangoHud draws it.
- *
- * Gestures go through [HudLockController]: long-press toggles the position lock (with a lock/unlock
- * badge fade), a tap cycles the size (Full→Tiles→Pill→Minimal→Full), and a drag repositions — tap +
- * drag are frozen while locked. A locked HUD lets a touch through where an on-screen control under it
- * takes it ([passesTouchAt]); everywhere else it keeps the touch, so a long press there unlocks it.
- */
class DroidDeckHudView(context: Context) : View(context) {
private var frames: FrameStats? = null
internal fun setFrameStats(stats: FrameStats?) { frames = stats }
internal fun setMangoReadings(readings: MangoReadings?) { metrics.setMangoReadings(readings) }
- // Frames per second actually reaching the panel while DroidDeck's frame generation adds frames the
- // game never made; 0 = nothing is adding frames, and the readout is the game's rate alone.
private var presentedFps = 0f
fun setPresentedFps(fps: Float) { presentedFps = fps }
@@ -85,7 +69,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private val density = resources.displayMetrics.density
- // ---- Config -----------------------------------------------------------
private var size = DroidDeckHudSize.FULL
private var scale = 1f
private var bgOpacity = 0.8f
@@ -109,8 +92,7 @@ class DroidDeckHudView(context: Context) : View(context) {
private var showGraph = false
private var showLow001 = true
private var fpsDecimal = true
- private var showClockTime = true // subtle corner/footer clock, available in EVERY size
- // Mega-only rows
+ private var showClockTime = true
private var showPerCore = true
private var showSwap = true
private var showNet = true
@@ -121,21 +103,15 @@ class DroidDeckHudView(context: Context) : View(context) {
private var showSession = true
private var engineLabel = ""
- private var displayServer = "" // "X11" / "Wayland": which display server the game runs on
- private var hdrState = DroidDeckHudHdr.NONE // Wayland HDR, on its own line under latency · display server
+ private var displayServer = ""
+ private var hdrState = DroidDeckHudHdr.NONE
private var gpuModel = ""
- // The GPU name a Wayland session reports to games instead of the real adapter ("" = none). It has
- // to travel separately from gpuModel: Wayland hands the spoof to DXVK and leaves the Vulkan device
- // alone, so everything the host reads back is still the real chip. On X11 the driver wrapper renames
- // the device itself, so there gpuModel already IS the spoofed name and this stays empty.
private var gpuSpoofName = ""
- // Stack-layer version strings (Mega bottom band + DX version on the engine row); fed by the host.
- private var wineVersion = "" // "Proton 10.0-4"
- private var graphicsWrapper = "" // graphics-driver wrapper package, e.g. "GameNative", "bcn_layer 20260719"
- private var dxvkVersion = "" // DXVK version, appended to a DXVK engine row
- private var vkd3dVersion = "" // VKD3D version, appended to a VKD3D engine row
+ private var wineVersion = ""
+ private var graphicsWrapper = ""
+ private var dxvkVersion = ""
+ private var vkd3dVersion = ""
- // ---- Live data (updated on the refresh coroutine, read on draw) --------
private var snap: HudMetrics.Snapshot? = null
private var fpsNow = 0f
private var fpsAvg = 0f
@@ -146,25 +122,17 @@ class DroidDeckHudView(context: Context) : View(context) {
private val graphPath = Path()
private var resolution = ""
- // DroidDeck look: the theme's main colour for the FPS label, graph and accent outline, the theme's
- // surface behind the readouts, a DroidDeck header (wordmark) on Full/Tiles/Minimal/Mega and the logo
- // chip on the Pill.
private var accent = 0xFF1A9FFF.toInt()
private var surfaceRgb = 0x121417
private var headerMark: RectF? = null
private var chipRect: RectF? = null
- // Pushed past a screen edge, the HUD shrinks to a tab with the FPS that stays at that edge.
var dock = DroidDeckHudDock.NONE
private set
private var tabRect: RectF? = null
- // The Strip is a bar across the whole screen, kept against the top or bottom edge.
private var stripRect: RectF? = null
private val tabPath = Path()
- // Every size sits in the DroidDeck mark's D: a straight left side, round on the
- // right. Its fixed layers (fill, header band, watermark, glowing outline) go into a bitmap, because
- // Android before 9 skips glow on shapes drawn with hardware acceleration.
private var dPanel: RectF? = null
private var dRadius = 0f
private var dDividerTop = -1f
@@ -172,7 +140,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private var dFrameKey = ""
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
- // ---- Colors (mockup) --------------------------------------------------
private val colGpu = 0xFF5EE08A.toInt()
private val colCpu = 0xFF58A6FF.toInt()
private val colVram = 0xFFC98BFF.toInt()
@@ -183,10 +150,9 @@ class DroidDeckHudView(context: Context) : View(context) {
private val colValue = 0xFFF2F5F9.toInt()
private val colDim = 0xFF9AA4B2.toInt()
private val colLo = 0xFFE4E8EE.toInt()
- private val colDisp = 0xFF4DD0E1.toInt() // display server (X11 / Wayland)
- private val colHdr = 0xFFFFD54F.toInt() // HDR on screen
+ private val colDisp = 0xFF4DD0E1.toInt()
+ private val colHdr = 0xFFFFD54F.toInt()
- // ---- Paints -----------------------------------------------------------
private val measurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
}
@@ -205,16 +171,10 @@ class DroidDeckHudView(context: Context) : View(context) {
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
- // ---- Built geometry ---------------------------------------------------
private class Glyph(val x: Float, val baseline: Float, val text: String, val color: Int, val sizePx: Float, val sans: Boolean = false, val glow: Boolean = false) {
fun moved(dx: Float, dy: Float) = Glyph(x + dx, baseline + dy, text, color, sizePx, sans, glow)
}
private class Span(val text: String, val color: Int, val sizePx: Float, val glow: Boolean = false)
- /**
- * A "label + value-run" row for the aligned grid sizes (Full / Mega). When [inline] is true the row
- * opts OUT of the shared label column — its value run is placed directly after the label — so a long
- * label (the graphics-wrapper on the FPS row) can't widen the column and shove every value right.
- */
private class HudRow(val label: Span, val vals: List, val inline: Boolean = false)
private val glyphs = ArrayList()
private val tileRects = ArrayList()
@@ -222,7 +182,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private var contentW = 0f
private var contentH = 0f
- // ---- Listeners --------------------------------------------------------
private var onMovedListener: BiConsumer? = null
private var onSizeCycledListener: Consumer? = null
private var onLockChangedListener: Consumer? = null
@@ -232,13 +191,10 @@ class DroidDeckHudView(context: Context) : View(context) {
private var onDockChangedListener: Consumer? = null
private var onScaledListener: IntConsumer? = null
private var onDraggedListener: BiConsumer? = null
- /** The HUD moved under the finger, at that screen point. */
fun setOnDraggedListener(l: BiConsumer?) { onDraggedListener = l }
private var onFlungListener: BiConsumer? = null
- /** A drag ended in a fling, at this velocity (pixels per second), just before the move is reported. */
fun setOnFlungListener(l: BiConsumer?) { onFlungListener = l }
fun setOnDockChangedListener(l: Consumer?) { onDockChangedListener = l }
- /** A pinch settled on this size, in percent. */
fun setOnScaledListener(l: IntConsumer?) { onScaledListener = l }
private val lockController = HudLockController(context, this, object : HudLockController.Callbacks {
@@ -256,7 +212,6 @@ class DroidDeckHudView(context: Context) : View(context) {
override fun onPinchEnd() { onScaledListener?.accept((scale * 100f).roundToInt()) }
})
- // A docked tab always takes touches, locked or not: a tap brings the HUD back, a drag moves the tab.
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var tabDownRawX = 0f
private var tabDownRawY = 0f
@@ -266,10 +221,8 @@ class DroidDeckHudView(context: Context) : View(context) {
private var onTabDraggedListener: BiConsumer? = null
private var onTabDroppedListener: Runnable? = null
private var onTabDragCancelledListener: Runnable? = null
- /** The docked tab is dragged: where the finger would put it (x, y in the parent). */
fun setOnTabDraggedListener(l: BiConsumer?) { onTabDraggedListener = l }
fun setOnTabDroppedListener(l: Runnable?) { onTabDroppedListener = l }
- /** The system took the tab's drag away: the tab goes back to where it was docked. */
fun setOnTabDragCancelledListener(l: Runnable?) { onTabDragCancelledListener = l }
private fun onTabTouch(event: MotionEvent): Boolean {
@@ -296,7 +249,6 @@ class DroidDeckHudView(context: Context) : View(context) {
return true
}
- // On the way into the tab or out of it, the tab fades in over the HUD at the tab's true size on screen.
private var previewEdge = DroidDeckHudDock.NONE
private var previewBlend = 0f
internal fun setDockPreview(edge: DroidDeckHudDock, blend: Float) {
@@ -307,32 +259,15 @@ class DroidDeckHudView(context: Context) : View(context) {
}
internal val dockPreviewBlend: Float get() = previewBlend
- // ---- Public surface (symmetric with the other overlays) ---------------
fun setEngineLabel(s: String?) { if ((s ?: "") != engineLabel) { engineLabel = s ?: ""; post { rebuildAndInvalidate() } } }
fun setGpuModel(s: String?) { if ((s ?: "") != gpuModel) { gpuModel = s ?: ""; post { rebuildAndInvalidate() } } }
- /** This session's Wayland GPU-name spoof (null / empty = none, the default): the GPU row names it
- * in place of the real model, marked so it can't be read as the chip that is really rendering. */
- fun setGpuSpoofName(s: String?) { gpuSpoofName = s ?: ""; post { rebuildAndInvalidate() } }
- fun setDisplayServer(s: String?) { displayServer = s ?: ""; post { rebuildAndInvalidate() } }
- /** [DroidDeckHudHdr] code; [DroidDeckHudHdr.NONE] (the default) draws no HDR line anywhere. Any thread. */
- fun setHdrState(state: Int) {
- val v = if (state in DroidDeckHudHdr.ON..DroidDeckHudHdr.NOT_ON_THIS_SCREEN) state else DroidDeckHudHdr.NONE
- post { if (v != hdrState) { hdrState = v; rebuildAndInvalidate() } }
- }
fun setWineVersion(s: String?) { if ((s ?: "") != wineVersion) { wineVersion = s ?: ""; post { rebuildAndInvalidate() } } }
- fun setGraphicsWrapper(s: String?) { graphicsWrapper = s ?: ""; post { rebuildAndInvalidate() } }
- fun setDxWrapper(dxvk: String?, vkd3d: String?) {
- dxvkVersion = dxvk ?: ""; vkd3dVersion = vkd3d ?: ""; post { rebuildAndInvalidate() }
- }
- /** The game's output from gamescope ("1280x720 @ 144Hz"), as MangoHud shows it; "" = this display. */
fun setResolution(s: String?) { resolution = s ?: "" }
private val periodDone = Channel(Channel.CONFLATED)
private var seenFpsUpdates = 0L
- /** New frames arrived: redraw the graph (MangoHud redraws it every frame), and the readouts when a
- * sampling period completed, at the moment MangoHud redraws its own. */
fun onFrames() {
val updates = frames?.fpsUpdates ?: 0L
if (updates != seenFpsUpdates) {
@@ -342,10 +277,6 @@ class DroidDeckHudView(context: Context) : View(context) {
if (graphRect != null) invalidate()
}
- /** The HUD has no orientation (tap cycles size instead); kept for a symmetric host surface. */
- fun setVertical(vertical: Boolean) { /* no-op */ }
- fun isVertical(): Boolean = false
-
private fun cycleSize() {
size = size.next()
onSizeCycledListener?.accept(size.token)
@@ -372,7 +303,7 @@ class DroidDeckHudView(context: Context) : View(context) {
showGraph = cfg.get("showFPSGraph", "0") == "1"
showLow001 = cfg.get("showLow001", "1") == "1"
fpsDecimal = cfg.get("fpsDecimal", "1") == "1"
- showClockTime = cfg.get("showClock", "1") == "1" // the clock defaults ON
+ showClockTime = cfg.get("showClock", "1") == "1"
showPerCore = cfg.get("showPerCore", "1") == "1"
showSwap = cfg.get("showSwap", "1") == "1"
showNet = cfg.get("showNet", "1") == "1"
@@ -397,7 +328,6 @@ class DroidDeckHudView(context: Context) : View(context) {
rebuildAndInvalidate()
}
- // ---- Lifecycle: self-refresh ------------------------------------------
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isShown) startUpdates()
@@ -408,8 +338,6 @@ class DroidDeckHudView(context: Context) : View(context) {
super.onDetachedFromWindow()
}
- // The host hides the HUD with GONE (HUD off, picture-in-picture) and keeps it attached: read the
- // sensors only while it shows.
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (isAttachedToWindow && isShown) startUpdates() else stopUpdates()
@@ -438,13 +366,11 @@ class DroidDeckHudView(context: Context) : View(context) {
graphMaxMs = stats?.graphMaxMs() ?: 0f
onRefresh?.run()
rebuildAndInvalidate()
- // Without frames (a still screen) MangoHud redraws nothing; the HUD keeps refreshing.
withTimeoutOrNull(REFRESH_MS) { periodDone.receive() }
}
}
}
- /** Runs on the main thread just before each readout refresh, for the host's labels. */
var onRefresh: Runnable? = null
private fun applyTheme() {
@@ -459,10 +385,6 @@ class DroidDeckHudView(context: Context) : View(context) {
colGraph = lighten(accent, 0.3f)
}
- /**
- * Inner Gamer turns the colour wheel inside the app's Compose UI, which is not running while the drawer is
- * closed, so the HUD turns it here and shares the hue so the drawer picks up where the HUD is.
- */
private fun startHueCycle() {
if (hueJob?.isActive == true) return
if (SessionPrefs.theme(context) != Themes.INNER_GAMER) return
@@ -504,7 +426,6 @@ class DroidDeckHudView(context: Context) : View(context) {
invalidate()
}
- // ---- Text helpers -----------------------------------------------------
private fun blend(color: Int): Int {
val i = colorIntensity.coerceIn(0f, 1f)
if (i >= 0.999f) return color
@@ -537,27 +458,19 @@ class DroidDeckHudView(context: Context) : View(context) {
private fun one(v: Float): String =
if (fpsDecimal) String.format(Locale.US, "%.1f", v) else v.roundToInt().toString()
- // Shows "30->118" while something downstream of the game is adding frames.
- // The game's own rate stays visible next to the panel rate, because the gap
- // between the two is precisely what frame generation is doing.
private fun fpsText(v: Float): String =
if (differs(v)) "${one(v)}\u2192${one(presentedFps)}" else one(v)
- // Either direction: up = frames added (LSFG Native), down = frames lost
- // between the guest and the panel, which is the failure win-fg can hit
- // and which a single number hides completely.
private fun differs(v: Float): Boolean =
presentedFps > 0f && kotlin.math.abs(presentedFps - v) > maxOf(1f, v * 0.10f)
private fun fmt1(v: Float): String = String.format(Locale.US, "%.1f", v)
private fun lowText(v: Float): String? = if (v <= 0f) null else fmt1(v)
- // number (white, or "—" muted when null) + unit (muted, smaller)
private fun numUnit(num: String?, unit: String, numPx: Float, unitPx: Float): List =
if (num == null) listOf(Span("—", colDim, numPx), Span(unit, colDim, unitPx))
else listOf(Span(num, colValue, numPx), Span(unit, colDim, unitPx))
- // "0.2GiB"/"75%"/"1938MHz" → white number + muted suffix
private fun valueUnit(text: String, numPx: Float, unitPx: Float): List {
val i = text.indexOfFirst { !(it.isDigit() || it == '.' || it == '-') }
return if (i <= 0) listOf(Span(text, colValue, numPx))
@@ -566,7 +479,7 @@ class DroidDeckHudView(context: Context) : View(context) {
private fun tempSpans(c: Int?, sensor: HudMetrics.TempSensor, numPx: Float, unitPx: Float): List {
if (c == null) return emptyList()
- val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false) // "81°C"
+ val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false)
val col = HudMetrics.tempColor(
c.toFloat(), metrics.resolveThresholds(sensor, tempDisplay), tempDisplay, colValue)
val i = txt.indexOf('°')
@@ -576,13 +489,9 @@ class DroidDeckHudView(context: Context) : View(context) {
private fun gap(unitPx: Float) = Span(" ", colDim, unitPx)
- /** The frame-rate number in neon, as on Minimal. */
private fun glowing(spans: List): List =
spans.mapIndexed { i, s -> if (i == 0) Span(s.text, s.color, s.sizePx, glow = true) else s }
- // ---- Wayland HDR state (one line of its own; nothing at all for DroidDeckHudHdr.NONE) ----
- /** The state as a full line: "HDR", "HDR (no headroom)", "HDR off", "HDR ready", "HDR tone-mapped",
- * "HDR not on this screen". */
private fun hdrLine(px: Float): List = when (hdrState) {
DroidDeckHudHdr.ON -> listOf(Span("HDR", colHdr, px))
DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("HDR", colHdr, px), Span(" (no headroom)", colBat, px))
@@ -592,8 +501,6 @@ class DroidDeckHudView(context: Context) : View(context) {
DroidDeckHudHdr.NOT_ON_THIS_SCREEN -> listOf(Span("HDR", colDim, px), Span(" not on this screen", colBat, px))
else -> emptyList()
}
- /** The value after an "HDR" label (Full / Mega): "on", "no headroom", "off", "ready", "tone-mapped",
- * "not on this screen". */
private fun hdrValue(px: Float): List = when (hdrState) {
DroidDeckHudHdr.ON -> listOf(Span("on", colHdr, px))
DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("no headroom", colBat, px))
@@ -605,12 +512,7 @@ class DroidDeckHudView(context: Context) : View(context) {
}
private fun hdrText(): String = hdrLine(1f).joinToString("") { it.text }
- // ---- GPU model (with the Wayland GPU-name spoof) ----
- /** What the GPU row names: the spoofed GPU when this session reports one, else the real model. */
private fun gpuNameText(): String = gpuSpoofName.ifBlank { gpuModel }
- /** The GPU row's value run. A spoofed name carries a marker in the same warning colour the HDR line
- * qualifies its state with, so the row can't be read as the chip that is really rendering:
- * "GeForce GTX 1080 (spoof)", and where the line is tightest (the pill) "GeForce GTX 1080 spoof". */
private fun gpuModelSpans(px: Float, color: Int, compact: Boolean = false): List {
val name = Span(gpuNameText(), color, px)
if (gpuSpoofName.isBlank()) return listOf(name)
@@ -619,7 +521,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private val inkRect = android.graphics.Rect()
- // ---- Layout builders --------------------------------------------------
private fun rebuild() {
glyphs.clear(); tileRects.clear(); graphRect = null
headerMark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
@@ -636,7 +537,6 @@ class DroidDeckHudView(context: Context) : View(context) {
}
}
- /** The docked tab: the FPS alone, in a box square on the edge it sits against. */
private fun buildDockTab() {
val numPx = sp(20f)
val spans = tabSpans()
@@ -649,7 +549,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private fun tabSpans() = listOf(Span(one(fpsNow), colValue, sp(20f)), Span("fps", colDim, sp(8.5f)))
- /** The size the docked tab has, so the HUD can contract to exactly that shape before the tab replaces it. */
internal fun dockTabSize(): Pair =
Pair(runWidth(tabSpans()) + 2f * sp(9f), capHeight(sp(20f)) + 2f * sp(7f))
@@ -675,18 +574,14 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawPath(tabPath, strokePaint)
}
- // ---- DroidDeck panel, header and chip -----------------------------------
- /** The clock sits in the header on every size but the Pill, which keeps it under the FPS. */
private fun footerClock(): Boolean = showClockTime && size == DroidDeckHudSize.PILL
private fun measureSans(text: String, sizePx: Float): Float { wordmarkPaint.textSize = sizePx; return wordmarkPaint.measureText(text) }
- private fun panelInset() = sp(8f) // room for the glow around the panel
- private fun panelLeft() = sp(13f) // the outline's glow sticks out of the left side
+ private fun panelInset() = sp(8f)
+ private fun panelLeft() = sp(13f)
private fun contentLeft() = panelLeft() + sp(16f)
- /** The DroidDeck mark, "DroidDeck" and the clock across the top, starting on [left], with the same
- * space above and below the mark. Returns the header's bottom edge and its right end. */
private fun addPanelHeader(left: Float, inner: Float): Pair {
val wordPx = sp(10f); val clockPx = sp(9.5f); val gapH = sp(7f)
wordmarkPaint.textSize = wordPx
@@ -714,11 +609,6 @@ class DroidDeckHudView(context: Context) : View(context) {
return Pair(dDividerTop + max(1f, density), x)
}
- /**
- * Moves a size's content, laid out from [pad] at the origin, into the panel: under the header, or after
- * the logo chip on the Pill. Lines that start on the content's left edge are lined up by their ink, so
- * the logo, labels and numbers share one edge.
- */
private fun intoPanel(pad: Float, header: Boolean) {
if (contentW <= 0f || contentH <= 0f) return
for (i in glyphs.indices) {
@@ -748,8 +638,6 @@ class DroidDeckHudView(context: Context) : View(context) {
finishPanel(panel, halfRound = !header)
}
- /** Closes the panel around everything placed so far, widened until no line, tile or graph reaches the
- * round side: a half circle on the short sizes (Pill, Minimal), large round corners on the tall ones. */
private fun finishPanel(panel: RectF, halfRound: Boolean) {
val r = if (halfRound) panel.height() / 2f else min(panel.height() / 2f, sp(56f))
val margin = outlineWidth() + sp(4f)
@@ -773,7 +661,6 @@ class DroidDeckHudView(context: Context) : View(context) {
contentH = panel.bottom + panelInset()
}
- // ---- Shared small-info helpers ----------------------------------------
private fun clockTimeString(): String = DateFormat.getTimeFormat(context).format(Date())
private fun elapsedString(): String {
@@ -783,31 +670,21 @@ class DroidDeckHudView(context: Context) : View(context) {
else String.format(Locale.US, "%02d:%02d", m, s)
}
- /**
- * The FPS row's API/engine label — the DX→Vulkan translator ([engineLabel]: "DXVK"/"VKD3D"/"Zink"),
- * gated by the "Engine" chip. This is short and shows in EVERY size. Falls back to a bare "FPS" when
- * the toggle is off or no engine label is set. (The graphics WRAPPER is separate — Mega-only, drawn
- * below the frametime graph — not on the FPS row.)
- */
private fun apiLabel(): String =
if (showEngine && engineLabel.isNotBlank()) engineLabel else "FPS"
- /** The DX-wrapper version WITH its engine name (e.g. "DXVK 2.4.1", "VKD3D 2.14"), or "" if none.
- * Used for the Mega stack line below the graph. */
private fun dxVersionLabeled(): String = when {
engineLabel.contains("VKD3D", true) && vkd3dVersion.isNotBlank() -> "VKD3D $vkd3dVersion"
engineLabel.contains("DXVK", true) && dxvkVersion.isNotBlank() -> "DXVK $dxvkVersion"
else -> ""
}
- /** The DX-wrapper version for the current engine, or "" (used for the Tiles "API" tile sub-line). */
private fun dxVersion(): String = when {
engineLabel.contains("VKD3D", true) -> vkd3dVersion
engineLabel.contains("DXVK", true) -> dxvkVersion
else -> ""
}
- /** Resolution + refresh, e.g. "1280x720 @ 120Hz". Read from the current display (Main thread). */
private fun resolutionString(): String {
if (resolution.isNotBlank()) return resolution
val dm = resources.displayMetrics
@@ -816,15 +693,13 @@ class DroidDeckHudView(context: Context) : View(context) {
return if (hz >= 1f) "$base @ ${hz.roundToInt()}Hz" else base
}
- /** A subtle, low-emphasis time-of-day tucked into the bottom-right of any size. Grows contentH slightly. */
private fun addSubtleClock(pad: Float, leftAligned: Boolean = false) {
if (!footerClock()) return
val px = sp(9.5f)
val t = clockTimeString()
val w = measure(t, px)
- val footerTop = contentH - pad // reuse the bottom padding band as the footer line
+ val footerTop = contentH - pad
val baseline = footerTop - ascent(px)
- // Right-aligned footer; on the Pill it sits on the left edge, under the FPS.
val x = if (leftAligned) pad else (contentW - pad - w).coerceAtLeast(pad)
glyphs.add(Glyph(x, baseline, t, blend(colDim), px))
contentH = footerTop + lineH(px) + pad * 0.5f
@@ -878,7 +753,6 @@ class DroidDeckHudView(context: Context) : View(context) {
val v = ArrayList()
v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx))
v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx)
- // Short API/engine label ("DXVK"/"VKD3D"/"Zink", else "FPS") — a normal aligned row.
rows.add(HudRow(Span(apiLabel(), colFps, rowPx), v))
rows.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx)))
rows.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx)))
@@ -902,9 +776,6 @@ class DroidDeckHudView(context: Context) : View(context) {
}
if (showFPS && showGraph) {
- // Frametime min/max line + green graph. "Frametime" is far wider than the short-label grid
- // column, so place the stat AFTER the ACTUAL label width (not labelCol) — otherwise the two
- // overlap and render as "Frametimemin:.. max:..".
val ftPx = unitPx * 1.15f
val baseline = y - asc
placeRun(pad, baseline, listOf(Span("Frametime", colFps, ftPx)))
@@ -945,8 +816,6 @@ class DroidDeckHudView(context: Context) : View(context) {
if (showVram && s.vramText() != null) tiles.add(Tile("VRAM", colVram, valueUnit(s.vramText()!!, valPx, unitPx), null, false))
if (showRAM) tiles.add(Tile("RAM", colRam, numUnit(s.ramPercent.roundToInt().toString(), "%", valPx, unitPx),
"${s.ramUsedText()} / ${s.ramTotalText()}", false))
- // API/engine tile — short translator name (DXVK/VKD3D/Zink) with the DX version on the sub-line;
- // a normal half-width tile that naturally fills the empty slot beside RAM. Gated by "Engine".
if (showEngine && engineLabel.isNotBlank())
tiles.add(Tile("API", colFps, listOf(Span(engineLabel, colValue, valPx)),
dxVersion().ifBlank { null }, false))
@@ -970,7 +839,7 @@ class DroidDeckHudView(context: Context) : View(context) {
var normalW = 0f
for (t in tiles) if (!t.wide) normalW = max(normalW, tileW(t))
if (normalW == 0f) for (t in tiles) normalW = max(normalW, tileW(t) / 2f)
- val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH // uniform (room for a sub line)
+ val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH
val fullW = normalW * 2 + tileGap
var x = pad; var y = pad; var col = 0
@@ -1011,20 +880,12 @@ class DroidDeckHudView(context: Context) : View(context) {
val pad = sp(10f); val midGap = sp(12f); val stkLineGap = sp(3f)
val left = ArrayList()
- // "30->118" is roughly three times the width of "30", and the pill's
- // number is set at 30sp precisely because it is the one big thing on a
- // small overlay. Shrink it while it carries both figures so the pill
- // does not balloon across the screen; it is still the largest element.
val generating = differs(fpsNow)
val fpsPx = if (generating) bigPx * 0.55f else bigPx
left += Span(fpsText(fpsNow), colValue, fpsPx, glow = true)
left += Span("fps", colDim, if (generating) bigUnitPx * 0.8f else bigUnitPx)
val stack = ArrayList>()
- // The GPU name normally heads the stack. A name wider than every stat under it (a Wayland spoof
- // such as "Radeon RX 6800/6800 XT / 6900 XT spoof") would set the stack's width and stretch the
- // whole capsule around empty space, so that name gets its own line across the top of the pill
- // instead, starting over the API caption (see nameOnTop below).
val gpuName = if (showGpuModel && gpuNameText().isNotBlank()) gpuModelSpans(stkPx, colDim, compact = true) else null
run {
val l = ArrayList()
@@ -1032,11 +893,7 @@ class DroidDeckHudView(context: Context) : View(context) {
if (showCPU) { if (l.isNotEmpty()) l += Span(" · ", colDim, stkPx); l += Span("CPU ${s.cpuPercent ?: "—"}%", colCpu, stkPx) }
if (l.isNotEmpty()) stack.add(l)
}
- // RAM % (system-wide) takes the slot the latency row used to occupy, matching the GPU/CPU idiom
- // on the row above it. colRam keeps RAM's identity colour consistent with the Full/Tiles sizes.
if (showRAM) stack.add(listOf(Span("RAM ${s.ramPercent.roundToInt()}%", colRam, stkPx)))
- // One battery row, as on the other sizes: each part shows on its own switch, so the battery
- // temperature no longer goes with the RAM switch and watts show without the battery level.
if (showBattery || showPower || showBatteryTemp) {
val l = ArrayList()
fun part(spans: List) {
@@ -1049,9 +906,6 @@ class DroidDeckHudView(context: Context) : View(context) {
if (showPower && s.battery.watts > 0f) part(listOf(Span("${fmt1(s.battery.watts)}W", colDim, stkPx)))
if (l.isNotEmpty()) stack.add(l)
}
- // Latency (+ the display server, + optional VRAM) drops to the bottom of the stack, under the
- // battery row. The display server sits here, not on the GPU-model line: that line is the
- // pill's widest and ran into the capsule's rounded edge.
run {
val l = ArrayList()
l += Span("${fmt1(1000f / max(fpsNow, 1f))}ms", colDim, stkPx)
@@ -1059,8 +913,6 @@ class DroidDeckHudView(context: Context) : View(context) {
if (showVram && s.vramText() != null) { l += Span(" · ", colDim, stkPx); l += Span("${s.vramText()} vram", colDim, stkPx) }
stack.add(l)
}
- // Wayland HDR state on its OWN line directly under the latency · display-server line (it ran off
- // the capsule's right edge as "40.3ms · Wayland · HDR (no headroom)"). Only in HDR sessions.
if (hdrState != DroidDeckHudHdr.NONE) stack.add(hdrLine(stkPx))
var statsW = 0f
@@ -1068,8 +920,6 @@ class DroidDeckHudView(context: Context) : View(context) {
val nameOnTop = gpuName != null && runWidth(gpuName) > statsW
if (gpuName != null && !nameOnTop) stack.add(0, gpuName)
- // Left column: a small API/engine caption (DXVK/VKD3D/Zink) above the big FPS and the clock below
- // it, all on the left edge, so the left reads API · FPS · clock top-to-bottom.
val apiStr = apiLabel()
val hasApi = apiStr != "FPS"
val apiH = if (hasApi) lineH(stkPx) else 0f
@@ -1088,14 +938,12 @@ class DroidDeckHudView(context: Context) : View(context) {
contentW = pad + leftBlockW + midGap + stackW + pad
contentH = pad + topLineH + innerH + pad
- // left column (API caption + big FPS), vertically centered as a block
var ly = pad + topLineH + (innerH - leftColH) / 2f
if (hasApi) {
placeRun(pad, ly - ascent(stkPx), listOf(Span(apiStr, colFps, stkPx)))
ly += apiH + apiGap
}
placeRun(pad, ly - ascent(bigPx), left)
- // stack, vertically centered
var sy = pad + topLineH + (innerH - stackTotalH) / 2f
for (l in stack) {
placeRun(pad + leftBlockW + midGap, sy - ascent(stkPx), l)
@@ -1103,22 +951,17 @@ class DroidDeckHudView(context: Context) : View(context) {
}
addSubtleClock(pad, leftAligned = true)
if (nameOnTop && gpuName != null) {
- // Left-aligned over the API caption; the pill only widens if the name is wider than the
- // API/FPS column + stats.
val end = placeRun(pad, pad - ascent(stkPx), gpuName)
contentW = max(contentW, end + pad)
}
}
- // One column as wide as the widest row: the logo, FPS, lows, graph and engine line start on its left
- // edge (ink, not advance), and the header keeps the same space above and below the logo.
private fun buildMinimal(s: HudMetrics.Snapshot) {
val bigPx = sp(34f); val bigUnitPx = bigPx * 0.32f; val subPx = sp(11.5f); val footPx = sp(9.5f)
val stroke = outlineWidth()
val left = contentLeft()
val inner = panelInset() + stroke
- // Same reasoning as the pill: 34sp is sized for one number, not two.
val minGenerating = differs(fpsNow)
val big = listOf(
Span(fpsText(fpsNow), colValue, if (minGenerating) bigPx * 0.55f else bigPx),
@@ -1164,11 +1007,6 @@ class DroidDeckHudView(context: Context) : View(context) {
finishPanel(panel, halfRound = true)
}
- /**
- * Full's readouts on one line across the screen: the mark, the FPS, the graph, the lows, then GPU, CPU,
- * memory, battery, display server and GPU model, with the clock on the right end. Groups that no longer
- * fit before the clock are left off, from the end.
- */
private fun buildStrip(s: HudMetrics.Snapshot) {
val rowPx = sp(11.5f); val unitPx = rowPx * 0.8f; val fpsPx = sp(15f)
val padX = sp(10f); val groupGap = sp(13f)
@@ -1265,8 +1103,6 @@ class DroidDeckHudView(context: Context) : View(context) {
return inkRect.left.toFloat()
}
- /** Places a column of "label + value" rows; returns (bottomY, rightX). Appends glyphs. Rows flagged
- * [HudRow.inline] opt out of the shared label column (value follows the label directly). */
private fun layoutColumn(rows: List, x: Float, y0: Float,
rowPx: Float, lineGap: Float, lvGap: Float): Pair {
if (rows.isEmpty()) return Pair(y0, x)
@@ -1286,7 +1122,6 @@ class DroidDeckHudView(context: Context) : View(context) {
return Pair(y - lineGap, right)
}
- /** Places wrapping " · "-separated info fragments within maxWidth; returns (bottomY, rightX). */
private fun layoutWrap(fragments: List>, x: Float, y0: Float, maxWidth: Float,
px: Float, lineGap: Float): Pair {
if (fragments.isEmpty()) return Pair(y0, x)
@@ -1307,7 +1142,6 @@ class DroidDeckHudView(context: Context) : View(context) {
val rowPx = sp(11.5f); val unitPx = rowPx * 0.62f; val bandPx = sp(9.5f)
val pad = sp(10f); val lineGap = sp(3.5f); val lvGap = sp(7f); val gutter = sp(16f)
- // ---- LEFT column: GPU, aggregate CPU, then per-core rows ----
val left = ArrayList()
if (showGpuModel && gpuNameText().isNotBlank())
left.add(HudRow(Span("GPU", colGpu, rowPx), gpuModelSpans(rowPx, colValue)))
@@ -1339,7 +1173,6 @@ class DroidDeckHudView(context: Context) : View(context) {
}
}
- // ---- RIGHT column: VRAM, RAM, SWP, NET, BAT, engine, lows ----
val right = ArrayList()
if (showVram && s.vramText() != null)
right.add(HudRow(Span("VRAM", colVram, rowPx), valueUnit(s.vramText()!!, rowPx, unitPx)))
@@ -1377,8 +1210,6 @@ class DroidDeckHudView(context: Context) : View(context) {
val v = ArrayList()
v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx))
v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx)
- // FPS/engine row: just the short API label. The DX version moved to the stack lines
- // below the frametime graph (see below), so this row stays compact.
right.add(HudRow(Span(apiLabel(), colFps, rowPx), v))
right.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx)))
right.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx)))
@@ -1387,15 +1218,12 @@ class DroidDeckHudView(context: Context) : View(context) {
right.add(HudRow(Span("0.01%", colLo, rowPx), numUnit(lowText(lows.low001Fps), "FPS", rowPx, unitPx)))
}
- // ---- Two columns side by side (each reflows independently) ----
val (leftBottom, leftRight) = layoutColumn(left, pad, pad, rowPx, lineGap, lvGap)
val rightX = (if (left.isEmpty()) pad else leftRight + gutter)
val (rightBottom, rightRight) = layoutColumn(right, rightX, pad, rowPx, lineGap, lvGap)
var y = max(leftBottom, rightBottom)
var maxRight = max(leftRight, rightRight)
- // ---- Bottom band spanning both columns: res · Proton · elapsed ----
- // (The graphics wrapper moved to the FPS-row label above, so it's no longer in this band.)
val band = ArrayList>()
if (showResolution) band.add(listOf(Span("RES ", colDim, bandPx), Span(resolutionString(), colValue, bandPx)))
if (displayServer.isNotBlank()) band.add(listOf(Span("DISP ", colDim, bandPx), Span(displayServer, colDisp, bandPx)))
@@ -1408,7 +1236,6 @@ class DroidDeckHudView(context: Context) : View(context) {
y = bBottom; maxRight = max(maxRight, bRight)
}
- // ---- Full-width frametime graph ----
if (showGraph) {
y += sp(3f)
val gh = sp(22f)
@@ -1418,9 +1245,6 @@ class DroidDeckHudView(context: Context) : View(context) {
y += gh
}
- // ---- Stack-layer lines BELOW the frametime graph, subtle (same size/style as the bottom
- // band): the DX-wrapper version first (e.g. "DXVK 2.4.1-…", gated by "DX ver"), then the
- // graphics wrapper beneath it (e.g. "Original", gated by "Wrapper"). ----
val dxLine = dxVersionLabeled()
val hasDx = showDxVer && dxLine.isNotBlank()
if (hasDx) {
@@ -1436,12 +1260,10 @@ class DroidDeckHudView(context: Context) : View(context) {
contentW = maxRight + pad
contentH = y + pad
- addSubtleClock(pad) // subtle time-of-day, consistent with the other sizes
+ addSubtleClock(pad)
}
- // ---- Measure / draw ---------------------------------------------------
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- // The Strip spans the parent, past the side margin the floating sizes keep.
if (stripRect != null) { setMeasuredDimension(contentW.roundToInt(), resolveSize(contentH.roundToInt(), heightMeasureSpec)); return }
setMeasuredDimension(
resolveSize(contentW.roundToInt(), widthMeasureSpec),
@@ -1461,7 +1283,6 @@ class DroidDeckHudView(context: Context) : View(context) {
drawTabPreview(canvas, blend)
}
- /** The docked tab drawn at its own size in the middle of the view, undoing the view's scale. */
private fun drawTabPreview(canvas: Canvas, blend: Float) {
val (w, h) = dockTabSize()
if (scaleX <= 0f || scaleY <= 0f) return
@@ -1487,7 +1308,6 @@ class DroidDeckHudView(context: Context) : View(context) {
dPanel?.let { panel -> panelFrame(panel)?.let { canvas.drawBitmap(it, 0f, 0f, null) } }
- // Tile backings
if (tileRects.isNotEmpty()) {
tilePaint.color = Color.argb((14 * bgOpacity).roundToInt().coerceIn(8, 40), 255, 255, 255)
val tr = sp(6f)
@@ -1498,7 +1318,6 @@ class DroidDeckHudView(context: Context) : View(context) {
tabRect?.let { drawTab(canvas, it) }
stripRect?.let { drawStrip(canvas, it) }
- // Text
for (g in glyphs) {
val paint = if (g.sans) wordmarkPaint else drawPaint
paint.textSize = g.sizePx
@@ -1509,14 +1328,11 @@ class DroidDeckHudView(context: Context) : View(context) {
}
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
- // Frametime graph
graphRect?.let { drawGraph(canvas, it) }
lockController.drawBadge(canvas)
}
- // MangoHud's frametime graph: the last 200 frames on a fixed 0-50 ms scale, longer frames clipped
- // to 49.9 ms (hud_elements.cpp TransformForward_Custom).
private fun drawGraph(canvas: Canvas, rect: RectF) {
val stats = frames ?: return
stats.graph(graphBuf)
@@ -1528,7 +1344,6 @@ class DroidDeckHudView(context: Context) : View(context) {
if (i == 0) graphPath.moveTo(px, py) else graphPath.lineTo(px, py)
}
if (dPanel != null) {
- // Neon without a blur: a blur would be redrawn with every frame.
graphPaint.strokeWidth = sp(4.5f)
graphPaint.color = withAlpha(blend(colGraph), 56)
canvas.drawPath(graphPath, graphPaint)
@@ -1538,7 +1353,6 @@ class DroidDeckHudView(context: Context) : View(context) {
canvas.drawPath(graphPath, graphPaint)
}
- /** The bar, with the accent line on the side that faces the game (under it at the top, over it at the bottom). */
private fun drawStrip(canvas: Canvas, rect: RectF) {
headerPaint.shader = null
headerPaint.color = panelColor()
@@ -1560,8 +1374,6 @@ class DroidDeckHudView(context: Context) : View(context) {
private fun withAlpha(color: Int, alpha: Int): Int =
Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
- /** The D: straight left side with small corners, round on the right with radius [rad] (a half circle
- * when it is half the height). */
private fun dShape(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float): Path {
return Path().apply {
moveTo(l, t + corner)
@@ -1575,7 +1387,6 @@ class DroidDeckHudView(context: Context) : View(context) {
}
}
- /** Where the inside of the outline's round side is at height [y]. */
private fun roundEdgeX(p: RectF, y: Float, stroke: Float): Float {
val r = dRadius - stroke
val cx = p.right - dRadius
@@ -1605,7 +1416,6 @@ class DroidDeckHudView(context: Context) : View(context) {
headerPaint.color = withAlpha(accent, 90)
c.drawRect(panel.left, dDividerTop, roundEdgeX(panel, dDividerTop, stroke), dividerBottom, headerPaint)
}
- // The watermark fills the half circle on the short sizes and sits on the right of the tall ones.
val halfRound = dRadius >= panel.height() / 2f - 0.5f
val wmH = if (halfRound) panel.height() * 0.92f else min(panel.height(), panel.width()) * 0.55f
DroidDeckMark.draw(c, panel.right - DroidDeckMark.widthFor(wmH) - (if (halfRound) panel.height() * 0.02f else sp(10f)),
@@ -1616,7 +1426,6 @@ class DroidDeckHudView(context: Context) : View(context) {
val outline = dShape(panel.left + h, panel.top + h, panel.right - h, panel.bottom - h, dRadius - h, corner)
val blur = BlurMaskFilter((sp(3.2f) - 0.5f).coerceAtLeast(0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL)
glowPaint.strokeWidth = stroke
- // Two blurred passes under the sharp one, as the 4-in-1 preview's neon.
for (pass in 0..2) {
glowPaint.maskFilter = if (pass < 2) blur else null
glowPaint.style = Paint.Style.STROKE; glowPaint.color = outlineColor()
@@ -1657,10 +1466,8 @@ class DroidDeckHudView(context: Context) : View(context) {
DroidDeckMark.draw(canvas, rect.centerX() - markW / 2f, rect.centerY() - markH / 2f, markH, blend(colValue), accent)
}
- /** Screen point → whether an on-screen control under the HUD takes a touch there. */
internal var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
- // Declining the down event hands the whole gesture to the views under the HUD.
override fun onTouchEvent(event: MotionEvent): Boolean {
if (dock != DroidDeckHudDock.NONE) return onTabTouch(event)
if (event.actionMasked == MotionEvent.ACTION_DOWN && lockController.isLocked() &&
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt
index dd21683f0..621f6dc1f 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/DroidDeckMark.kt
@@ -5,7 +5,6 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
-/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */
internal object DroidDeckMark {
private const val VIEW_X = -55.98f
const val VIEW_W = 239.92f
@@ -36,7 +35,6 @@ internal object DroidDeckMark {
fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H
- /** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */
fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H
fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
index adf3e067e..2bc92b98d 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt
@@ -3,13 +3,6 @@ package com.droiddeck.launcher.hud
import java.io.File
import kotlin.math.roundToInt
-/**
- * Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud
- * overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the
- * frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and
- * the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max
- * taken over those 200. Average and lows are DroidDeck HUD's own readouts.
- */
internal class FrameStats {
private val graph = FloatArray(GRAPH_FRAMES)
private var graphHead = 0
@@ -29,11 +22,9 @@ internal class FrameStats {
@Volatile var fps = 0f
private set
- /** Completed sampling periods; MangoHud redraws its readouts when one completes. */
@Volatile var fpsUpdates = 0L
private set
- /** Frames added so far; the HUD redraws its graph when this moves. */
@Volatile var count = 0L
private set
@@ -81,10 +72,8 @@ internal class FrameStats {
count++
}
- /** MangoHud's frame time readout: 1000 / FPS. */
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
- /** The graph, oldest first, into [out] (GRAPH_FRAMES long). */
@Synchronized
fun graph(out: FloatArray) {
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
@@ -96,7 +85,6 @@ internal class FrameStats {
@Synchronized
fun graphMaxMs(): Float = graph.max()
- /** Average over the frames this session, from the first frame to [nowNs]'s last frame. */
@Synchronized
fun averageFps(): Float {
if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f
@@ -112,7 +100,6 @@ internal class FrameStats {
}
}
- /** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */
fun lows(nowNs: Long): Lows {
val copy: LongArray
synchronized(this) {
@@ -145,11 +132,6 @@ internal class FrameStats {
}
}
-/**
- * Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the
- * shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows,
- * which stop at the screen's refresh rate.
- */
internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) {
private var handle = 0L
private var nextOpenNs = 0L
@@ -160,7 +142,7 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
private val info = IntArray(5)
@Volatile private var fromGamescope = false
private var lastCompositorNs = 0L
- @Volatile private var compositorFrames = 0L // written by the compositor thread only
+ @Volatile private var compositorFrames = 0L
private var seenCompositorFrames = 0L
@Volatile var engine = ""
@@ -176,7 +158,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
@Volatile var refreshMilliHz = 0
private set
- /** Main thread, once per display frame. True when new frames arrived. */
fun poll(nowNs: Long): Boolean {
val compositorAdded = compositorFrames != seenCompositorFrames
seenCompositorFrames = compositorFrames
@@ -185,7 +166,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
nextOpenNs = nowNs + REOPEN_NS
handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L)
if (handle == 0L) return compositorAdded
- // Frames already in the file were written before this reader started.
HudFrames.nativePoll(handle, frametimes, times)
}
val n = HudFrames.nativePoll(handle, frametimes, times)
@@ -199,7 +179,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
} else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) {
fromGamescope = false
}
- // A new session writes the file again from the start; reopen when gamescope has gone quiet.
if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close()
if (nowNs - lastInfoNs >= INFO_NS) {
lastInfoNs = nowNs
@@ -208,7 +187,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
return n > 0 || compositorAdded
}
- /** Compositor thread: one frame shown on the panel. */
fun onCompositorFrame(nowNs: Long) {
if (fromGamescope) {
lastCompositorNs = 0L
@@ -247,8 +225,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
private const val STALE_NS = 5_000_000_000L
private const val INFO_NS = 500_000_000L
- /** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp
- * engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */
fun engineLabel(name: String): String = when (name) {
"DXVK" -> "DXVK"
"vkd3d" -> "VKD3D"
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt
index 63a9a7945..ca13e7a3e 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/HudFrames.kt
@@ -1,12 +1,10 @@
package com.droiddeck.launcher.hud
-/** Reader for the frame-times file gamescope patch 0125 writes (hudframes.c). */
internal object HudFrames {
init {
System.loadLibrary("droiddeckwayland")
}
- /** Under the Linux root: the session exports /run/droiddeck-hud/frames to gamescope. */
const val PATH = "run/droiddeck-hud/frames"
const val CAPACITY = 1024
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
index f739ec8b6..98c34df7c 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
+++ b/app/src/main/java/com/droiddeck/launcher/hud/HudLockController.java
@@ -8,7 +8,7 @@
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
- * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
+ * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
*/
package com.droiddeck.launcher.hud;
@@ -28,48 +28,18 @@ import android.view.View;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
-/**
- * Shared touch + lock behaviour for every in-game performance HUD (classic FrameRating(+Horizontal),
- * GameHub PerfHudView, GameNative PerformanceHudView, and the DroidDeck HUD). One place so the four
- * overlays can't drift:
- *
- *
- * - tap → {@link Callbacks#onTap()} (orientation-toggle for the first three; size-cycle for
- * the DroidDeck HUD) — a no-op that only re-flashes the lock badge while locked.
- * - drag → moves the host view and, on release, {@link Callbacks#onMoved} to persist the
- * position — disabled (frozen) while locked.
- * - long-press → toggles {@code hudLocked}, persisted via {@link Callbacks#onLockChanged}
- * and kept in sync with the "Lock in place" menu chip. Works locked or unlocked (so a long-press
- * always unlocks); a locked {@code DroidDeckHudView} passes touches on only over on-screen controls.
- *
- *
- * On every lock/unlock (and on a blocked gesture while locked) a small padlock badge fades in →
- * holds → fades out in a corner of the panel. The fade is driven by a self-reposting Runnable that
- * invalidates only while visible — nothing runs on the present path, nothing animates at rest.
- *
- *
All methods are Main-thread only (touch + draw), matching the epoll-thread rule: the views feed
- * this from {@code onTouchEvent}/{@code dispatchDraw}, never from a present tick.
- */
public class HudLockController {
public interface Callbacks {
- /** Unlocked single tap: orientation-toggle (classic/gamehub/gamenative) or size-cycle (DroidDeck HUD). */
void onTap();
- /** Drag released (unlocked): persist the new (x, y). */
void onMoved(float x, float y);
- /** The view moved under a drag (unlocked), with the finger at screen point (rawX, rawY). */
default void onDragged(float rawX, float rawY) {}
- /** The drag ended in a fling (unlocked), in pixels per second; comes just before {@link #onMoved}. */
default void onFlung(float velocityX, float velocityY) {}
- /** Lock toggled by long-press: persist {@code hudLocked} + sync the menu chip. */
void onLockChanged(boolean locked);
- /** Two-finger pinch (unlocked): the change since the previous event, above 1 when the fingers spread. */
default void onPinch(float factor) {}
- /** The fingers came off a pinch: persist the new size. */
default void onPinchEnd() {}
}
- // Fade timing (ms): in → hold → out. Lock/unlock hold ~1.5 s; a blocked-gesture re-flash is shorter.
private static final long FADE_IN_MS = 160;
private static final long HOLD_TOGGLE_MS = 1500;
private static final long HOLD_REFLASH_MS = 750;
@@ -83,13 +53,10 @@ public class HudLockController {
private boolean locked = false;
- // Drag state
private float downRawX, downRawY, startX, startY;
private boolean dragging = false;
- // A pinch ends the gesture's drag and tap: the finger left down after it must not move the HUD.
private boolean pinched = false;
- // Fade state
private long fadeStart = 0;
private long holdMs = HOLD_TOGGLE_MS;
private boolean fadeShowLocked = false;
@@ -110,14 +77,12 @@ public class HudLockController {
iconStroke.setStrokeJoin(Paint.Join.ROUND);
}
- /** Set lock state from config (applyConfig) WITHOUT firing the persist callback. */
public void setLocked(boolean locked) { this.locked = locked; }
public boolean isLocked() { return locked; }
public boolean isDragging() { return dragging; }
- /** Feed from the host view's onTouchEvent. Always consumes (mirrors the legacy manual handlers). */
public boolean onTouchEvent(MotionEvent event) {
scaleDetector.onTouchEvent(event);
detector.onTouchEvent(event);
@@ -143,7 +108,6 @@ public class HudLockController {
@Override
public void onLongPress(MotionEvent e) {
- // Long-press always toggles — this is the only gesture that still works while locked.
locked = !locked;
cb.onLockChanged(locked);
flash(locked, HOLD_TOGGLE_MS);
@@ -153,7 +117,7 @@ public class HudLockController {
public boolean onSingleTapUp(MotionEvent e) {
if (pinched) return true;
if (locked) {
- flash(true, HOLD_REFLASH_MS); // intentionally locked — remind the user
+ flash(true, HOLD_REFLASH_MS);
} else {
cb.onTap();
}
@@ -171,7 +135,7 @@ public class HudLockController {
if (pinched) return true;
if (locked) {
if (!dragging) flash(true, HOLD_REFLASH_MS);
- dragging = true; // mark so the blocked drag doesn't fall through to a tap
+ dragging = true;
return true;
}
view.setX(startX + (e2.getRawX() - downRawX));
@@ -203,7 +167,6 @@ public class HudLockController {
}
}
- // ---- Fade badge -------------------------------------------------------
private void flash(boolean showLocked, long hold) {
fadeShowLocked = showLocked;
holdMs = hold;
@@ -236,14 +199,6 @@ public class HudLockController {
return Color.argb(Math.round(255 * a), Color.red(color), Color.green(color), Color.blue(color));
}
- /**
- * Draw the padlock badge in the top-right of the panel if a fade is in progress. Call from the
- * host's onDraw (plain View) or dispatchDraw (ViewGroup), after the content.
- *
- *
No background box: the glyph is drawn twice — a slightly expanded, thicker semi-transparent
- * black "halo" pass for legibility on any game frame, then the white glyph on top — so it reads as
- * a floating icon, not an opaque tile.
- */
public void drawBadge(Canvas canvas) {
float a = currentAlpha();
if (a <= 0f) return;
@@ -262,14 +217,12 @@ public class HudLockController {
float r = size * 0.2f;
float shackleTop = top + size * 0.08f;
- // Pass 1: dark halo (expanded body, thicker shackle) for contrast; Pass 2: white glyph.
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
Color.argb(Math.round(150 * a), 0, 0, 0), 3.0f * d, 0.9f * d, false);
drawLock(canvas, cx, bodyTop, bodyLeft, bodyRight, bottom, r, shackleTop, d,
withAlpha(0xFFFFFFFF, a), 1.7f * d, 0f, true);
}
- /** One padlock pass. {@code halo} inflates the body/shackle in dark; the white pass punches the keyhole. */
private void drawLock(Canvas canvas, float cx, float bodyTop, float bodyLeft, float bodyRight,
float bottom, float r, float shackleTop, float d,
int color, float strokeW, float expand, boolean punchKeyhole) {
@@ -277,7 +230,6 @@ public class HudLockController {
iconStroke.setColor(color);
iconStroke.setStrokeWidth(strokeW);
- // Body
canvas.drawRoundRect(bodyLeft - expand, bodyTop - expand, bodyRight + expand, bottom + expand,
2.4f * d, 2.4f * d, iconFill);
if (punchKeyhole) {
@@ -289,19 +241,16 @@ public class HudLockController {
RectF arc = new RectF(cx - r, shackleTop, cx + r, shackleTop + 2f * r);
if (fadeShowLocked) {
- // Closed: both legs run straight down into the body.
Path p = new Path();
p.addArc(arc, 180f, 180f);
p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop);
p.moveTo(cx + r, shackleTop + r); p.lineTo(cx + r, bodyTop);
canvas.drawPath(p, iconStroke);
} else {
- // Open, clearly to the LEFT: the RIGHT leg stays seated in the body; the arc + LEFT leg
- // hinge up-and-left off the right attachment so the left leg lifts well clear of the body.
- canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke); // fixed right leg
+ canvas.drawLine(cx + r, shackleTop + r, cx + r, bodyTop, iconStroke);
Path p = new Path();
p.addArc(arc, 180f, 180f);
- p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop); // full-length left leg
+ p.moveTo(cx - r, shackleTop + r); p.lineTo(cx - r, bodyTop);
canvas.save();
canvas.rotate(-42f, cx + r, shackleTop + r);
canvas.drawPath(p, iconStroke);
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java
index a98aa8535..759e01351 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java
+++ b/app/src/main/java/com/droiddeck/launcher/hud/HudMetrics.java
@@ -8,7 +8,8 @@
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
- * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
+ * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
+ * Metric readers from @xXJSONDeruloXx's work.
*/
package com.droiddeck.launcher.hud;
@@ -32,50 +33,17 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
-/**
- * Shared live-metric collector for every performance HUD overlay. This is the single, device-complete
- * implementation of the sysfs / Android readers — no overlay should re-read sysfs itself, so the GPU
- * discovery / thermal / battery logic never diverges.
- *
- *
Ported and hardened from GameNative's {@code PerformanceHudView} readers, which are far more
- * device-complete than the two-fixed-path versions previously inlined in the classic overlays:
- *
- * - GPU load — ~10 static candidates plus a dynamic {@code /sys/class/devfreq} +
- * {@code /sys/devices/virtual/devfreq} walk for gpu/mali/g3d/kgsl nodes; handles gpubusy
- * (busy/total), Mali gpuinfo (delta-ms/wall-ms, needs state between calls) and generic percent
- * nodes. Discovery cached.
- * - CPU usage — {@code /proc/stat} aggregate delta with a scaling-frequency fallback for devices
- * where {@code /proc/stat} is restricted.
- * - Thermal — prioritized CPU zone discovery (cpu-silicon > cpu-0 > cpu > soc >
- * s5p-tmu > cputop > tsens > cluster > big/little) and a separate prioritized GPU zone
- * discovery plus kgsl/mali direct paths. milli-°C normalization + 1..150 sanity clamp.
- * - Battery — % capacity, power W, runtime-left estimate (charge_counter / current_now, smoothed),
- * battery temperature, plus the dual-battery current-sum for the classic overlays.
- *
- *
- * Every getter is null / absent-safe: nullable getters return {@code null} when a metric cannot be
- * read (the overlay hides that row, matching GameNative behavior); the legacy non-null getters return
- * 0 in that case. Not thread-safe; call from a single refresh thread per instance.
- */
public class HudMetrics {
private final Context context;
public HudMetrics(Context context) { this.context = context; }
- // DroidDeck: the readings Steam's performance overlay shows come from MangoReadings, so both agree.
private MangoReadings mango;
public void setMangoReadings(MangoReadings readings) { mango = readings; }
- // =======================================================================
- // CPU usage
- // =======================================================================
private Long lastCpuTotal = null;
private Long lastCpuIdle = null;
- /**
- * Overall device CPU usage 0..100 computed from the /proc/stat delta since the last call, with a
- * scaling-frequency fallback. Returns null only when neither source is readable.
- */
public Integer getCpuUsagePercent() {
String line = readFirstLine("/proc/stat");
if (line != null) {
@@ -104,8 +72,6 @@ public class HudMetrics {
return clampPercent((int) usage);
}
}
- // First sample seeds the delta — fall through to the frequency estimate so the
- // very first read still returns something.
}
}
}
@@ -127,20 +93,10 @@ public class HudMetrics {
return clampPercent((int) ((currentTotal * 100L) / maxTotal));
}
- /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */
- public float getCPUUsage() {
- Integer p = getCpuUsagePercent();
- return p == null ? 0f : p;
- }
-
- // =======================================================================
- // GPU load
- // =======================================================================
private List gpuUsagePathsCache = null;
private Long lastMaliGpuInfoMs = null;
private long lastMaliGpuInfoWallMs = 0L;
- /** GPU utilisation 0..100, or null when no readable source is found on this device. */
public Integer getGpuUsagePercent() {
for (String path : discoverGpuUsagePaths()) {
Integer p = readGpuUsageSample(path);
@@ -149,12 +105,6 @@ public class HudMetrics {
return null;
}
- /** Legacy non-null accessor (0 when unavailable). Kept for {@code PerfHudView}. */
- public int getGPULoad() {
- Integer p = getGpuUsagePercent();
- return p == null ? 0 : p;
- }
-
private List discoverGpuUsagePaths() {
if (gpuUsagePathsCache != null) return gpuUsagePathsCache;
LinkedHashSet candidates = new LinkedHashSet<>();
@@ -163,8 +113,6 @@ public class HudMetrics {
if (new File(p).canRead()) candidates.add(p);
}
- // Vendor platform nodes with hashed/addressed names we can't hard-code (e.g.
- // /sys/devices/platform/13000000.mali/utilisation, .../gpusysfs/gpu_busy, panfrost, sgpu).
File platformDir = new File("/sys/devices/platform");
File[] platformNodes = platformDir.listFiles(File::isDirectory);
if (platformNodes != null) {
@@ -211,9 +159,6 @@ public class HudMetrics {
return gpuUsagePathsCache;
}
- // Ordered, world-readable-when-present GPU utilisation nodes across the vendors we've seen:
- // Adreno KGSL, Mali (misc + Exynos vendor), PowerVR, and amdgpu-style sysfs (Samsung Xclipse /
- // "sgpu", AMD-on-Exynos). readGpuUsageSample() knows how to parse each filename shape.
private static final String[] GPU_USAGE_STATIC_PATHS = {
"/sys/class/kgsl/kgsl-3d0/gpubusy",
"/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage",
@@ -223,23 +168,19 @@ public class HudMetrics {
"/sys/class/misc/mali0/device/gpuinfo",
"/sys/devices/platform/mali/utilization",
"/sys/kernel/gpu/gpu_busy",
- "/sys/devices/platform/gpusysfs/gpu_busy", // Exynos Mali vendor node
+ "/sys/devices/platform/gpusysfs/gpu_busy",
"/sys/class/misc/pvrsrvkm/device/utilisation",
- "/sys/class/pvr/utilisation", // PowerVR (Rogue) sysfs
+ "/sys/class/pvr/utilisation",
"/sys/class/pvr/gpu_utilisation",
- "/sys/class/drm/card0/device/gpu_busy_percent", // amdgpu / Xclipse (sgpu), 0..100
+ "/sys/class/drm/card0/device/gpu_busy_percent",
"/sys/class/devfreq/gpu/load",
- // ── Additive cross-vendor fallbacks (appended LAST: tried only after every path above
- // misses, so a device that already resolves — e.g. Adreno kgsl — never reaches these). ──
- "/sys/kernel/ged/hal/gpu_utilization", // MediaTek GED: " " → loading%
- "/sys/module/ged/parameters/gpu_loading", // MediaTek GED module param, plain percent
+ "/sys/kernel/ged/hal/gpu_utilization",
+ "/sys/module/ged/parameters/gpu_loading",
};
- // Percent-bearing filenames scanned under each devfreq / platform GPU node.
private static final String[] GPU_USAGE_FILES = {
"gpu_busy_percentage", "gpu_busy_percent", "gpu_load", "utilisation", "utilization",
"load", "gpu_busy", "gpuinfo",
};
- // Substrings that mark a devfreq / platform node as the GPU, across vendors.
private static final String[] GPU_NODE_TOKENS = {
"gpu", "mali", "g3d", "kgsl", "panfrost", "pvr", "powervr", "xclipse", "sgpu",
};
@@ -258,8 +199,6 @@ public class HudMetrics {
return clampPercent((int) ((busy * 100L) / total));
}
case "gpuinfo": {
- // Mali multi-line node: the GPU busy time (ms) is the last token of line 1. Utilisation
- // is the delta over wall-clock delta between two reads, so it needs state.
String line = readNthLine(path, 1);
if (line == null) return null;
String[] toks = line.trim().split("\\s+");
@@ -294,46 +233,12 @@ public class HudMetrics {
return null;
}
- // =======================================================================
- // RAM
- // =======================================================================
- public float getRAMPercent() {
- ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
- ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
- am.getMemoryInfo(mi);
- if (mi.totalMem <= 0) return 0;
- return (mi.totalMem - mi.availMem) * 100f / mi.totalMem;
- }
+ private List thermalZonesCache = null;
- /** Used RAM as "x.xGB" (or "yMB" below 1 GB), matching GameNative's HUD text. */
- public String getUsedRamText() {
- ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
- if (am == null) return "—";
- ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
- am.getMemoryInfo(mi);
- long usedBytes = Math.max(0L, mi.totalMem - mi.availMem);
- double usedGb = usedBytes / (1024.0 * 1024.0 * 1024.0);
- if (usedGb >= 1.0) return String.format(Locale.US, "%.1fGB", usedGb);
- return (usedBytes / (1024L * 1024L)) + "MB";
- }
-
- // =======================================================================
- // Temperature
- // =======================================================================
- private List thermalZonesCache = null; // each entry: {type, tempPath}
-
- /** Legacy non-null CPU-temperature accessor in °C (0 when unavailable). Kept for {@code PerfHudView}. */
- public float getTemperature() {
- Integer t = getCpuTempC();
- return t == null ? 0f : t;
- }
-
- /** Hottest representative CPU temperature in °C, or null when no CPU zone is readable. */
public Integer getCpuTempC() {
return readTemperatureC(discoverPrioritizedCpuTempPaths());
}
- /** GPU temperature in °C, or null when no GPU sensor is readable. */
public Integer getGpuTempC() {
ArrayList paths = new ArrayList<>();
paths.add("/sys/class/kgsl/kgsl-3d0/temp");
@@ -346,16 +251,16 @@ public class HudMetrics {
private List discoverPrioritizedCpuTempPaths() {
return prioritizePaths(type -> {
- if (type.contains("gpu")) return null; // never mistake a GPU zone for CPU
+ if (type.contains("gpu")) return null;
if (type.contains("cpu-silicon")) return 0;
if (type.contains("cpu-0")) return 1;
- if (type.contains("cpuss")) return 2; // Qualcomm cpuss composite
- if (type.contains("mtktscpu")) return 2; // MediaTek CPU thermal
- if (type.contains("cpu")) return 2; // cpu, cpu-*-usr, cpu_thermal, cpu_center
- if (type.contains("s5p-tmu")) return 3; // Exynos TMU
+ if (type.contains("cpuss")) return 2;
+ if (type.contains("mtktscpu")) return 2;
+ if (type.contains("cpu")) return 2;
+ if (type.contains("s5p-tmu")) return 3;
if (type.contains("soc")) return 4;
if (type.contains("cputop")) return 5;
- if (type.contains("tsens")) return 6; // Qualcomm tsens aggregate
+ if (type.contains("tsens")) return 6;
if (type.contains("cluster")) return 7;
if (type.contains("big") || type.contains("little")) return 8;
return null;
@@ -365,11 +270,11 @@ public class HudMetrics {
private List discoverPrioritizedGpuTempPaths() {
return prioritizePaths(type -> {
if (type.contains("gpu-silicon")) return 0;
- if (type.contains("gpuss")) return 1; // Qualcomm gpuss
- if (type.contains("mtktsgpu")) return 1; // MediaTek GPU thermal
+ if (type.contains("gpuss")) return 1;
+ if (type.contains("mtktsgpu")) return 1;
if (type.contains("gpu-virt")) return 2;
if (type.contains("gpu")) return 2;
- if (type.contains("g3d")) return 3; // Exynos Mali (g3d)
+ if (type.contains("g3d")) return 3;
if (type.contains("kgsl")) return 4;
if (type.contains("mali")) return 5;
if (type.contains("xclipse") || type.contains("sgpu")) return 6;
@@ -381,12 +286,11 @@ public class HudMetrics {
private List prioritizePaths(Ranker ranker) {
List zones = discoverAllThermalZones();
- ArrayList order = new ArrayList<>(); // {index, rank}
+ ArrayList order = new ArrayList<>();
for (int i = 0; i < zones.size(); i++) {
Integer r = ranker.rank(zones.get(i)[0]);
if (r != null) order.add(new int[]{i, r});
}
- // Sort by rank, then by path for determinism.
order.sort((a, b) -> {
if (a[1] != b[1]) return Integer.compare(a[1], b[1]);
return zones.get(a[0])[1].compareTo(zones.get(b[0])[1]);
@@ -426,14 +330,12 @@ public class HudMetrics {
if (!seen.add(path)) continue;
Integer raw = readIntFromLine(path);
if (raw == null) continue;
- // Round to nearest degree for milli-°C sources; sanity-clamp to reject offline sensors.
int celsius = raw > 1000 ? (raw + 500) / 1000 : raw;
if (celsius >= 1 && celsius <= 150) return celsius;
}
return null;
}
- /** The first path in {@code paths} that yields a plausible reading, or null. */
private String resolveTempPath(List paths) {
LinkedHashSet seen = new LinkedHashSet<>();
for (String path : paths) {
@@ -446,13 +348,8 @@ public class HudMetrics {
return null;
}
- // =======================================================================
- // Temperature danger bands (green / amber / red)
- // =======================================================================
-
public enum TempSensor { CPU, GPU, BATTERY }
- /** Amber and red onset in °C. Always °C internally — conversion happens at format time only. */
public static final class Thresholds {
public final int amberC;
public final int redC;
@@ -463,19 +360,14 @@ public class HudMetrics {
public static final int TEMP_COLOR_AMBER = 0xFFFFB74D;
public static final int TEMP_COLOR_RED = 0xFFEF5350;
- // Tier-2 fallbacks, used when the device declares no usable trip points. Battery is the only one
- // with a well-grounded number (Li-ion); CPU/GPU vary enough between parts that these are a
- // deliberately conservative guess — which is exactly why trip points are preferred.
private static final int DEFAULT_RED_CPU = 90;
private static final int DEFAULT_RED_GPU = 90;
private static final int DEFAULT_RED_BAT = 48;
- /** Amber sits a proportional step below red, which scales sensibly across both CPU/GPU and battery ranges. */
public static int amberForRed(int redC) {
return Math.round(redC * 0.88f);
}
- /** Parsed temperature display options, shared by every overlay so they can't disagree. */
public static final class TempDisplay {
public final boolean fahrenheit;
public final boolean colorBands;
@@ -518,17 +410,11 @@ public class HudMetrics {
}
}
- // Trip points are a device constant — resolve once per sensor and keep it.
private final java.util.EnumMap autoThresholdCache =
new java.util.EnumMap<>(TempSensor.class);
private final java.util.EnumSet autoThresholdResolved =
java.util.EnumSet.noneOf(TempSensor.class);
- /**
- * Bands for a sensor. Manual mode uses the user's red point; Auto prefers the device's OWN
- * declared thermal trip points (per-device truth — a handheld with active cooling and a phone on
- * the same SoC throttle at very different points) and falls back to {@link #DEFAULT_RED_CPU} etc.
- */
public Thresholds resolveThresholds(TempSensor sensor, TempDisplay display) {
if (display != null && !display.auto) {
int red = display.manualRedFor(sensor);
@@ -547,15 +433,6 @@ public class HudMetrics {
return new Thresholds(amberForRed(red), red);
}
- /**
- * Bands from the kernel's declared trip points for the zone we actually read this sensor from:
- * amber ← the lowest sane {@code passive} trip, red ← the lowest sane {@code hot}/{@code critical}.
- *
- * Returns null unless a usable red point is found. Two real traps this guards against: some
- * zones aren't temperature sensors at all (a Snapdragon's {@code pm8550-bcl-lvl0} battery-current
- * limiter reports {@code trip_point_0_temp = 1} with type {@code passive}), and the GPU is often
- * read from a non-thermal_zone node ({@code /sys/class/kgsl/...}) that has no trip points.
- */
private Thresholds readTripPointThresholds(TempSensor sensor) {
String tempPath;
switch (sensor) {
@@ -563,7 +440,7 @@ public class HudMetrics {
tempPath = resolveTempPath(discoverPrioritizedGpuTempPaths());
break;
case BATTERY:
- return null; // battery temp comes from BatteryManager, not a thermal zone
+ return null;
default:
tempPath = resolveTempPath(discoverPrioritizedCpuTempPaths());
}
@@ -580,8 +457,6 @@ public class HudMetrics {
Integer raw = readIntFromLine(tripTemp.getPath());
if (raw == null) continue;
int celsius = raw > 1000 ? (raw + 500) / 1000 : raw;
- // Reject anything that isn't plausibly a thermal trip — this is what filters out the
- // battery-current-limiter zones whose "trip point" is a level index, not a temperature.
if (celsius < 20 || celsius > 120) continue;
String typePath = tripTemp.getPath().replaceAll("_temp$", "_type");
@@ -600,7 +475,6 @@ public class HudMetrics {
return new Thresholds(amber, red);
}
- /** Band colour for a reading, or {@code fallbackColor} when banding is off or the value is unusable. */
public static int tempColor(Float celsius, Thresholds t, TempDisplay display, int fallbackColor) {
if (celsius == null || t == null || display == null || !display.colorBands) return fallbackColor;
if (celsius >= t.redC) return TEMP_COLOR_RED;
@@ -608,7 +482,6 @@ public class HudMetrics {
return TEMP_COLOR_OK;
}
- /** Formats a °C reading in the user's unit. Thresholds stay °C; only display converts. */
public static String formatTemp(float celsius, TempDisplay display, boolean oneDecimal) {
boolean f = display != null && display.fahrenheit;
float value = f ? celsius * 9f / 5f + 32f : celsius;
@@ -618,15 +491,12 @@ public class HudMetrics {
: String.format(Locale.ENGLISH, "%d%s", Math.round(value), unit);
}
- // =======================================================================
- // Battery
- // =======================================================================
public static final class Battery {
public final float watts;
public final boolean charging;
- public final Integer percent; // 0..100 or null
- public final Integer tempC; // rounded °C or null
- public final String runtimeText; // "LEFT 2h 5m" / "LEFT CHG" / null
+ public final Integer percent;
+ public final Integer tempC;
+ public final String runtimeText;
Battery(float watts, boolean charging) {
this(watts, charging, null, null, null);
@@ -640,29 +510,23 @@ public class HudMetrics {
}
}
- /** power_supply current_now channels (µA) for the dual-battery sum. */
private static final String[] CURRENT_CHANNELS = {
"/sys/class/power_supply/battery/current_now",
"/sys/class/power_supply/bms/current_now",
"/sys/class/power_supply/main/current_now",
};
- /** power_supply voltage_now channels (µV) — fallback when {@code EXTRA_VOLTAGE} reads 0 on some
- * OEM firmwares (e.g. HONOR Magic), which otherwise leaves wattage stuck at 0.0W. */
private static final String[] VOLTAGE_CHANNELS = {
"/sys/class/power_supply/battery/voltage_now",
"/sys/class/power_supply/bms/voltage_now",
"/sys/class/power_supply/main/voltage_now",
};
- /** power_supply power_now channels (µW) — last-resort direct power reading when neither the
- * BatteryManager voltage nor {@code voltage_now} is available. */
private static final String[] POWER_CHANNELS = {
"/sys/class/power_supply/battery/power_now",
"/sys/class/power_supply/bms/power_now",
"/sys/class/power_supply/main/power_now",
};
- /** First readable long across a channel list, or null. */
private static Long readLongFromChannels(String[] paths) {
for (String p : paths) {
Long v = readLongFromLine(p);
@@ -671,15 +535,6 @@ public class HudMetrics {
return null;
}
- /**
- * ADDITIVE charging fallback from {@code /sys/class/power_supply/*}, consulted ONLY when the
- * framework gave no usable charge state ({@code EXTRA_STATUS == UNKNOWN} or a null battery intent).
- * A charger/input supply reporting {@code online == 1} — or the battery's own {@code status} reading
- * "Charging"/"Full" — means charging. Returns TRUE when charging is positively detected, FALSE when a
- * status/online node is readable but not charging, or null when nothing usable exists (so the caller
- * keeps its own default). Never reached on devices whose BatteryManager reports a real status
- * (e.g. Adreno phones/handhelds), so it cannot change their reading.
- */
private static Boolean readChargingFromPowerSupply() {
File[] supplies = new File("/sys/class/power_supply").listFiles();
if (supplies == null || supplies.length == 0) return null;
@@ -690,14 +545,12 @@ public class HudMetrics {
type = type == null ? "" : type.trim().toLowerCase(Locale.US);
boolean isBattery = type.equals("battery") || name.equals("battery") || name.equals("bms");
if (isBattery) {
- // The battery's own status string is the most direct charge-state signal.
String st = readFirstLine(new File(supply, "status").getPath());
if (st == null) continue;
st = st.trim().toLowerCase(Locale.US);
- sawSignal = Boolean.FALSE; // "discharging"/"not charging" both land here
+ sawSignal = Boolean.FALSE;
if (st.equals("charging") || st.equals("full")) return Boolean.TRUE;
} else {
- // A charger / power-input supply (ac/usb/wireless/mains/dc): online==1 → external power in.
Long online = readLongFromLine(new File(supply, "online").getPath());
if (online == null) continue;
sawSignal = Boolean.FALSE;
@@ -712,55 +565,6 @@ public class HudMetrics {
private static final double RUNTIME_SMOOTHING_OLD_WEIGHT = 0.65;
private static final double RUNTIME_SMOOTHING_NEW_WEIGHT = 0.35;
- /**
- * Legacy discharge-only power reading used by the classic overlays + {@code PerfHudView}:
- * watts are reported only while discharging (0 when charging). When {@code dualBattery} is set the
- * per-cell current channels are summed to correct devices that report only one cell's current.
- */
- public Battery getBattery(boolean dualBattery) {
- Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
- boolean charging = false;
- int voltageMv = 0;
- if (status != null) {
- charging = status.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0;
- voltageMv = status.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0);
- } else {
- // ADDITIVE: no battery intent → best-effort power_supply charge probe (default stays false).
- // Never reached on devices that deliver ACTION_BATTERY_CHANGED (e.g. Adreno).
- Boolean sysCharging = readChargingFromPowerSupply();
- if (sysCharging != null) charging = sysCharging;
- }
- long microAmps;
- if (dualBattery) {
- long sum = 0; int n = 0;
- for (String path : CURRENT_CHANNELS) {
- Long v = readLongFromLine(path);
- if (v != null) { sum += Math.abs(v); n++; }
- }
- microAmps = n > 0 ? -sum : readCurrentNowFallback();
- } else {
- microAmps = readCurrentNowFallback();
- }
- // current_now's SIGN convention is device-dependent — many OEMs (Xiaomi/Poco especially)
- // report discharge as POSITIVE, the opposite of the AOSP convention. Gating wattage on the
- // sign therefore reads 0W on battery on those devices (and only shows a value while charging).
- // Use the magnitude for the power figure; the reliable charge DIRECTION is EXTRA_PLUGGED (the
- // `charging` flag), which the HUD already uses for the PWR/CHG label.
- // EXTRA_VOLTAGE reads 0 on some OEM firmwares even though current is fine — fall back to the
- // power_supply voltage_now (µV → mV) so wattage isn't stuck at 0.
- if (voltageMv <= 0) {
- Long uv = readLongFromChannels(VOLTAGE_CHANNELS);
- if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L);
- }
- // current unit auto-detected (mA vs µA) — see currentRawToAmps.
- float watts = (voltageMv / 1000f) * currentRawToAmps(microAmps);
- return new Battery(watts, charging);
- }
-
- /**
- * Full GameNative-parity battery snapshot for the GameNative-style HUD: %, power W (magnitude,
- * regardless of charge/discharge), smoothed runtime-left estimate, and temperature.
- */
public Battery collectBattery() {
BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE);
if (bm == null) return new Battery(0f, false);
@@ -771,15 +575,12 @@ public class HudMetrics {
Intent status = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (status == null) {
- // No battery intent at all → additive power_supply charge probe (default stays false).
Boolean sysCharging = readChargingFromPowerSupply();
return new Battery(0f, sysCharging != null && sysCharging, percent, null, null);
}
int st = status.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN);
boolean charging = st == BatteryManager.BATTERY_STATUS_CHARGING || st == BatteryManager.BATTERY_STATUS_FULL;
- // ADDITIVE: only when the framework reports no usable charge state do we consult power_supply.
- // A device that reports CHARGING/DISCHARGING/FULL/NOT_CHARGING (e.g. Adreno) never enters this.
if (st == BatteryManager.BATTERY_STATUS_UNKNOWN) {
Boolean sysCharging = readChargingFromPowerSupply();
if (sysCharging != null) charging = sysCharging;
@@ -792,23 +593,16 @@ public class HudMetrics {
int rawTemp = status.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0);
if (rawTemp > 0) tempC = Math.round(rawTemp / 10f);
- // EXTRA_VOLTAGE reads 0 on some OEM firmwares (e.g. HONOR Magic) even though capacity, temp
- // and current all work — which left wattage stuck at 0.0W. Fall back to the power_supply
- // voltage_now (µV → mV); if voltage is still unavailable, use power_now (µW) directly.
if (voltageMv <= 0) {
Long uv = readLongFromChannels(VOLTAGE_CHANNELS);
if (uv != null && uv > 0) voltageMv = (int) (Math.abs(uv) / 1000L);
}
- // Wattage = current × voltage, with current UNIT auto-detected (CURRENT_NOW is nominally µA but
- // many OEMs — e.g. this HONOR — report mA; see batteryCurrentAmps). currentMicroAmps above stays
- // as-is for the runtime estimate (a ratio, so unit-independent). If neither current nor voltage
- // resolves, fall back to power_now (µW) directly.
float amps = batteryCurrentAmps(bm);
float watts = 0f;
if (amps > 0f && voltageMv > 0) {
watts = (voltageMv / 1000f) * amps;
} else {
- Long uw = readLongFromChannels(POWER_CHANNELS); // µW
+ Long uw = readLongFromChannels(POWER_CHANNELS);
if (uw != null && uw != 0L) watts = (float) (Math.abs(uw) / 1_000_000.0);
}
@@ -842,24 +636,12 @@ public class HudMetrics {
return minutes + "m";
}
- private long readCurrentNowFallback() {
- BatteryManager bm = (BatteryManager) context.getSystemService(Context.BATTERY_SERVICE);
- return bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW);
- }
-
- /** Raw battery current reading → AMPS, with unit AUTO-DETECT. {@code BATTERY_PROPERTY_CURRENT_NOW}
- * (and {@code current_now}) are nominally µA, but many OEMs report mA — a small number, whereas a
- * real µA reading is always huge. So a magnitude < 20000 means the device gave mA. Without this,
- * wattage on mA-reporting devices came out ~1000× too small → 0.0W even though current & voltage are
- * fine. Sentinel/zero → 0. (Heuristic mirrors Ludashi-plus WinlatorHUD.getBatteryCurrentAmps().) */
private static float currentRawToAmps(long raw) {
if (raw == 0L || raw == Long.MIN_VALUE) return 0f;
long a = Math.abs(raw);
- return a < 20000L ? a / 1000f : a / 1_000_000f; // mA → A, else µA → A
+ return a < 20000L ? a / 1000f : a / 1_000_000f;
}
- /** Battery current in AMPS for the wattage calc: framework property first, then power_supply
- * {@code current_now} sysfs (battery/bms/main) when it's unsupported ({@code Long.MIN_VALUE}) or 0. */
private float batteryCurrentAmps(BatteryManager bm) {
long raw = (bm != null) ? bm.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) : 0L;
if (raw == 0L || raw == Long.MIN_VALUE) {
@@ -869,11 +651,6 @@ public class HudMetrics {
return currentRawToAmps(raw);
}
- // =======================================================================
- // Clock frequencies (best-effort, nullable)
- // =======================================================================
-
- /** Peak CPU core clock in MHz (max scaling_cur_freq across cores), or null when unreadable. */
public Integer getCpuClockMhz() {
int cores = Runtime.getRuntime().availableProcessors();
long maxKhz = 0;
@@ -882,29 +659,26 @@ public class HudMetrics {
if (cur != null && cur > maxKhz) maxKhz = cur;
}
if (maxKhz <= 0) return null;
- return (int) (maxKhz / 1000L); // kHz → MHz
+ return (int) (maxKhz / 1000L);
}
private List gpuClockPathsCache = null;
private static final String[] GPU_CLOCK_PATHS = {
- "/sys/class/kgsl/kgsl-3d0/gpuclk", // Adreno, Hz
- "/sys/class/kgsl/kgsl-3d0/devfreq/cur_freq", // Adreno devfreq, Hz
- "/sys/class/kgsl/kgsl-3d0/clock_mhz", // some kernels, already MHz
- "/sys/kernel/gpu/gpu_clock", // Mali vendor node, MHz
- "/sys/devices/platform/gpusysfs/gpu_clock", // Exynos Mali vendor node, MHz
- "/sys/class/misc/mali0/device/clock", // Mali, kHz/MHz
- "/sys/class/devfreq/gpu/cur_freq", // generic devfreq gpu, Hz
- // ── Additive cross-vendor fallbacks (appended LAST: only reached when every node above
- // is unreadable, so Adreno/Mali devices that already resolve are unaffected). ──
- "/sys/kernel/ged/hal/current_freqency", // MediaTek GED (vendor's spelling), kHz
- "/sys/kernel/ged/hal/gpu_clock", // MediaTek GED alt, MHz
+ "/sys/class/kgsl/kgsl-3d0/gpuclk",
+ "/sys/class/kgsl/kgsl-3d0/devfreq/cur_freq",
+ "/sys/class/kgsl/kgsl-3d0/clock_mhz",
+ "/sys/kernel/gpu/gpu_clock",
+ "/sys/devices/platform/gpusysfs/gpu_clock",
+ "/sys/class/misc/mali0/device/clock",
+ "/sys/class/devfreq/gpu/cur_freq",
+ "/sys/kernel/ged/hal/current_freqency",
+ "/sys/kernel/ged/hal/gpu_clock",
};
private List discoverGpuClockPaths() {
if (gpuClockPathsCache != null) return gpuClockPathsCache;
LinkedHashSet found = new LinkedHashSet<>();
for (String p : GPU_CLOCK_PATHS) if (new File(p).canRead()) found.add(p);
- // Vendor-agnostic devfreq walk: any gpu/mali/panfrost/xclipse/sgpu node's cur_freq (Hz).
File[] devfreqRoots = {
new File("/sys/class/devfreq"),
new File("/sys/devices/virtual/devfreq"),
@@ -928,49 +702,31 @@ public class HudMetrics {
return gpuClockPathsCache;
}
- /** GPU clock in MHz (Adreno KGSL / devfreq / Mali nodes), or null when no source reports it. */
public Integer getGpuClockMhz() {
for (String p : discoverGpuClockPaths()) {
Long raw = readLongFromLine(p);
if (raw == null || raw <= 0) continue;
long mhz;
- if (raw >= 1_000_000L) mhz = raw / 1_000_000L; // Hz
- else if (raw >= 10_000L) mhz = raw / 1_000L; // kHz (defensive)
- else mhz = raw; // already MHz
+ if (raw >= 1_000_000L) mhz = raw / 1_000_000L;
+ else if (raw >= 10_000L) mhz = raw / 1_000L;
+ else mhz = raw;
if (mhz >= 1 && mhz <= 3000) return (int) mhz;
}
return null;
}
- // =======================================================================
- // VRAM (best-effort, Java-only sysfs — no native rebuild)
- // =======================================================================
- // Covered here in pure Java: Adreno KGSL (bytes mapped/allocated) and amdgpu-style drm nodes
- // (Samsung Xclipse / "sgpu", AMD-on-Exynos), which expose mem_info_vram_used/_total directly.
- // TODO(native): VK_EXT_memory_budget as a vendor-agnostic fallback for Mali/PowerVR, which expose
- // no world-readable VRAM node. FEASIBILITY (checked 2026-07): the compositor's Vulkan layer
- // (app/src/main/cpp/winlator/vulkan.c) creates an EPHEMERAL VkInstance/VkPhysicalDevice inside
- // getRenderer() and destroys it every call — it holds no reusable device and loads only the
- // properties/extension entrypoints, not vkGetPhysicalDeviceMemoryProperties2. Wiring the budget
- // query therefore means a new JNI method + enabling VK_KHR_get_physical_device_properties2 /
- // VK_EXT_memory_budget at instance create + an NDK rebuild — out of scope for a source-only pass.
- // CAVEAT to document if/when added: this HUD is an app-side overlay, NOT in the game's Vulkan
- // device, so an app-created instance reports the DEVICE-WIDE heap budget (a GPU-memory-pressure
- // proxy), not the game's per-process VRAM. Devices with no readable node return null → HUD hides VRAM.
private List vramUsedPathsCache = null;
private static final String[] VRAM_USED_PATHS = {
- "/sys/class/kgsl/kgsl-3d0/gpumem_mapped", // bytes currently mapped to the GPU
- "/sys/class/kgsl/kgsl-3d0/page_alloc", // bytes allocated to the KGSL page pool
- "/sys/class/kgsl/kgsl-3d0/mapped", // alt name on some kernels
- "/sys/class/drm/card0/device/mem_info_vram_used",// amdgpu / Xclipse (sgpu), bytes
- "/sys/class/kgsl/kgsl-3d0/page_alloc_max", // high-water fallback
+ "/sys/class/kgsl/kgsl-3d0/gpumem_mapped",
+ "/sys/class/kgsl/kgsl-3d0/page_alloc",
+ "/sys/class/kgsl/kgsl-3d0/mapped",
+ "/sys/class/drm/card0/device/mem_info_vram_used",
+ "/sys/class/kgsl/kgsl-3d0/page_alloc_max",
};
- // Total-VRAM companions (amdgpu exposes one; used only by the diagnostics report).
private static final String[] VRAM_TOTAL_PATHS = {
"/sys/class/drm/card0/device/mem_info_vram_total",
};
- /** Used GPU memory in bytes from Adreno KGSL sysfs, or null when this device exposes nothing readable. */
public Long getVramUsedBytes() {
if (vramUsedPathsCache == null) {
ArrayList found = new ArrayList<>();
@@ -986,50 +742,33 @@ public class HudMetrics {
private static Long sanitizeVramBytes(Long v) {
if (v == null) return null;
- if (v < (1L << 20)) return null; // < 1 MiB → idle-zero / not a byte count
- if (v > 64L * (1L << 30)) return null; // > 64 GiB → not bytes
+ if (v < (1L << 20)) return null;
+ if (v > 64L * (1L << 30)) return null;
return v;
}
- /** Used GPU memory as "x.xGiB" (or "yMiB"), or null when no VRAM source is readable. */
- public String getVramUsedText() {
- Long b = getVramUsedBytes();
- return b == null ? null : formatGiB(b);
- }
-
private static String formatGiB(long bytes) {
return String.format(Locale.US, "%.1fGiB", bytes / (1024.0 * 1024.0 * 1024.0));
}
- // =======================================================================
- // Single cached snapshot — read EVERYTHING once per HUD refresh
- // =======================================================================
-
- /**
- * Immutable point-in-time reading of every metric the DroidDeck HUD draws. Collected in one pass by
- * {@link #snapshot()} so the view never touches sysfs / BatteryManager during draw (the user's
- * explicit ask: one collection pass, cached, so gameplay isn't strained). All numeric fields are
- * raw (nullable when unreadable); the view formats + colours them.
- */
public static final class Snapshot {
- public final Integer cpuPercent; // 0..100 or null
- public final Integer gpuPercent; // 0..100 or null
- public final Integer cpuClockMhz; // MHz or null
- public final Integer gpuClockMhz; // MHz or null
- public final Integer cpuTempC; // °C or null
- public final Integer gpuTempC; // °C or null
+ public final Integer cpuPercent;
+ public final Integer gpuPercent;
+ public final Integer cpuClockMhz;
+ public final Integer gpuClockMhz;
+ public final Integer cpuTempC;
+ public final Integer gpuTempC;
public final long ramUsedBytes;
public final long ramTotalBytes;
- public final float ramPercent; // 0..100
- public final Long vramUsedBytes; // bytes or null
- public final Battery battery; // never null
- // ---- Mega-only extras ----
- public final int[] perCorePercent; // per-core 0..100, -1 unknown (never null; may be empty)
- public final int[] perCoreClockMhz; // per-core MHz, 0 unknown (never null; may be empty)
- public final Long swapUsedBytes; // bytes or null (no swap)
- public final Long swapTotalBytes; // bytes or null
- public final Long netDownBps; // bytes/sec down, or null (first sample / unreadable)
- public final Long netUpBps; // bytes/sec up, or null
+ public final float ramPercent;
+ public final Long vramUsedBytes;
+ public final Battery battery;
+ public final int[] perCorePercent;
+ public final int[] perCoreClockMhz;
+ public final Long swapUsedBytes;
+ public final Long swapTotalBytes;
+ public final Long netDownBps;
+ public final Long netUpBps;
Snapshot(Integer cpuPercent, Integer gpuPercent, Integer cpuClockMhz, Integer gpuClockMhz,
Integer cpuTempC, Integer gpuTempC, long ramUsedBytes, long ramTotalBytes,
@@ -1066,7 +805,6 @@ public class HudMetrics {
}
}
- /** One collection pass over every metric. Call from the HUD's own ~1 s refresh thread, never draw. */
public Snapshot snapshot() {
if (mango != null) return mangoSnapshot(mango);
Integer cpu = getCpuUsagePercent();
@@ -1102,7 +840,6 @@ public class HudMetrics {
net == null ? null : net[0], net == null ? null : net[1]);
}
- // DroidDeck: the same snapshot with MangoHud's sources and rounding (see MangoReadings).
private Snapshot mangoSnapshot(MangoReadings m) {
MangoReadings.Cpu cpu = m.cpu();
MangoReadings.Memory mem = m.memory();
@@ -1121,11 +858,6 @@ public class HudMetrics {
net == null ? null : net[0], net == null ? null : net[1]);
}
- // =======================================================================
- // Mega-view extras: per-core CPU, swap, network (best-effort, nullable)
- // =======================================================================
-
- /** Per-core clock in MHz (each core's {@code scaling_cur_freq}); 0 for a core that can't be read. */
public int[] getPerCoreClockMhz() {
int cores = Runtime.getRuntime().availableProcessors();
int[] out = new int[Math.max(0, cores)];
@@ -1136,11 +868,9 @@ public class HudMetrics {
return out;
}
- // Per-core /proc/stat deltas — one prev sample per core, seeded on the first read.
private long[] lastCoreTotal = null;
private long[] lastCoreIdle = null;
- /** Per-core CPU usage 0..100 from the {@code cpuN} /proc/stat deltas; -1 for a core not yet sampled. */
public int[] readPerCorePercent() {
int cores = Runtime.getRuntime().availableProcessors();
int[] out = new int[Math.max(0, cores)];
@@ -1153,8 +883,8 @@ public class HudMetrics {
try (BufferedReader r = new BufferedReader(new FileReader("/proc/stat"))) {
String line;
while ((line = r.readLine()) != null) {
- if (!line.startsWith("cpu")) break; // cpu lines are first; stop at the block end
- if (line.length() < 4 || !Character.isDigit(line.charAt(3))) continue; // skip aggregate "cpu "
+ if (!line.startsWith("cpu")) break;
+ if (line.length() < 4 || !Character.isDigit(line.charAt(3))) continue;
String[] parts = line.trim().split("\\s+");
int core;
try { core = Integer.parseInt(parts[0].substring(3)); } catch (Exception e) { continue; }
@@ -1165,8 +895,8 @@ public class HudMetrics {
try {
long v = Long.parseLong(parts[i]);
total += v;
- if (i == 4) idle = v; // idle
- else if (i == 5) idle += v; // + iowait
+ if (i == 4) idle = v;
+ else if (i == 5) idle += v;
} catch (NumberFormatException e) { ok = false; break; }
}
if (!ok) continue;
@@ -1179,10 +909,6 @@ public class HudMetrics {
}
}
} catch (Exception ignored) {}
- // Frequency fallback for any core /proc/stat couldn't give us — restricted on some firmwares
- // (e.g. HONOR / Android 16), which is also why the aggregate getCpuUsagePercent() falls back.
- // scaling_cur_freq / cpuinfo_max_freq is a load proxy (per-core clocks ARE readable there), and
- // it also seeds the very first sample. Cores with a real /proc/stat delta keep it (no regression).
for (int i = 0; i < out.length; i++) {
if (out[i] >= 0) continue;
Long cur = readLongFromLine("/sys/devices/system/cpu/cpu" + i + "/cpufreq/scaling_cur_freq");
@@ -1193,7 +919,6 @@ public class HudMetrics {
return out;
}
- /** Swap {used, total} in bytes from /proc/meminfo, or null when there is no swap. */
public long[] getSwapBytes() {
long totalKb = -1, freeKb = -1;
try (BufferedReader r = new BufferedReader(new FileReader("/proc/meminfo"))) {
@@ -1215,10 +940,8 @@ public class HudMetrics {
return v == null ? -1 : v;
}
- // Net rate: cumulative rx/tx bytes + the wall-clock of the previous read → bytes/sec.
private long lastNetRx = -1, lastNetTx = -1, lastNetWallMs = 0;
- /** Network {down, up} in bytes/sec (all interfaces except loopback), or null on the first/failed read. */
public long[] readNetRateBps() {
long rx = 0, tx = 0;
boolean any = false;
@@ -1241,7 +964,7 @@ public class HudMetrics {
long now = SystemClock.elapsedRealtime();
long prevRx = lastNetRx, prevTx = lastNetTx, prevWall = lastNetWallMs;
lastNetRx = rx; lastNetTx = tx; lastNetWallMs = now;
- if (prevRx < 0 || prevWall <= 0) return null; // seed
+ if (prevRx < 0 || prevWall <= 0) return null;
long dt = now - prevWall;
if (dt <= 0) return null;
long down = Math.max(0L, (rx - prevRx) * 1000L / dt);
@@ -1249,20 +972,8 @@ public class HudMetrics {
return new long[]{down, up};
}
- // =======================================================================
- // Diagnostics report (invoked ONLY on user tap — never on the HUD refresh path)
- // =======================================================================
-
- /**
- * One-tap plain-text device report for the "Export HUD diagnostics" action: for every metric it
- * lists the resolved source path/API + current value, or MISS + the exact candidates it tried,
- * followed by an "exists on this device" dump (thermal zone types, devfreq/kgsl/mali nodes) so a
- * device owner can send back the real path for anything that missed. Does a short prime+sample so
- * delta-based readers (CPU%/GPU%/net) report a live value. Run OFF the main thread.
- */
public String buildDiagnosticsReport(Context ctxIn) {
final Context ctx = ctxIn != null ? ctxIn : this.context;
- // Prime the delta-based readers, wait, then take a live sample.
snapshot();
try { Thread.sleep(350L); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
Snapshot s = snapshot();
@@ -1281,7 +992,6 @@ public class HudMetrics {
sb.append("\n== Metrics ==\n");
- // ---- CPU ----
boolean procStat = new File("/proc/stat").canRead();
if (s.cpuPercent != null) {
metricHit(sb, "CPU %", s.cpuPercent + "%",
@@ -1292,7 +1002,6 @@ public class HudMetrics {
"/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq");
}
- // Per-core %
StringBuilder cores = new StringBuilder();
for (int i = 0; i < s.perCorePercent.length; i++) {
if (i > 0) cores.append(' ');
@@ -1304,7 +1013,6 @@ public class HudMetrics {
else
metricMiss(sb, "Per-core %", "/proc/stat");
- // Per-core clock
StringBuilder coreClk = new StringBuilder();
for (int i = 0; i < s.perCoreClockMhz.length; i++) {
if (i > 0) coreClk.append(' ');
@@ -1317,7 +1025,6 @@ public class HudMetrics {
else
metricMiss(sb, "CPU clock", "/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq");
- // CPU temp
String cpuTempPath = resolveTempPath(discoverPrioritizedCpuTempPaths());
if (s.cpuTempC != null && cpuTempPath != null)
metricHit(sb, "CPU temp", s.cpuTempC + "°C", cpuTempPath);
@@ -1325,7 +1032,6 @@ public class HudMetrics {
metricMiss(sb, "CPU temp",
"/sys/class/thermal/thermal_zone*/temp (types: cpu*/cpuss/tsens/mtktscpu/s5p-tmu/soc)");
- // ---- GPU ----
String gpuUsagePath = firstReadableGpuUsagePath();
if (s.gpuPercent != null && gpuUsagePath != null)
metricHit(sb, "GPU %", s.gpuPercent + "%", gpuUsagePath);
@@ -1347,7 +1053,6 @@ public class HudMetrics {
"/sys/class/kgsl/kgsl-3d0/temp", "/sys/class/misc/mali0/device/temp",
"/sys/class/thermal/thermal_zone*/temp (types: gpu*/gpuss/g3d/kgsl/mali/mtktsgpu)");
- // ---- Memory ----
metricHit(sb, "RAM", s.ramUsedText() + " / " + s.ramTotalText()
+ " (" + Math.round(s.ramPercent) + "%)", "ActivityManager.MemoryInfo");
if (s.swapUsedBytes != null)
@@ -1355,7 +1060,6 @@ public class HudMetrics {
else
metricMiss(sb, "SWAP", "/proc/meminfo (SwapTotal=0 or unreadable)");
- // VRAM
String vramPath = firstReadableVramUsedPath();
if (s.vramUsedBytes != null && vramPath != null) {
Long total = readFirstReadableLong(VRAM_TOTAL_PATHS);
@@ -1366,14 +1070,12 @@ public class HudMetrics {
new ArrayList<>(), VRAM_USED_PATHS);
}
- // ---- Net ----
if (s.netDownBps != null)
metricHit(sb, "NET", formatRate(s.netDownBps) + " down / " + formatRate(s.netUpBps) + " up",
"/proc/net/dev");
else
metricMiss(sb, "NET", "/proc/net/dev (first sample or unreadable)");
- // ---- Battery ----
Battery b = s.battery;
StringBuilder bat = new StringBuilder();
bat.append(b.percent != null ? b.percent + "%" : "?%")
@@ -1383,7 +1085,6 @@ public class HudMetrics {
.append(b.runtimeText != null ? ", " + b.runtimeText : "");
metricHit(sb, "Battery", bat.toString(), "BatteryManager + /sys/class/power_supply");
- // ---- Exists-on-device dump ----
appendExistsDump(sb);
return sb.toString();
}
@@ -1415,7 +1116,7 @@ public class HudMetrics {
}
private String firstReadableVramUsedPath() {
- if (vramUsedPathsCache == null) getVramUsedBytes(); // builds the cache
+ if (vramUsedPathsCache == null) getVramUsedBytes();
if (vramUsedPathsCache == null) return null;
for (String p : vramUsedPathsCache) if (sanitizeVramBytes(readLongFromLine(p)) != null) return p;
return null;
@@ -1479,12 +1180,6 @@ public class HudMetrics {
appendGpuNoRootProbes(sb);
}
- /** Probes for a ROOT-FREE GPU-load path on locked-sysfs Adreno devices (SM8750/HONOR etc.):
- * (1) can the app open the KGSL device node? The sysfs mirror (/sys/class/kgsl) is SELinux-blocked
- * (vendor_sysfs_kgsl), but /dev/kgsl-3d0 is labeled gpu_device and is normally app-openable —
- * that's the door for reading GPU busy via ioctl perf-counters instead of sysfs.
- * (2) is there an app-readable devfreq node under /sys/devices/platform that bypasses the blocked
- * /sys/class/kgsl symlink (a free win for at least the clock). */
private void appendGpuNoRootProbes(StringBuilder sb) {
sb.append("-- GPU no-root reachability probes --\n");
sb.append(" /dev/kgsl-3d0 open O_RDWR: ")
@@ -1516,8 +1211,6 @@ public class HudMetrics {
if (!found) sb.append(" devfreq (platform kgsl): (no app-readable node found)\n");
}
- /** open()+close() a path with the given flags, reporting OK or the errno class — used to test
- * whether the app's sandbox can reach a device node without root. */
private static String probeOpen(String path, int flags) {
java.io.FileDescriptor fd = null;
try {
@@ -1530,8 +1223,6 @@ public class HudMetrics {
}
}
- /** Battery-wattage inputs: which power_supply nodes this app can actually READ, and their raw
- * values. Confirms the voltage_now / power_now fallback for devices where EXTRA_VOLTAGE is 0. */
private void appendPowerSupplyDump(StringBuilder sb) {
sb.append("-- /sys/class/power_supply (battery watts inputs) --\n");
File[] nodes = new File("/sys/class/power_supply").listFiles();
@@ -1582,9 +1273,6 @@ public class HudMetrics {
return names.length() == 0 ? "(no readable files)" : names.toString();
}
- // =======================================================================
- // Low-level helpers
- // =======================================================================
private static int clampPercent(int v) { return v < 0 ? 0 : (v > 100 ? 100 : v); }
private static String readFirstLine(String path) {
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java
index 1c3a1ba9b..f455d8525 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java
+++ b/app/src/main/java/com/droiddeck/launcher/hud/KeyValueSet.java
@@ -8,7 +8,7 @@
* link to https://github.com/The412Banner/FusionHUD in the project documentation
* AND in the in-app credits/about screen.
*
- * Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
+ * Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
*/
package com.droiddeck.launcher.hud;
@@ -20,17 +20,14 @@ import java.util.Iterator;
public class KeyValueSet implements Iterable {
private String data = "";
- // Default constructor for an empty KeyValueSet
public KeyValueSet() {
this.data = "";
}
- // Constructor to initialize with a string representation of key-value pairs
public KeyValueSet(String data) {
this.data = data != null && !data.isEmpty() ? data : "";
}
- // Helper method to find the start and end indices of a key in the data string
private int[] indexOfKey(String key) {
int start = 0;
int end = data.indexOf(",");
@@ -48,37 +45,31 @@ public class KeyValueSet implements Iterable {
return null;
}
- // Method to get the value associated with a key, or an empty string if the key is not present
public String get(String key) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return "";
}
- // Method to get the value associated with a key, or a fallback value if the key is not present
public String get(String key, String fallback) {
for (String[] keyValue : this) if (keyValue[0].equals(key)) return keyValue[1];
return fallback;
}
- // Method to get a boolean value associated with a key, or a fallback value if the key is not present
public boolean getBoolean(String key, boolean fallback) {
String value = get(key);
if (value.isEmpty()) return fallback;
return value.equals("1") || value.equals("t") || value.equalsIgnoreCase("true");
}
- // Method to get a float value associated with a key, or a fallback value if the key is not present
public float getFloat(String key, float fallback) {
String value = get(key);
try {
if (!value.isEmpty()) return Float.parseFloat(value);
} catch (NumberFormatException e) {
- // Ignore exception and return fallback
}
return fallback;
}
- // Method to set a value for a key in the key-value data
public void put(String key, Object value) {
int[] range = indexOfKey(key);
if (range != null) {
@@ -88,21 +79,15 @@ public class KeyValueSet implements Iterable {
}
}
- // Method to set a float value for a key in the key-value data
public void put(String key, float value) {
put(key, String.valueOf(value));
}
- // Iterator implementation for key-value pairs in the format [key, value]
@NonNull
@Override
public Iterator iterator() {
final int[] start = {0};
final int[] end = {data.indexOf(",")};
- // A single-item config (no comma) gives indexOf == -1, making hasNext() (start < end)
- // immediately false so the sole pair was never iterated (get() then returned the
- // fallback). Guard it exactly as next() already does. An empty string still yields
- // end == 0 → no iteration, which is correct.
if (end[0] == -1) end[0] = data.length();
final String[] item = new String[2];
return new Iterator() {
diff --git a/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt
index 828bcc0f5..ade7aa656 100644
--- a/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt
+++ b/app/src/main/java/com/droiddeck/launcher/hud/MangoReadings.kt
@@ -3,12 +3,6 @@ package com.droiddeck.launcher.hud
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
-/**
- * The readings Steam's performance overlay shows in a DroidDeck session (Valve's mangohud 20260902.1,
- * tools/mangoapp), taken from the files the session hands it and computed the same way, so the two
- * overlays agree. Sources and formulas follow that build: cpu.cpp calculateCPUData, deckard_temp.cpp,
- * msm.cpp getQcomGpuInfo, memory.cpp update_meminfo and battery.cpp getPercent / getPower.
- */
internal class MangoReadings(private val sessionDir: File, private val rootDir: File) {
class Cpu(val percent: Int, val perCore: IntArray)
class Memory(val usedBytes: Long, val totalBytes: Long, val swapUsedBytes: Long, val swapTotalBytes: Long)
@@ -17,7 +11,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
private val cpuTimes = HashMap()
private val cpuPercents = HashMap()
- /** Total and per-core load from the session's /proc/stat (DroidDeck's stand-in when the app can't read it). */
@JvmOverloads
fun cpu(stat: File = statFile()): Cpu? {
val lines = runCatching { stat.readLines() }.getOrNull() ?: return null
@@ -38,7 +31,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
File(sessionDir, "proc-stat").takeIf { !File("/proc/stat").canRead() && it.isFile } ?: File("/proc/stat")
private fun cpuPercent(key: String, v: LongArray): Int {
- // user nice system idle iowait irq softirq steal guest guest_nice; guest time is already in user.
val user = v[0] - v[8]
val nice = v[1] - v[9]
val systemAll = v[2] + v[5] + v[6]
@@ -55,7 +47,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
return busy.coerceIn(0f, 100f).toInt().also { cpuPercents[key] = it }
}
- /** Average of the zones the session types cpuN-thermal, truncated. */
fun cpuTempC(): Int? {
var sum = 0f
var count = 0
@@ -85,7 +76,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
return (milli / 1000.0).toInt()
}
- /** MemTotal - MemAvailable and SwapTotal - SwapFree from /proc/meminfo. */
fun memory(): Memory? {
val kb = HashMap()
runCatching {
@@ -102,10 +92,8 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
return Memory((total - available) * 1024, total * 1024, (swapTotal - swapFree) * 1024, swapTotal * 1024)
}
- /** The KGSL total DroidDeck shares with the session (GpuMemComponent). */
fun vramBytes(): Long? = fresh(File(rootDir, VRAM_FILE), VRAM_FRESH_MS)?.let(::readLong)
- /** BAT* under the session's power_supply (BatteryComponent): no watts while charging, full or unknown. */
fun battery(): Battery? {
val dirs = File(sessionDir, "sys/power_supply").listFiles { f -> f.name.contains("BAT") }?.sortedBy { it.name }
if (dirs.isNullOrEmpty()) return null
@@ -149,7 +137,6 @@ internal class MangoReadings(private val sessionDir: File, private val rootDir:
private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull()
private fun readFloat(file: File): Float? = runCatching { file.readLines().first().trim().toFloat() }.getOrNull()
- /** std::stod on the first line: the leading number, "6 %" -> 6. */
fun leadingNumber(file: File): Double? = runCatching {
Regex("^\\s*-?\\d+(\\.\\d+)?").find(file.readLines().first())?.value?.trim()?.toDouble()
}.getOrNull()
diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
index 46ce99798..8cbe135e7 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
@@ -612,7 +612,6 @@ public class FakeInputWriter {
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null;
- // Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length.
ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq);
nativeStoreFence();
return output;
diff --git a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt
index b8be5bf54..55588d6fd 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/GesturePrefs.kt
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.input
import android.content.Context
-/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
diff --git a/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt
index 68d5722d0..4d69f68c5 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/GestureTouchStreamGate.kt
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.input
import android.view.MotionEvent
-/** Keeps split Android touch targets from restarting one shared gesture recognizer. */
internal class GestureTouchStreamGate {
enum class Source { SURFACE, SCREEN_KEYS, ON_SCREEN_CONTROLS }
diff --git a/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt b/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt
index 21ca4717d..61dfe32c4 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/HomeButtonService.kt
@@ -37,7 +37,6 @@ object HomeButtonShortcuts {
}
fun onKeyEvent(context: Context, event: KeyEvent): Boolean {
- // AYN maps its system-assistant key (gpio-keys KEY_F24) to HOME too; only the pad's Home is Guide.
if (!HomeGuideInput.isHome(event.keyCode) || !PadBridge.isControllerKeyEvent(event)) return false
return input.onKeyEvent(event, enabled() && HomeApp.isDefault(context))
}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt
index fd289f734..e4d712044 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/HomeGuideInput.kt
@@ -46,7 +46,6 @@ class HomeGuideInput(private val setPressed: (Boolean) -> Unit, private val onCa
}
companion object {
- // The Retroid Nova pad falls back to Generic.kl, which maps its Home scan code to MOVE_HOME.
fun isHome(keyCode: Int) = keyCode == KeyEvent.KEYCODE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_HOME
private const val DUO_QUICK_MENU_SCAN_CODE = 194
diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
index b7fced710..8f546e8b0 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt
@@ -672,7 +672,6 @@ class OnScreenControls(
private fun closestSnap(position: Float, targets: List): Float? =
targets.minByOrNull { abs(position - it) }?.takeIf { abs(position - it) <= dp(SNAP_DP) }
- /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean =
editing || keyboardContains(x, y) || (!buttonsOnly && quickContains(x, y)) ||
(!quickHidden && (controlAt(x, y) ?: adaptiveStickAt(x, y)) != null)
diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
index 7dd234daf..edb95fbe6 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
@@ -250,11 +250,6 @@ public final class PadBridge {
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
- public static boolean isControllerKey(KeyEvent event) {
- return isFromController(event.getDevice())
- && isPadKey(event.getKeyCode(), event.getDevice().getKeyboardType());
- }
-
public static boolean isPadKey(int keyCode, int keyboardType) {
if ((keyCode >= KeyEvent.KEYCODE_BUTTON_A && keyCode <= KeyEvent.KEYCODE_BUTTON_MODE)
|| (keyCode >= KeyEvent.KEYCODE_BUTTON_1 && keyCode <= KeyEvent.KEYCODE_BUTTON_16)) return true;
@@ -616,7 +611,6 @@ public final class PadBridge {
private void writePad(PadState report) {
if (backQamAxesBlocked && !hasAxes(state)) backQamAxesBlocked = false;
long at = android.os.SystemClock.uptimeMillis();
- // Steam must see the modifier before a partner; preserve quick press/release pairs in order.
if (backQamPressed || homeGuidePressed || !backQamWrites.isEmpty()) at += backQamDelayMs;
writeBackQamReport(report, at);
}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt
index 2967a06f6..422375432 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKey.kt
@@ -36,12 +36,6 @@ fun screenKeyDirections(shape: Int): List = when (shape) {
else -> emptyList()
}
-val SCREEN_KEY_SHAPES =
- listOf(
- SCREEN_KEY_PILL, SCREEN_KEY_ROUND, SCREEN_KEY_CIRCLE, SCREEN_KEY_RECT,
- SCREEN_KEY_TRIGGER, SCREEN_KEY_DPAD, SCREEN_KEY_STICK,
- )
-
data class ScreenKey(
val codes: List,
val x: Float,
@@ -55,9 +49,4 @@ data class ScreenKey(
val toggle: Boolean = false,
val labels: Boolean = true,
) {
- fun shownLabel(context: Context): String = when {
- text.isNotEmpty() -> text
- glyph.isNotEmpty() -> glyph
- else -> screenKeyLabel(context, codes)
- }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt
index df129888c..be1f658f3 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/ScreenKeys.kt
@@ -609,7 +609,6 @@ class ScreenKeys(context: Context, private val sendKey: (Int, Boolean) -> Unit)
else -> entry.halfH
}
- /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || entryAt(x, y) != null
private fun entryAt(x: Float, y: Float): Entry? = entries.lastOrNull { entry ->
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
index 38f7f76d9..0f18132ef 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt
@@ -19,7 +19,6 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int) {
it != NONE && it.id == remembered && (it != DECK_CONTROLS || deckPad) && (it != DASHBOARD || dashboard) &&
(it != DESKTOP || desktop)
}?.let { return it }
- // No pick yet: the dashboard. A pick that can't open right now keeps the old fallback.
val picked = entries.any { it != NONE && it.id == remembered }
return when {
dashboard && !picked -> DASHBOARD
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt
index 68eed5ede..672cf9b47 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt
@@ -112,7 +112,6 @@ class SteamTouchControls(
private var selected = -1
private var dragOffset = 0f to 0f
private var pinchStart = 0f
- private var pinchScale = 1f
private var saving = false
private val pendingBindings = HashMap()
private var pendingColor: FloatArray? = null
@@ -244,7 +243,6 @@ class SteamTouchControls(
private fun hit(list: List, x: Float, y: Float): Element? =
list.filter { reach(it, x, y) <= 1.15f }.minByOrNull { reach(it, x, y) }
- /** Whether a finger going down at ([x], [y]) lands on something here, as [onTouchEvent] decides it. */
fun takesTouchAt(x: Float, y: Float): Boolean = editing || menuOpen || hit(live(), x, y) != null
private fun live(): List =
@@ -350,17 +348,6 @@ class SteamTouchControls(
canvas.restore()
}
- private fun drawSteamMark(canvas: Canvas, x: Float, y: Float, r: Float, color: Int) {
- val p = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color; style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND }
- p.strokeWidth = r * 0.16f
- canvas.drawCircle(x + r * 0.28f, y - r * 0.18f, r * 0.27f, p)
- canvas.drawCircle(x + r * 0.28f, y - r * 0.18f, r * 0.1f, Paint(p).apply { style = Paint.Style.FILL })
- canvas.drawCircle(x - r * 0.3f, y + r * 0.3f, r * 0.17f, p)
- p.strokeWidth = r * 0.2f
- canvas.drawLine(x + r * 0.1f, y - r * 0.02f, x - r * 0.2f, y + r * 0.22f, p)
- canvas.drawLine(x - r * 0.45f, y + r * 0.28f, x - r * 0.95f, y + r * 0.1f, p)
- }
-
private fun drawFace(canvas: Canvas, type: Int, binding: SteamTouchBindings.Binding?, x: Float, y: Float, r: Float,
alpha: Int, pressed: Boolean) {
val square = isSquare(type)
@@ -855,19 +842,6 @@ class SteamTouchControls(
invalidate()
}
- private fun controlName(type: Int) = when (type) {
- SteamTouchConfig.DPAD -> context.getString(R.string.touch_dpad)
- SteamTouchConfig.JOYSTICK_LEFT -> context.getString(R.string.touch_left_stick)
- SteamTouchConfig.JOYSTICK_RIGHT -> context.getString(R.string.touch_right_stick)
- SteamTouchConfig.TRACKPAD_LEFT -> context.getString(R.string.deck_left_trackpad)
- SteamTouchConfig.TRACKPAD_RIGHT -> context.getString(R.string.deck_right_trackpad)
- SteamTouchConfig.TRACKPAD_CENTER -> context.getString(R.string.deck_surface_trackpad)
- SteamTouchConfig.STEAM -> "Steam"
- SteamTouchConfig.THUMB -> context.getString(R.string.touch_menu)
- SteamTouchConfig.KEYBOARD -> context.getString(R.string.touch_keyboard)
- else -> label(type)
- }
-
private fun moreMenu() {
val host = parent as? android.view.ViewGroup ?: return
val density = resources.displayMetrics.density
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt
index 2d63358d3..560bff4f0 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt
@@ -74,8 +74,6 @@ class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
}
- fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
-
fun state(): State {
repeat(8) {
val seq = buffer.getLong(OFF_STATE_SEQ)
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt
index 3e0ca925a..062bfbfdc 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/SteamVolumeKeys.kt
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.input
import android.media.AudioManager
import android.view.KeyEvent
-/** Use Steam's PulseAudio bridge for feedback instead of requesting Android's volume UI. */
internal object SteamVolumeKeys {
fun dispatch(event: KeyEvent, enabled: Boolean, adjust: (Int) -> Unit): Boolean {
val direction = when (event.keyCode) {
diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java
index 140b4ace4..819226764 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/TouchscreenGestures.java
@@ -28,7 +28,6 @@ public final class TouchscreenGestures {
void key(int code, boolean down);
void wheel(int steps);
void showKeyboard();
- /** Establish the pointer origin before a relative middle-mouse pan begins. */
default void prepareRelativePan() {}
default void gesture(Gesture gesture) {}
}
@@ -97,12 +96,10 @@ public final class TouchscreenGestures {
final int code;
MouseButton(int code) { this.code = code; }
}
- // Long-press detection
private Runnable longPressRunnable;
private boolean longPressTriggered;
private boolean longPressActionHeld, twoFingerHoldActionHeld, threeFingerHoldActionHeld;
- // Double-tap detection
private long lastTapTime;
private float lastTapX;
private float lastTapY;
@@ -110,76 +107,54 @@ public final class TouchscreenGestures {
private float doubleTapAnchorX, doubleTapAnchorY;
private long singleFingerDownTime;
- // Drag tracking (box selection)
private boolean isDragging;
private boolean dragButtonPressed;
- // Two-finger tracking
private boolean twoFingerDragging;
private float twoFingerLastX0, twoFingerLastY0;
private float twoFingerLastX1, twoFingerLastY1;
private boolean twoFingerMiddleButtonDown;
- // Pinch tracking
private float pinchLastDistance;
private float pinchZoomRemainder;
private float keyPanRemainderX, keyPanRemainderY;
private static final float PINCH_STEP_PX = 10f;
- // Two-finger tap detection
private boolean twoFingerTapPossible;
private boolean twoFingerTapFired;
- // Track which WASD/arrow keys are currently held
private boolean panKeyUp, panKeyDown, panKeyLeft, panKeyRight;
- // True when finger has moved beyond tap tolerance (prevents spurious tap on lift)
private boolean movedBeyondTapThreshold;
- // True after a multi-finger gesture has been active — prevents the remaining
- // finger from starting a spurious single-finger drag on lift sequence.
private boolean multiFingerGestureUsed;
- // Remember which keycodes were actually pressed so releasePanKeys releases only those
private Integer panLeftKey, panRightKey, panUpKey, panDownKey;
- // Long-press movement tolerance (pixels of raw finger movement before cancelling long-press)
private static final float LONG_PRESS_MOVE_TOLERANCE = 50f;
- // Raw touch-down coordinates for measuring finger travel
private float touchDownRawX, touchDownRawY;
- // Last raw coordinates for single-finger drag delta
private float singleFingerLastRawX, singleFingerLastRawY;
- // Two-finger gesture mutual exclusion (only pan OR pinch, not both)
private static final int TWO_FINGER_GESTURE_NONE = 0;
private static final int TWO_FINGER_GESTURE_PAN = 1;
private static final int TWO_FINGER_GESTURE_ZOOM = 2;
private int twoFingerGestureMode = TWO_FINGER_GESTURE_NONE;
- // Accumulated deltas for deciding which two-finger gesture to lock into
private float accumulatedPinchDelta, accumulatedPanDelta;
- // Settle window: ignore movement for first N ms after second finger lands (#1)
- // Also serves as a grace period for a third finger to arrive before 2F locks in
private static final long TWO_FINGER_SETTLE_MS = 120;
private static final long THREE_FINGER_SETTLE_MS = 50;
private long twoFingerDownTime;
private long threeFingerDownTime;
- // Velocity gating for two-finger hold vs drag (#2)
private long twoFingerLastMoveTime;
- private static final float MIN_DRAG_VELOCITY = 0.3f; // px/ms to commit to drag
+ private static final float MIN_DRAG_VELOCITY = 0.3f;
- // Pinch vs drag ratio: pinch delta must exceed pan by this factor to classify as pinch (#4)
private static final float PINCH_RATIO_THRESHOLD = 1.5f;
- // Cancel-and-reclassify: allow reclassification within this window after lock-in (#5)
private static final long RECLASSIFY_WINDOW_MS = 100;
private long twoFingerLockTime;
private float reclassifyPinchDelta, reclassifyPanDelta;
- // Gesture pointer ownership tracking (prevents d-pad/button pointer conflicts)
private int gestureFingerCount = 0;
private final ArrayList gestureOwnedPointerIds = new ArrayList<>();
private final int[] multiFingerStartIds = new int[3];
private final float[] multiFingerStartX = new float[3], multiFingerStartY = new float[3];
private int multiFingerStartCount;
- // Two-finger hold detection
private Runnable twoFingerHoldRunnable;
private boolean twoFingerHoldTriggered;
- // Three-finger tracking
private boolean threeFingerTapPossible;
private boolean threeFingerTapFired;
private boolean threeFingerDragging;
@@ -199,9 +174,7 @@ public final class TouchscreenGestures {
switch (action) {
case MotionEvent.ACTION_DOWN:
- // A new touch must not shorten or discard an already recognized click.
handleTsCancel(true);
- // First pointer overall — gesture system claims it
gestureOwnedPointerIds.clear();
gestureOwnedPointerIds.add(pointerId);
gestureFingerCount = 1;
@@ -209,13 +182,11 @@ public final class TouchscreenGestures {
break;
case MotionEvent.ACTION_POINTER_DOWN:
- // New pointer arrived — gesture system claims it
if (!gestureOwnedPointerIds.contains(pointerId)) {
gestureOwnedPointerIds.add(pointerId);
gestureFingerCount++;
}
if (gestureFingerCount == 1) {
- // First gesture finger (controls own earlier pointers)
handleTsDown(event, actionIndex);
} else if (gestureFingerCount == 2) {
handleTsPointerDown(event);
@@ -242,15 +213,12 @@ public final class TouchscreenGestures {
gestureOwnedPointerIds.remove(Integer.valueOf(pointerId));
gestureFingerCount = Math.max(0, gestureFingerCount - 1);
if (prevCount == 3 && gestureFingerCount == 2) {
- // Three-finger gesture ending
handleTsThreeFingerUp(event);
} else if (prevCount == 2 && gestureFingerCount == 1) {
- // Two-finger gesture ending (2→1)
handleTsPointerUp(event);
} else if (prevCount >= 4) {
if (removedIndex < 3) rebaseThreeFingerMovement(event);
} else if (prevCount >= 1 && gestureFingerCount == 0) {
- // Last gesture finger lifted while controls still hold pointers
handleTsUp(event, actionIndex);
}
}
@@ -261,9 +229,6 @@ public final class TouchscreenGestures {
int prevCount = gestureFingerCount;
gestureOwnedPointerIds.remove(Integer.valueOf(pointerId));
gestureFingerCount = Math.max(0, gestureFingerCount - 1);
- // Clean up multi-finger state if needed, routing by previous
- // gesture mode so a 3-finger gesture isn't ended via the
- // two-finger cleanup path.
if (prevCount >= 3) {
handleTsThreeFingerUp(event);
} else if (prevCount == 2) {
@@ -271,7 +236,6 @@ public final class TouchscreenGestures {
}
handleTsUp(event, actionIndex);
}
- // ACTION_UP = last pointer overall — full cleanup
gestureOwnedPointerIds.clear();
gestureFingerCount = 0;
break;
@@ -309,15 +273,12 @@ public final class TouchscreenGestures {
threeFingerHoldTriggered = false;
accumulatedThreeFingerDelta = 0;
- // Store raw touch coordinates for long-press movement tolerance
touchDownRawX = event.getX(actionIndex);
touchDownRawY = event.getY(actionIndex);
singleFingerDownTime = SystemClock.uptimeMillis();
singleFingerLastRawX = touchDownRawX;
singleFingerLastRawY = touchDownRawY;
- // Keep a nearby re-tap undecided until UP: it can still become a drag,
- // long press, or multi-finger gesture instead of a double click.
if (isDoubleTap(event, actionIndex)) {
secondTapCandidate = true;
doubleTapAnchorX = lastTapX;
@@ -327,7 +288,6 @@ public final class TouchscreenGestures {
if (!secondTapCandidate && delayedPress == null && pendingTapPress == null) moveCursorTo(x, y);
- // Schedule long-press
if (gestureConfig.getLongPressEnabled()) {
longPressRunnable = () -> {
secondTapCandidate = false;
@@ -342,7 +302,6 @@ public final class TouchscreenGestures {
postDelayed(longPressRunnable, gestureConfig.getLongPressDelay());
}
- // Tap: immediate cursor move (press is deferred to UP for click, or MOVE for drag)
}
private void handleTsPointerDown(MotionEvent event) {
@@ -351,11 +310,6 @@ public final class TouchscreenGestures {
flushPendingTapRelease();
lastTapTime = 0;
flushPendingHoldClickRelease();
- // Cancel any single-finger long-press. cancelLongPressTimer only
- // clears the pending runnable; if the long-press has already fired
- // we must also release the held action before we transition into a
- // multi-finger gesture, otherwise the long-press button/key stays
- // down for the rest of the touch sequence.
if (longPressTriggered) {
longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction());
longPressTriggered = false;
@@ -388,7 +342,6 @@ public final class TouchscreenGestures {
event.getY(idx0) - event.getY(idx1));
recordMultiFingerOrigins(event, 2);
}
- // Schedule two-finger hold
if (gestureConfig.getTwoFingerHoldEnabled()) {
twoFingerHoldRunnable = () -> {
twoFingerHoldTriggered = true;
@@ -404,33 +357,26 @@ public final class TouchscreenGestures {
}
private void handleTsMove(MotionEvent event, int pointerIndex) {
- // After a multi-finger gesture, ignore single-finger movement until all fingers lift
if (multiFingerGestureUsed) return;
int x = (int) event.getX(pointerIndex);
int y = (int) event.getY(pointerIndex);
- // If long press already triggered, ignore movement (right-click already sent)
if (longPressTriggered) return;
- // Measure raw finger travel from initial touch point
float rawTravel = maxFingerTravel(event, pointerIndex, touchDownRawX, touchDownRawY);
if (secondTapCandidate && (rawTravel > doubleTapSlop
|| maxFingerTravel(event, pointerIndex, doubleTapAnchorX, doubleTapAnchorY) >= doubleTapSlop)) {
secondTapCandidate = false;
}
- // The threshold decides when a drag starts. An active drag must keep
- // following the finger even when it returns through the touch origin.
if (!isDragging && rawTravel <= LONG_PRESS_MOVE_TOLERANCE) {
return;
}
- // Beyond tolerance — cancel long press
cancelLongPressTimer();
movedBeyondTapThreshold = true;
- // Detect drag start
if (!isDragging && gestureConfig.getDragEnabled()) {
flushPendingTapRelease();
String dragAction = gestureConfig.getDragAction();
@@ -438,14 +384,7 @@ public final class TouchscreenGestures {
dragButtonPressed = true;
singleFingerLastRawX = event.getX(pointerIndex);
singleFingerLastRawY = event.getY(pointerIndex);
- // Send the configured drag press immediately so that a short
- // move followed straight by UP still produces a press+release
- // pair. A zero-delta performPanAction just presses the button
- // (or is a no-op for the key-pan variants, which press per
- // direction on their next move frame).
if (isClickDragAction(dragAction)) {
- // The candidate's cursor stayed at the previous click. Start
- // a drag at this touch's own origin before pressing its button.
moveCursorTo((int) touchDownRawX, (int) touchDownRawY);
if (useRelativeMouseDragMovement()) {
pressClickDragAction(dragAction);
@@ -456,7 +395,6 @@ public final class TouchscreenGestures {
performPanAction(0f, 0f, dragAction);
}
notifyGesture(Gesture.DRAG);
- // Non-click pan actions continue with real deltas on the next frame.
return;
}
@@ -481,14 +419,11 @@ public final class TouchscreenGestures {
return;
}
- // Move cursor
moveCursorTo(x, y);
}
private void handleTsTwoFingerMove(MotionEvent event) {
if (multiFingerGestureUsed) return;
- // If a two-finger hold has already fired, ignore subsequent jitter so
- // it can't lock into a pan/zoom gesture mid-hold.
if (twoFingerHoldTriggered) return;
if (gestureFingerCount < 2 || gestureOwnedPointerIds.size() < 2) return;
@@ -496,20 +431,15 @@ public final class TouchscreenGestures {
int idx1 = event.findPointerIndex(gestureOwnedPointerIds.get(1));
if (idx0 < 0 || idx1 < 0) return;
- // Settling delays recognition, not movement tracking. A swipe or pinch
- // must stop being a tap/hold even when it finishes inside that delay.
if (multiFingerMovementExceeded(event)) {
twoFingerTapPossible = false;
cancelTwoFingerHoldTimer();
}
- // Slow deliberate travel can cross touch slop before reaching the
- // pan/zoom threshold. It must not turn into a stationary hold first.
if (multiFingerMovementExceeded(event, true)) cancelTwoFingerHoldTimer();
float x0 = event.getX(idx0), y0 = event.getY(idx0);
float x1 = event.getX(idx1), y1 = event.getY(idx1);
- // Compute per-frame pan and pinch deltas
float currDist = (float) Math.hypot(x0 - x1, y0 - y1);
float framePinchDelta = Math.abs(currDist - pinchLastDistance);
@@ -523,7 +453,6 @@ public final class TouchscreenGestures {
long now = SystemClock.uptimeMillis();
if (now - twoFingerDownTime < TWO_FINGER_SETTLE_MS) {
- // Still settling — update stored positions but don't accumulate deltas
twoFingerLastX0 = x0; twoFingerLastY0 = y0;
twoFingerLastX1 = x1; twoFingerLastY1 = y1;
pinchLastDistance = currDist;
@@ -531,14 +460,12 @@ public final class TouchscreenGestures {
}
long frameTime = now - twoFingerLastMoveTime;
twoFingerLastMoveTime = now;
- float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0; // px/ms
+ float velocity = (frameTime > 0) ? framePanDelta / frameTime : 0;
- // Accumulate deltas until we have enough movement to decide gesture type
if (twoFingerGestureMode == TWO_FINGER_GESTURE_NONE) {
accumulatedPinchDelta += framePinchDelta;
accumulatedPanDelta += framePanDelta;
if (accumulatedPinchDelta + accumulatedPanDelta > (float) gestureConfig.getGestureThreshold()) {
- // Enough movement to lock into a gesture — no longer a tap or hold
twoFingerTapPossible = false;
cancelTwoFingerHoldTimer();
if (accumulatedPinchDelta > accumulatedPanDelta * PINCH_RATIO_THRESHOLD
@@ -551,7 +478,6 @@ public final class TouchscreenGestures {
twoFingerLockTime = now;
notifyGesture(Gesture.TWO_FINGER_DRAG);
} else if (accumulatedPanDelta >= accumulatedPinchDelta && gestureConfig.getTwoFingerDragEnabled()) {
- // Fallback: pan is dominant but velocity was too low — still drag
twoFingerGestureMode = TWO_FINGER_GESTURE_PAN;
twoFingerLockTime = now;
notifyGesture(Gesture.TWO_FINGER_DRAG);
@@ -571,7 +497,7 @@ public final class TouchscreenGestures {
reclassifyPinchDelta += framePinchDelta;
reclassifyPanDelta += framePanDelta;
float reclassifyTotal = reclassifyPinchDelta + reclassifyPanDelta;
- if (reclassifyTotal > 20f) { // enough post-lock data to reclassify
+ if (reclassifyTotal > 20f) {
boolean shouldBeZoom = reclassifyPinchDelta > reclassifyPanDelta * PINCH_RATIO_THRESHOLD;
if (shouldBeZoom && twoFingerGestureMode == TWO_FINGER_GESTURE_PAN
&& gestureConfig.getPinchEnabled()) {
@@ -585,7 +511,7 @@ public final class TouchscreenGestures {
resetSmallMovement();
notifyGesture(Gesture.TWO_FINGER_DRAG);
}
- twoFingerLockTime = 0; // done reclassifying
+ twoFingerLockTime = 0;
reclassifyPinchDelta = 0;
reclassifyPanDelta = 0;
}
@@ -595,12 +521,10 @@ public final class TouchscreenGestures {
pinchZoomRemainder = accumulateSignedMovement(pinchZoomRemainder, delta);
if (Math.abs(pinchZoomRemainder) >= PINCH_STEP_PX) {
performZoomAction(pinchZoomRemainder > 0);
- // Keep the fractional travel, without queuing a burst of old
- // zoom actions after a large move or a direction reversal.
pinchZoomRemainder %= PINCH_STEP_PX;
}
}
- pinchLastDistance = currDist; // Always update baseline to avoid stale deltas
+ pinchLastDistance = currDist;
if (twoFingerGestureMode == TWO_FINGER_GESTURE_PAN) {
String twoFingerDragAction = gestureConfig.getTwoFingerDragAction();
if (isClickDragAction(twoFingerDragAction)) {
@@ -627,14 +551,11 @@ public final class TouchscreenGestures {
private void handleTsPointerUp(MotionEvent event) {
cancelTwoFingerHoldTimer();
if (multiFingerMovementExceeded(event)) twoFingerTapPossible = false;
- // Two-finger hold release
if (twoFingerHoldTriggered) {
twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction());
twoFingerHoldTriggered = false;
}
- // Two-finger tap detection
else if (twoFingerTapPossible && !twoFingerDragging && gestureConfig.getTwoFingerTapEnabled()) {
- // Move cursor to midpoint between the two fingers
float midX = (twoFingerLastX0 + twoFingerLastX1) / 2f;
float midY = (twoFingerLastY0 + twoFingerLastY1) / 2f;
queueTapClick(gestureConfig.getTwoFingerTapAction(), midX, midY);
@@ -652,7 +573,6 @@ public final class TouchscreenGestures {
reclassifyPinchDelta = 0;
reclassifyPanDelta = 0;
twoFingerLockTime = 0;
- // Suppress single-finger tap/drag when last finger lifts after any 2F gesture
movedBeyondTapThreshold = true;
multiFingerGestureUsed = true;
}
@@ -661,7 +581,6 @@ public final class TouchscreenGestures {
cancelLongPressTimer();
if (longPressTriggered) {
- // Release the long-press action
longPressActionHeld = releaseHoldAction(longPressActionHeld, gestureConfig.getLongPressAction());
longPressTriggered = false;
return;
@@ -671,7 +590,6 @@ public final class TouchscreenGestures {
float y = event.getY(actionIndex);
if (isDragging && dragButtonPressed) {
- // End drag (release any held drag buttons/keys)
releaseDragInput();
dragButtonPressed = false;
isDragging = false;
@@ -685,8 +603,6 @@ public final class TouchscreenGestures {
? gestureConfig.getLongPressDelay() : ViewConfiguration.getLongPressTimeout();
if (SystemClock.uptimeMillis() - singleFingerDownTime < tapLimit
&& Math.hypot(x - doubleTapAnchorX, y - doubleTapAnchorY) < doubleTapSlop) {
- // A normal left tap already supplied the first click. Other
- // single-tap mappings need a complete synthesized double click.
boolean firstTapClicked = gestureConfig.getTapEnabled()
&& TouchGestureConfig.ACTION_LEFT_CLICK.equals(gestureConfig.getTapAction());
int clicks = firstTapClicked ? 1 : 2;
@@ -697,26 +613,21 @@ public final class TouchscreenGestures {
}
}
- // Two-finger tap already sent its click on POINTER_UP — skip the tap on UP
if (twoFingerTapFired) {
twoFingerTapFired = false;
return;
}
- // Three-finger tap already sent its click — skip the tap on UP
if (threeFingerTapFired) {
threeFingerTapFired = false;
return;
}
- // Simple tap — only if finger stayed within tap tolerance
if (gestureConfig.getTapEnabled() && !movedBeyondTapThreshold) {
queueTapClick(gestureConfig.getTapAction(), x, y);
notifyGesture(Gesture.TAP);
}
- // Record for double-tap detection (even if tap itself is disabled,
- // so double-tap can still prime from single touch-ups)
if (!movedBeyondTapThreshold && (gestureConfig.getTapEnabled() || gestureConfig.getDoubleTapEnabled())) {
lastTapTime = SystemClock.uptimeMillis();
lastTapX = x;
@@ -776,13 +687,10 @@ public final class TouchscreenGestures {
cancelLongPressTimer();
cancelDrag();
cancelTwoFingerHoldTimer();
- // If a two-finger hold already injected its action, release it before
- // transitioning so the held button/key doesn't stay down.
if (twoFingerHoldTriggered) {
twoFingerHoldActionHeld = releaseHoldAction(twoFingerHoldActionHeld, gestureConfig.getTwoFingerHoldAction());
twoFingerHoldTriggered = false;
}
- // Clean up two-finger state
releaseDragInput();
resetSmallMovement();
twoFingerDragging = false;
@@ -797,7 +705,6 @@ public final class TouchscreenGestures {
threeFingerDragOffsetX = threeFingerDragOffsetY = 0f;
threeFingerDownTime = SystemClock.uptimeMillis();
- // Compute midpoint of three gesture fingers
if (gestureOwnedPointerIds.size() >= 3) {
float sumX = 0, sumY = 0;
int validCount = 0;
@@ -809,13 +716,12 @@ public final class TouchscreenGestures {
validCount++;
}
}
- if (validCount < 3) return; // Can't proceed without all 3 pointers
+ if (validCount < 3) return;
threeFingerLastMidX = sumX / 3f;
threeFingerLastMidY = sumY / 3f;
recordMultiFingerOrigins(event, 3);
}
- // Schedule three-finger hold
if (gestureConfig.getThreeFingerHoldEnabled()) {
threeFingerHoldRunnable = () -> {
threeFingerHoldTriggered = true;
@@ -831,8 +737,6 @@ public final class TouchscreenGestures {
private void handleTsThreeFingerMove(MotionEvent event) {
if (gestureFingerCount < 3 || gestureOwnedPointerIds.size() < 3) return;
- // If a three-finger hold has already fired, ignore subsequent jitter so
- // it can't lock into a pan gesture mid-hold.
if (threeFingerHoldTriggered) return;
if (multiFingerMovementExceeded(event)) {
threeFingerTapPossible = false;
@@ -877,8 +781,6 @@ public final class TouchscreenGestures {
}
if (threeFingerGestureMode == THREE_FINGER_GESTURE_PAN) {
- // Once recognized, retain small mouse movements. Keyboard panning
- // accumulates its own direction threshold in performKeyPan.
String threeFingerDragAction = gestureConfig.getThreeFingerDragAction();
if (isClickDragAction(threeFingerDragAction)) {
if (useRelativeMouseDragMovement()) {
@@ -919,7 +821,6 @@ public final class TouchscreenGestures {
multiFingerStartCount = 0;
resetSmallMovement();
threeFingerGestureMode = THREE_FINGER_GESTURE_NONE;
- // Suppress single-finger tap/drag when last finger lifts after any 3F gesture
movedBeyondTapThreshold = true;
multiFingerGestureUsed = true;
}
@@ -966,7 +867,6 @@ public final class TouchscreenGestures {
}
x /= 3f;
y /= 3f;
- // Replacing a tracked finger changes the centroid without moving the cursor.
if (threeFingerDragging) {
threeFingerDragOffsetX += threeFingerLastMidX - x;
threeFingerDragOffsetY += threeFingerLastMidY - y;
@@ -1030,9 +930,6 @@ public final class TouchscreenGestures {
private void startPendingTapClick() {
if (delayedPress != null || pendingTapPress != null || pendingTapClicks.isEmpty()) return;
TapClick click = pendingTapClicks.peekFirst();
- // Games that poll button state need an observable UP interval too.
- // Reuse the first tap's location: normal finger drift must not turn
- // this into two clicks on different targets or a tiny drag.
long delay = Math.max(0, CLICK_DELAYED_TIME - (SystemClock.uptimeMillis() - lastInputReleaseTime));
pendingTapPress = () -> {
pendingTapPress = null;
@@ -1224,9 +1121,6 @@ public final class TouchscreenGestures {
pressButton(MouseButton.BUTTON_MIDDLE);
twoFingerMiddleButtonDown = true;
}
- // Pan from the client's current pointer. Games can recenter the mouse
- // when the middle button goes down; replaying our old absolute touch
- // anchor after that warp causes a large, unintended camera movement.
moveCursorByRelativeDelta(dx, dy);
}
@@ -1287,10 +1181,6 @@ public final class TouchscreenGestures {
}
private void performKeyPan(float dx, float dy, Integer leftKey, Integer rightKey, Integer upKey, Integer downKey) {
- // When fingers are actively moving, update key states to match the
- // swipe direction. When fingers stop moving but stay on screen,
- // keep the current keys held — movement continues until finger-up
- // calls releasePanKeys().
keyPanRemainderX = accumulateSignedMovement(keyPanRemainderX, dx);
keyPanRemainderY = accumulateSignedMovement(keyPanRemainderY, dy);
@@ -1300,9 +1190,6 @@ public final class TouchscreenGestures {
dy = keyPanRemainderY;
keyPanRemainderX = keyPanRemainderY = 0f;
- // --- Significant movement detected: update each axis ---
-
- // Horizontal: determine desired state from this frame's delta
boolean wantLeft = dx < -3f;
boolean wantRight = dx > 3f;
@@ -1311,7 +1198,6 @@ public final class TouchscreenGestures {
if (wantLeft && !panKeyLeft) { pressKey(leftKey); panKeyLeft = true; panLeftKey = leftKey; }
if (wantRight && !panKeyRight) { pressKey(rightKey); panKeyRight = true; panRightKey = rightKey; }
- // Vertical: determine desired state from this frame's delta
boolean wantUp = dy < -3f;
boolean wantDown = dy > 3f;
diff --git a/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt
index 343d18bdd..0734710a3 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/VrControllers.kt
@@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
-/** What SteamVR sees for the game pad. The driver reads the file once, when SteamVR starts. */
object VrControllers {
enum class Emulate(val key: String) { OFF("off"), INDEX("index"), TOUCH("touch"), VIVE("vive") }
@@ -46,7 +45,6 @@ object VrControllers {
fun encode(settings: Settings): String =
"emulate=${settings.emulate.key}\nsticks=${if (settings.sticksMoveHands) "hands" else "game"}\n"
- /** Writes into the session's runtime dir, which the guest sees as /run/droiddeck. */
fun publish(context: Context) {
runCatching {
val dir = File(context.filesDir, ".wayland-rt").apply { mkdirs() }
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt
index 5c5687c96..c45c74821 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt
@@ -23,7 +23,6 @@ object DeckyManager {
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val SETTINGS = "root/homebrew/settings/loader.json"
- // droiddeck-session reads the marker from the root of the client it runs.
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
"root/.local/share/Steam/.cef-enable-remote-debugging",
"root/.droiddeck-x64/.local/share/Steam/.cef-enable-remote-debugging",
@@ -58,7 +57,6 @@ object DeckyManager {
check(staged.renameTo(file)) { "cannot write ${file.path}" }
}
- // Mirrors Decky's updater: Stable takes the newest non-prerelease tag, Prerelease the newest tag of either kind.
fun latest(channels: ReleaseChannels, branch: Int): Release? =
if (branch == BRANCH_STABLE) channels.stable.firstOrNull() else channels.all.firstOrNull()
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
index 4a4857634..2b976796f 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt
@@ -51,7 +51,6 @@ object DesktopCatalog {
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
- /** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List =
@@ -63,7 +62,6 @@ object DesktopCatalog {
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
- /** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
@@ -102,7 +100,6 @@ object DesktopCatalog {
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
- /** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt
index cf0d3d7f6..334bce3df 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeviceControlsComponent.kt
@@ -189,7 +189,6 @@ class DeviceControlsComponent(context: Context, root: File, private val activity
companion object {
private const val TAG = "DeviceControls"
- // AudioService sends these on every stream step; the Settings observer and 250 ms poll lag a held volume key.
private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION"
private const val STREAM_MUTE_CHANGED_ACTION = "android.media.STREAM_MUTE_CHANGED_ACTION"
private const val PERMISSION_CHANNEL = "brightness_permission"
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
index 7db52a9e2..8e5769c31 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
@@ -39,7 +39,6 @@ object GuestCommand {
if (linkDir != null) cmd.add(1, "--link2symlink")
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
binds.forEach { cmd.add("-b"); cmd.add(it) }
- // Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
@@ -48,8 +47,6 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
- // The Steam client installer and the package tools inside the guest fetch over plain curl,
- // so the app proxy has to reach them through the environment proot starts them with.
DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort ->
cmd += listOf(
"http_proxy=http://$hostPort", "https_proxy=http://$hostPort",
@@ -77,7 +74,6 @@ object GuestCommand {
try {
while (process.isAlive && !flag.get()) Thread.sleep(200)
if (flag.get()) {
- // PRoot ignores SIGTERM; SIGQUIT makes it kill its tracees and exit cleanly.
if (pid > 0) {
runCatching { android.system.Os.kill(pid, android.system.OsConstants.SIGQUIT) }
} else {
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
index 04926193a..ab5a58737 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
@@ -50,7 +50,6 @@ public final class LinuxRuntime {
}
public static File liveDir(Context context) {
- // Android may evict cache files even while guest processes are using them.
File dir = new File(context.getNoBackupFilesDir(), "guest-runtime");
dir.mkdirs();
return dir;
@@ -63,9 +62,7 @@ public final class LinuxRuntime {
}
private static final String HOST_DIR = "opt/android-host";
- /** The downloaded add-on pack. The APK does not ship this binary. */
public static final String PROOT_PACK = "add-on pack";
- /** Termux's unpatched proot inside the rootfs. Developer switch only. */
public static final String PROOT_ROOTFS = "rootfs fallback";
public static final String PROOT_MISSING = "missing";
@@ -81,7 +78,6 @@ public final class LinuxRuntime {
return rootfsProot(context).isFile() && rootfsLoader(context).isFile();
}
- /** Patched proot is the add-on pack. The rootfs copy is not that binary. */
public static boolean patchedProot(Context context) {
return AddonPack.PROOT.installed(context);
}
@@ -98,7 +94,6 @@ public final class LinuxRuntime {
return AddonPack.PROOT.file(context, "libproot-loader.so");
}
- /** Where {@link #prootBinary} came from: {@link #PROOT_PACK}, {@link #PROOT_ROOTFS} or {@link #PROOT_MISSING}. */
public static String prootSource(Context context) {
File bin = prootBinary(context);
if (!bin.isFile()) return PROOT_MISSING;
@@ -116,7 +111,6 @@ public final class LinuxRuntime {
return hasRootfs(context) && (patchedProot(context) || rootfsProotReady(context));
}
- /** The rootfs alone: enough to read the game library, while the proot pack can still be downloading. */
public static boolean hasRootfs(Context context) {
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
File root = rootDir(context);
@@ -392,9 +386,6 @@ public final class LinuxRuntime {
return thermalZone("ddr");
}
- // MangoHud reads the GPU and DDR temperatures from these zone numbers (bindAdrenoStats). On phones
- // where they are CPU zones, their type follows the temperature put there, so they stay out of the
- // CPU average.
private static java.util.Map replacedZoneTypes() {
java.util.Map replaced = new java.util.HashMap<>();
if (gpuTempSource() != null) {
@@ -405,7 +396,6 @@ public final class LinuxRuntime {
return replaced;
}
- /** The zones the session's MangoHud averages for the CPU temperature (all typed cpuN-thermal there). */
public static List cpuTempZones() {
java.util.Map replaced = replacedZoneTypes();
List zones = new ArrayList<>();
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java
index ee3349405..50fb07355 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntimeInstaller.java
@@ -244,10 +244,6 @@ public final class LinuxRuntimeInstaller {
}
}
- /**
- * The download is named after the release, so a partial file is only ever resumed for the same
- * version and checksum, never for a different runtime that happens to share a fixed name.
- */
static String archiveName(Release release) {
String version = release.version.replaceAll("[^A-Za-z0-9._-]", "_");
String sum = release.sha256.replaceAll("[^A-Za-z0-9]", "");
@@ -255,7 +251,6 @@ public final class LinuxRuntimeInstaller {
return "linuxfs-" + version + "-" + sum + ".tar.zst";
}
- /** Removes archives left by other releases and by builds that used the fixed name. */
private static void deleteOtherArchives(File dir, String keep) {
File[] files = dir.listFiles((d, name) -> name.startsWith("linuxfs") && name.endsWith(".tar.zst") && !name.equals(keep));
if (files == null) return;
@@ -264,8 +259,6 @@ public final class LinuxRuntimeInstaller {
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), archiveName(release));
- // A failed download keeps what it fetched so the next attempt resumes it; anything that
- // reached verification and failed is not trusted and is removed.
boolean keepPartial = false;
try {
deleteOtherArchives(context.getCacheDir(), archive.getName());
@@ -526,8 +519,6 @@ public final class LinuxRuntimeInstaller {
}
}
- // Packages extract over the live runtime and their archives list a link before the file it
- // names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
return extract(context, archive, destination, destination, listener);
}
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt
index 974b6fe16..e9a9f4a72 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/ProotFastPath.kt
@@ -12,10 +12,6 @@ object ProotFastPath {
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
const val MAX_BINDS = 256
- /**
- * The fast path's trampoline is let through only by the proot built with patches/0014.
- * The rootfs copy (Termux's stock proot) is not that binary, so the library stays out.
- */
fun allows(preference: Boolean, noSeccomp: Boolean, source: String): Boolean =
preference && !noSeccomp && source == LinuxRuntime.PROOT_PACK
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java
index 3b2a39ee8..fbbc5ddae 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeFileTree.java
@@ -74,11 +74,6 @@ public final class RuntimeFileTree {
throw new IOException("Cannot make directory removable: " + dir);
}
- /**
- * Paths in a runtime tree that belong to the user: the home folder, imported AppImages and script apps, and catalog
- * AppImages with their menu entries and markers. Tar-kind catalog packages (the desktop) unpack over /usr and cannot
- * be carried.
- */
static java.util.List userPaths(File tree) throws IOException {
java.util.List paths = new java.util.ArrayList<>(java.util.Arrays.asList("root", "opt/appimages/user", "opt/droiddeck-apps", "opt/droiddeck/wincomponents"));
for (String n : children(new File(tree, "usr/local/share/applications"))) {
@@ -168,13 +163,6 @@ public final class RuntimeFileTree {
return false;
}
- /**
- * Moves each user path that exists in {@code from} into {@code to}, replacing what {@code to} ships there. With
- * {@code onlyMissing}, a path already present in {@code to} is left alone. On failure the paths already moved go back
- * before the exception is thrown. Rollback failures are suppressed on that exception; callers must retain the source
- * of any incomplete rollback.
- * Returns the moved paths.
- */
static java.util.List carryUserData(File from, File to, boolean onlyMissing) throws IOException {
return carryUserData(from, to, onlyMissing, new java.util.ArrayList<>(userPaths(from, to)));
}
@@ -200,7 +188,6 @@ public final class RuntimeFileTree {
return moved;
}
- /** Puts the given paths back from {@code from} into {@code to}; failures are collected, not stopped on. */
static void carryBack(File from, File to, java.util.List paths) throws IOException {
IOException failed = null;
for (int i = paths.size() - 1; i >= 0; i--) {
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt
index f334e6cc5..94d7f443f 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeHealth.kt
@@ -10,7 +10,6 @@ object RuntimeHealth {
fun brokenLibraryLinks(context: Context): List = brokenLibraryLinks(LinuxRuntime.rootDir(context))
- /** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List =
listOf("usr/lib", "usr/lib/aarch64-linux-gnu").flatMap { dir ->
val lib = File(root, dir)
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt
index aa53092fe..a641cf1f2 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/RuntimeInstallService.kt
@@ -117,7 +117,6 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
- /** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt
index 517fa6c74..ef64bfde0 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BatteryComponent.kt
@@ -56,7 +56,6 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING
val timeToFullS = if (charging && chargeFullUah > chargeNowUah && abs(currentAvgUa) > 0) (chargeFullUah - chargeNowUah) * 3600L / abs(currentAvgUa) else 0L
- // Android can keep reporting charging at 100%; Linux reports Full there, and Steam shows 99% until it does.
val statusText = when (status) {
BatteryManager.BATTERY_STATUS_CHARGING -> if (pct >= 100) "Full" else "Charging"
BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging"
@@ -127,9 +126,6 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
companion object {
private const val TAG = "SessionService"
- // CURRENT_NOW/CURRENT_AVERAGE are documented as µA, but Samsung and other vendors report mA,
- // which made every lifetime estimate 1000x too long. No real µA reading under load is below 10 mA,
- // and no mA reading is above 10 A, so anything smaller than 10_000 is taken as mA.
private const val MILLIAMP_CEILING = 10_000L
internal fun toMicroamps(raw: Long): Long = if (abs(raw) < MILLIAMP_CEILING) raw * 1000L else raw
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt
index 953196900..a525bed37 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovie.kt
@@ -24,7 +24,6 @@ object BootMovie {
DROIDDECK_60 to "droiddeck-startup-60.webm",
)
- // Steam's CEF player only takes WebM codecs; MediaMuxer's WebM writer cannot take AV1.
internal val VIDEO_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9, MediaFormat.MIMETYPE_VIDEO_AV1)
private val REMUX_CODECS = setOf(MediaFormat.MIMETYPE_VIDEO_VP8, MediaFormat.MIMETYPE_VIDEO_VP9)
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
@@ -39,18 +38,14 @@ object BootMovie {
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
- /** A movie imported before movies were kept in Steam's own folder. */
internal fun legacyCustomFile(context: Context) = customFile(context)
- /** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
- /** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
fun select(context: Context, id: String) {
SessionPrefs.setBootMovie(context, id)
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
- // Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
@@ -67,7 +62,6 @@ object BootMovie {
}
}
- /** The file the current choice plays from, before any volume or length change. */
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
private fun sourceOf(context: Context, choice: String): File? = when {
@@ -84,7 +78,6 @@ object BootMovie {
return variant(context, source)
}
- /** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
private fun variant(context: Context, source: File): File? {
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
val seconds = SessionPrefs.bootMovieSeconds(context)
@@ -124,21 +117,15 @@ object BootMovie {
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
- /**
- * How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
- * whether it still plays as it is when every step fails.
- */
internal class Plan(val passthrough: Boolean, val steps: List, val sourceOnFailure: Boolean)
internal fun plan(volume: Int, seconds: Int): Plan = when {
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
- // Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
}
- /** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
internal const val MAX_LONG_SIDE = 1920
internal const val MAX_SHORT_SIDE = 1080
@@ -150,22 +137,16 @@ object BootMovie {
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
- /** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
internal fun oversizedPlan(volume: Int): Plan =
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
- /** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
private fun prune(folder: File, keep: String?) {
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
}
- /**
- * Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
- * that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
- */
fun syncFromSteam(context: Context): Boolean {
val named = SessionFiles.startupMovieLocalPath(BootMovieLibrary.steamConfig(context)) ?: return false
val seen = SessionPrefs.bootMovieSteamSeen(context)
@@ -174,7 +155,6 @@ object BootMovie {
if (named == SessionFiles.BOOT_MOVIE_PATH || (named.isEmpty() && seen == null)) return false
val path = named.ifEmpty { "/movies/steam_os_startup.webm" }
val prefix = "/uioverrides/movies/"
- // The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt
index 0dd7a0fad..6fb1f56a9 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieLibrary.kt
@@ -9,22 +9,14 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
-/**
- * The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
- * Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
- * three startup movies and the movies bought in Steam's points shop (neither can be removed).
- */
object BootMovieLibrary {
private const val TAG = "BootMovieLibrary"
const val PREFIX = "file:"
- /** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:`. */
const val STEAM_PREFIX = "steam:"
- /** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
private val lock = Any()
- /** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
const val SOURCE_DROIDDECK = "droiddeck"
@@ -37,28 +29,21 @@ object BootMovieLibrary {
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
- /**
- * The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
- * x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
- */
internal fun customMovieDirs(context: Context): List =
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
- /** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
- /** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
fun idOf(name: String) = PREFIX + name
- /** Every Steam client folder: the usual one and the x86-64 client's. */
internal fun steamRoots(context: Context): List = listOf(
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
).filter { it.isDirectory }
@@ -86,7 +71,6 @@ object BootMovieLibrary {
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
- /** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
val meta = readMeta(context)
if (change(meta)) {
@@ -100,7 +84,6 @@ object BootMovieLibrary {
}
}
- /** The post a movie was installed from, if it came from SteamDeckRepo. */
fun repoId(context: Context, name: String): String? =
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
@@ -128,7 +111,6 @@ object BootMovieLibrary {
val info = meta.optJSONObject(f.name)
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
- // Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
}
val steam = LinkedHashMap()
@@ -147,14 +129,12 @@ object BootMovieLibrary {
for ((id, file) in steam) {
val native = file.nameWithoutExtension.lowercase() in NATIVE
if ((file.name in STAGED && !native) || !seen.add(file.canonicalPath)) continue
- // Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
}
return out
}
- /** The four startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf(
"deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup",
"steam_os_startup" to "SteamOS startup",
@@ -167,13 +147,8 @@ object BootMovieLibrary {
private val scans = HashMap()
- /** Forgets the last search for Steam's own movies. */
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
- /**
- * Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
- * client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
- */
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
val now = System.currentTimeMillis()
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
@@ -191,10 +166,8 @@ object BootMovieLibrary {
scan
}
- /** The item number in the name Steam gives a bought movie, `- _.webm`; null for any other name. */
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
- /** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
private fun boughtTitle(root: File, id: String): String? {
val key = "communityitemid\\\":\\\"$id\\\""
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
@@ -209,7 +182,6 @@ object BootMovieLibrary {
private const val OVERRIDES = "/uioverrides/movies/"
- /** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
internal fun restore(context: Context) = synchronized(lock) {
migrateLegacyMeta(context)
val meta = readMeta(context)
@@ -222,7 +194,6 @@ object BootMovieLibrary {
}
}
- /** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
private fun migrateLegacyMeta(context: Context) {
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
if (!legacy.isFile) return
@@ -238,7 +209,6 @@ object BootMovieLibrary {
legacy.delete()
}
- /** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
private fun mirror(kept: File, copy: File): Boolean {
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
return try {
@@ -255,10 +225,8 @@ object BootMovieLibrary {
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
- /** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
- /** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
fun freeName(context: Context, title: String): String {
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
val dirs = customMovieDirs(context)
@@ -269,10 +237,6 @@ object BootMovieLibrary {
return name
}
- /**
- * Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
- * title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
- */
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
synchronized(lock) {
val name = freeName(context, title)
@@ -283,7 +247,6 @@ object BootMovieLibrary {
name
}
- /** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
editMeta(context) { meta ->
val info = JSONObject().put("title", title).put("source", source)
@@ -301,12 +264,10 @@ object BootMovieLibrary {
val removed = when {
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
else -> {
- // Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
if (!id.startsWith(PREFIX)) return false
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
val copies = customMovieDirs(context).map { File(it, name) }
- // A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
val target = copies.firstOrNull { it.exists() }
val managed = File(libraryDir(context), name)
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
@@ -335,14 +296,12 @@ object BootMovieLibrary {
return removed
}
- /** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun coverName(name: String) = "cover-" + safe(name)
- /** How long a movie runs, in whole seconds; 0 when it cannot be read. */
fun durationSeconds(file: File?): Int {
if (file == null || !file.isFile) return 0
val retriever = MediaMetadataRetriever()
@@ -357,7 +316,6 @@ object BootMovieLibrary {
}
}
- /** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
var best: Bitmap? = null
var bestLuma = -1
@@ -382,11 +340,9 @@ object BootMovieLibrary {
private const val MIN_COVER_LUMA = 24
- /** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
fun cover(context: Context, entry: Entry): File? {
val file = fileOf(context, entry) ?: return null
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
- // Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
if (given.isFile && given.length() > 0L) return given
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt
index 3a672dcfd..18ff31169 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieReport.kt
@@ -8,10 +8,6 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-/**
- * What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
- * where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
- */
object BootMovieReport {
private const val TAG = "BootMovieReport"
private const val MAX_TRACE_LINES = 200
@@ -23,7 +19,6 @@ object BootMovieReport {
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
- /** Adds a line to the short trace kept for the report; only the latest lines are kept. */
fun note(context: Context, line: String) {
synchronized(lock) {
runCatching {
@@ -36,7 +31,6 @@ object BootMovieReport {
}
}
- /** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
fun write(context: Context): File? {
val text = runCatching { build(context) }.getOrElse {
Log.w(TAG, "could not build the report", it)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt
index 92bbbae4e..55f84c006 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BootMovieTranscoder.kt
@@ -46,7 +46,6 @@ internal object BootMovieTranscoder {
return Result(audioDropped = hadAudio && !muxedAudio)
}
- /** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
val audio = runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
@@ -121,7 +120,6 @@ internal object BootMovieTranscoder {
internal class Layout(val width: Int, val height: Int, val x: Int, val y: Int, val contentWidth: Int, val contentHeight: Int)
- // Steam fills the screen with its startup movie, so portrait clips are pillarboxed into 16:9 instead of cropped.
internal fun layout(width: Int, height: Int, rotation: Int): Layout {
val (w, h) = if (rotation % 180 != 0) height to width else width to height
fun even(v: Double) = maxOf(2, (v.toInt() / 2) * 2)
@@ -137,7 +135,6 @@ internal object BootMovieTranscoder {
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
- /** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
val frames = pcm.size / channels
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
@@ -164,7 +161,6 @@ internal object BootMovieTranscoder {
return out
}
- /** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
private const val MAX_PCM_BYTES = 64 shl 20
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
@@ -212,7 +208,6 @@ internal object BootMovieTranscoder {
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
- // Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
index d43b1f617..45e0890c5 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
@@ -7,13 +7,8 @@ import android.service.notification.StatusBarNotification
import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.NotificationManagerCompat
-/** One notification for the bottom-screen list. Kept in memory only; never written to disk or logs. */
data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
-/**
- * Notifications from other apps for the bottom screen, so a message can be read without leaving the game.
- * Works only after the user allows DroidDeck notification access in Android settings.
- */
object BottomNotifications {
val items = mutableStateListOf()
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt
index 90010a82c..905f22715 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/BottomScreenshots.kt
@@ -12,12 +12,10 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-/** A screenshot for the bottom-screen gallery: one DroidDeck took (a MediaStore [uri]) or one Steam saved (a [file]). */
data class BottomShot(val uri: Uri?, val file: File?, val takenAt: Long, val steam: Boolean) {
val model: Any get() = uri ?: file!!
}
-/** Game frames DroidDeck saves to Pictures/DroidDeck, plus the screenshots Steam keeps under userdata. */
object BottomScreenshots {
private const val FOLDER = "DroidDeck"
private const val QUALITY = 95
@@ -58,7 +56,6 @@ object BottomScreenshots {
}
}.getOrDefault(false)
- /** Newest first, at most [LIMIT]. */
fun list(context: Context, runtimeRoot: File): List =
(own(context) + steam(runtimeRoot)).sortedByDescending { it.takenAt }.take(LIMIT)
@@ -85,7 +82,6 @@ object BottomScreenshots {
shots
}.getOrDefault(emptyList())
- /** The .jpg files in userdata//760/remote//screenshots of each Steam home; the thumbnails folder is skipped. */
internal fun steam(runtimeRoot: File): List =
SteamDownloadMonitor.STEAM_HOMES.flatMap { home ->
File(runtimeRoot, "$home/userdata").listFiles { f -> f.isDirectory }.orEmpty().flatMap { account ->
diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
index 8b7ad50f0..b1d70902a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
@@ -34,7 +34,6 @@ object ComponentsManager {
"Dxvk-gplasync-Linux" to "dxvk",
"Vkd3d-proton-Linux" to "vkd3d",
)
- // DD_NIGHTLY holds every component in one release; the file name says which.
private val NIGHTLY_PREFIXES = linkedMapOf("DD-dxvk-" to "dxvk", "DD-vkd3d-proton-" to "vkd3d", "DD-FEXCore-" to "fex")
private val SOURCES = RELEASES.keys.map { NIGHTLIES to it } + (DD_COMPONENTS to DD_NIGHTLY)
private val DOWNLOAD_REPOS = listOf(NIGHTLIES, DD_COMPONENTS)
@@ -976,12 +975,9 @@ object ComponentsManager {
internal fun assetComp(release: String, name: String): String? =
RELEASES[release] ?: if (release == DD_NIGHTLY) NIGHTLY_PREFIXES.entries.firstOrNull { name.startsWith(it.key) }?.value else null
- // Per component: the stable packages newest first, then the nightlies.
internal val CATALOG_ORDER: Comparator =
compareBy { it.comp }.thenBy { it.release == DD_NIGHTLY }.then(newestFirst { it.file })
- // A nightly DD--[+N]-nightly.wcp pairs with the newest stable -[-]-linux.wcp
- // in the same list, so the stable row can offer it behind a Stable/Nightly switch. Unpaired nightlies keep their own row.
internal fun nightlyPairs(items: List): Map {
val stable = items.filter { it.release != DD_NIGHTLY }
return items.filter { it.release == DD_NIGHTLY }.mapNotNull { n ->
diff --git a/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt b/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt
index 92cd7652d..d3471283e 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/FexCatalog.kt
@@ -7,11 +7,6 @@ import com.droiddeck.launcher.core.Downloader
import org.json.JSONObject
import java.io.File
-/**
- * The FEX build DroidDeck-Components' fex.json names for x86 programs. A session uses the last copy read.
- * Components' build-fex.yml replaces the archive on its single `fex` release and records the build's version;
- * a release per build (fex-2610-r5) is still read.
- */
object FexCatalog {
private const val TAG = "FexCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/fex.json"
@@ -43,7 +38,6 @@ object FexCatalog {
fun cached(context: Context): Build? = runCatching { parse(file(context).readText()) }.getOrNull()
- /** Reads fex.json straight from GitHub (a digest never comes from a mirror), at most every ten minutes. */
@Synchronized
fun refresh(context: Context, now: Long = System.currentTimeMillis()): Build? {
if (now - refreshedAt < REFRESH_MS) return cached(context)
@@ -62,7 +56,6 @@ object FexCatalog {
return build
}
- /** For droiddeck-fex-update: the build and every source its archive may come from (it checks the sha256). */
fun environment(build: Build, sources: List = DownloadSource.candidates(build.url)): List = listOf(
"BL_FEX_TAG=${build.tag}",
"BL_FEX_SHA256=${build.sha256}",
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt
index 447fb254c..b4bd458a5 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt
@@ -17,7 +17,6 @@ object GameStorage {
val primary = context.getExternalFilesDir(null)?.absolutePath
val primaryStorage = Environment.getExternalStorageDirectory()?.absolutePath
return context.getExternalFilesDirs(null).orEmpty().filterNotNull().mapNotNull { dir ->
- // Non-primary app dirs are usable even when Android reports the volume as non-removable (USB SSDs often do).
if (primary != null && dir.absolutePath == primary) return@mapNotNull null
if (!primaryStorage.isNullOrBlank() && dir.absolutePath.startsWith("$primaryStorage/")) return@mapNotNull null
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt
index 28777f6b0..2a32d817e 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.session
import java.nio.ByteBuffer
import java.nio.ByteOrder
-/** One reading from a racing game's telemetry. Fractions are 0..1; null means the game does not send it. */
data class Telemetry(
val source: String,
val speedKmh: Float? = null,
@@ -19,33 +18,22 @@ data class Telemetry(
val currentLapS: Float? = null,
val lastLapS: Float? = null,
val bestLapS: Float? = null,
- /** G-force as the game sends it (lateral, longitudinal) and steering from -1 (left) to 1 (right). */
val gLat: Float? = null,
val gLon: Float? = null,
val steer: Float? = null,
- /** Front left, front right, rear left, rear right. */
val tireTempsC: List? = null,
val turboBar: Float? = null,
val engineTempC: Float? = null,
val oilTempC: Float? = null,
- /** OutGauge dashboard lights that are on, as GameTelemetry.DL_* bits. */
val lights: Int? = null,
)
-/**
- * Racing telemetry games send over UDP to 127.0.0.1. Each format has its own port, the one the game or
- * its common tools use by default:
- * - [CODEMASTERS_PORT]: DiRT Rally 1/2, DiRT 4, GRID ("extradata 3" float array), DiRT Showdown (152-byte packet).
- * - [FORZA_PORT]: Forza Motorsport 7/2023 and Forza Horizon 4/5 "Data Out" (Sled and Dash).
- * - [OUTGAUGE_PORT]: BeamNG.drive and Live for Speed OutGauge.
- */
object GameTelemetry {
const val CODEMASTERS_PORT = 20777
const val FORZA_PORT = 5300
const val OUTGAUGE_PORT = 4444
val PORTS = listOf(CODEMASTERS_PORT, FORZA_PORT, OUTGAUGE_PORT)
- // Games send to 127.0.0.1; on Android InetAddress.getLoopbackAddress() is ::1, which IPv4 packets never reach.
val LISTEN_ADDRESS: java.net.InetAddress = java.net.InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1))
fun parse(port: Int, data: ByteArray, length: Int): Telemetry? = runCatching {
@@ -61,15 +49,12 @@ object GameTelemetry {
private const val MPS_TO_KMH = 3.6f
private const val GRAVITY = 9.81f
- // OutGauge DashLights / ShowLights bits (Live for Speed InSim; BeamNG uses the same values).
const val DL_SHIFT = 1
const val DL_HANDBRAKE = 4
const val DL_PITSPEED = 8
const val DL_TC = 16
const val DL_ABS = 1024
- // Float indexes of the Codemasters packet; engine rpm values are sent divided by 10. Older games
- // (DiRT Showdown) send only the first 38 floats, so lap count, position, fuel and max rpm are missing there.
private const val CM_LAP_TIME = 1
private const val CM_SPEED = 7
private const val CM_THROTTLE = 29
@@ -99,7 +84,6 @@ object GameTelemetry {
speedKmh = f(CM_SPEED) * MPS_TO_KMH,
rpm = f(CM_RPM) * 10f,
maxRpm = (f(CM_MAX_RPM) * 10f).takeIf { it > 0f },
- // Neutral is 0; DiRT Rally 2.0 reports reverse as 10, older games as -1.
gear = when {
gear == 0 -> "N"
gear < 0 || gear == 10 -> "R"
@@ -119,8 +103,6 @@ object GameTelemetry {
)
}
- // Forza "Data Out": Sled is 232 bytes; the Dash block follows it (FM7 311, FM 2023 331). Horizon 4/5 send
- // 324 bytes with 12 extra bytes before the Dash block.
private const val FORZA_SLED = 232
private const val FORZA_FM7 = 311
private const val FORZA_HORIZON = 324
@@ -140,7 +122,6 @@ object GameTelemetry {
if (dash < 0) return Telemetry(source = "Forza", rpm = rpm, maxRpm = maxRpm.takeIf { it > 0f })
if (!racing) return Telemetry(source = "Forza")
val gear = buf.get(dash + 75).toInt() and 0xFF
- // Tire temperatures are read as Fahrenheit, as community Data Out parsers do; not checked on a device.
val tiresF = (0 until 4).map { buf.getFloat(dash + 24 + it * 4) }
return Telemetry(
source = "Forza",
@@ -163,7 +144,6 @@ object GameTelemetry {
)
}
- // OutGauge: 92 bytes, or 96 with the optional id at the end.
private const val OUTGAUGE_SIZE = 92
internal fun outGauge(buf: ByteBuffer, length: Int): Telemetry? {
@@ -189,14 +169,12 @@ object GameTelemetry {
}
}
-/** Games that publish their state locally without any setup: War Thunder's web page and Elite Dangerous' Status.json. */
object GameStatusFiles {
const val WAR_THUNDER_STATE = "http://127.0.0.1:8111/state"
private const val ELITE_APP = "359320"
private const val ELITE_STATUS = "pfx/drive_c/users/steamuser/Saved Games/Frontier Developments/Elite Dangerous/Status.json"
const val WAR_THUNDER_ROWS = 12
- /** War Thunder's own labels (for example "TAS, km/h") with their values, while a match runs. */
internal fun warThunder(json: String): List> {
val state = org.json.JSONObject(json)
if (!state.optBoolean("valid", false)) return emptyList()
@@ -214,7 +192,6 @@ object GameStatusFiles {
data class Elite(val fuelMain: Double?, val fuelReserve: Double?, val cargo: Double?, val pips: List?, val legal: String?, val balance: Long?)
- /** Null while Elite is at its menus (Status.json then carries only the timestamp and flags). */
internal fun elite(json: String): Elite? {
val s = org.json.JSONObject(json)
val fuel = s.optJSONObject("Fuel")
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt b/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt
index 1b5b90db6..3c2c5098b 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GameThreadHints.kt
@@ -12,54 +12,26 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import kotlin.math.abs
-/**
- * The game's busiest threads named to Android's performance hint service (ADPF) for a session.
- *
- * Qualcomm's WALT moves a thread to the prime cores only once it fills a mid core (the Odin 3's
- * sched_upmigrate is 95), and a game thread that waits on its workers or the GPU for part of each
- * frame never does: Factorio's update thread sat at ~68% on cores 0-5 with 6-7 idle, and ran 60%
- * faster pinned to 6-7. An app may not raise a thread's uclamp (EPERM without CAP_SYS_NICE), but a
- * hint session may name any thread of its UID - the guest's are - and one reported behind its
- * target is boosted by the power HAL onto the prime cores: on the Odin 3 a 70%-busy thread of a
- * child process went from cores 0-5 to 6-7 and ran 1.7x faster within a second.
- *
- * Once a second the busiest threads of this UID are read from /proc; one busy more than [ENTER] of
- * the time (averaged with the second before, so a loading spike does not count) joins the session,
- * and leaves it under [LEAVE] - well below, since it runs faster where it was moved. While the
- * session has threads it reports them behind target, which is what asks for the boost: the reports
- * carry no frame timing, the busy share is the signal (a capped game's frame interval looks the same
- * as a slow one's). Nothing is boosted while the device is critically hot or saves power.
- *
- * Nor while the game holds the panel's rate or a fraction of it ([holdsPace]): there is nothing to
- * gain, and it cost frame pacing - Serious Sam capped at 120 fps ran its boosted main thread on prime
- * cores the governor kept at 2.2 GHz, slower than a 3.5 GHz mid core, and 23-25% of its frames fell
- * under 90 fps against 2-7% unboosted. A game with no frames (Factorio's benchmark, a loading screen)
- * or one falling behind the panel is boosted; one holding pace keeps what it has for [PACE_LEAVE_S]
- * seconds and is then let go.
- */
object GameThreadHints {
private const val TAG = "GameThreadHints"
private const val TARGET_NS = 10_000_000L
private const val REPORT_MS = 50L
- private const val SCAN_EVERY = 20 // reports, so once a second
+ private const val SCAN_EVERY = 20
private const val ENTER = 0.6
- private const val LEAVE = 0.15 // a capped game's main thread idles to ~30% on a prime core; under 0.3 it hopped back and forth
- private const val MAX_THREADS = 2 // one per prime core; a third would be sped up in place instead
+ private const val LEAVE = 0.15
+ private const val MAX_THREADS = 2
private const val TOP_PROCESSES = 3
private const val PACE_WINDOW_S = 3
private const val PACE_LEAVE_S = 10
private const val PACE_TOLERANCE = 0.08
- /** Busy, but not the game: the app itself and the Steam client, whose UI would otherwise hold the prime cores. */
private val NOT_GAMES = setOf("steam", "steamwebhelper")
private var worker: Thread? = null
- /** At a session's start. The line is for the log. */
@Synchronized
fun start(context: Context): String {
if (!SessionPrefs.gameThreadHints(context)) return "game thread hints: off"
- // Measured on the Odin 3 (CQ8725S) only; another HAL may boost differently, or not at all.
if (Build.VERSION.SDK_INT < 31 || Build.SOC_MODEL != "CQ8725S") return "game thread hints: Odin 3 only"
val hm = context.getSystemService(PerformanceHintManager::class.java)
?: return "game thread hints: no PerformanceHintManager"
@@ -69,7 +41,6 @@ object GameThreadHints {
return "game thread hints: watching"
}
- /** At a session's end. */
@Synchronized
fun stop() {
worker?.interrupt()
@@ -92,7 +63,6 @@ object GameThreadHints {
val fps = (frames.last() - frames.first()).toDouble() / (frames.size - 1).coerceAtLeast(1)
val paced = frames.size > PACE_WINDOW_S && holdsPace(fps, SessionState.refreshHz.toDouble())
pacedFor = if (paced) pacedFor + 1 else 0
- // Not SEVERE: the Odin 3 reports it once its skin reaches 50 C, a couple of minutes into any game.
val spare = pm.isPowerSaveMode || pm.currentThermalStatus >= PowerManager.THERMAL_STATUS_CRITICAL
val next = when {
spare || pacedFor >= PACE_LEAVE_S -> emptySet()
@@ -101,7 +71,6 @@ object GameThreadHints {
}
if (next != boosted) {
session = update(hm, session, next)
- // No session means a thread exited in between: tried again next second.
boosted = if (session == null) emptySet() else next
Log.i(TAG, (if (session == null && next.isNotEmpty()) "no session for " else "boosting ") +
next.joinToString { "${readName("/proc/$it/comm")}/$it ${((busy[it] ?: 0.0) * 100).toInt()}%" }.ifEmpty { "nothing" } +
@@ -119,7 +88,6 @@ object GameThreadHints {
}
}
- /** A new session over [tids] in place of [session]; null for none. The set changes seldom, so no setThreads. */
private fun update(hm: PerformanceHintManager, session: PerformanceHintManager.Session?, tids: Set): PerformanceHintManager.Session? {
session?.close()
if (tids.isEmpty()) return null
@@ -131,19 +99,9 @@ object GameThreadHints {
}
}
- /**
- * Whether a game presenting [fps] frames a second keeps up with a [refreshHz] panel: within
- * [PACE_TOLERANCE] of the rate or of a half, a third or a quarter of it (a 60 fps limiter on 120 Hz).
- * A game with next to no frames (a headless benchmark, a loading screen) does not.
- */
- // ponytail: a CPU-bound game that happens to run at ~60 fps on a 120 Hz panel reads as limited; narrow the band if that shows up
internal fun holdsPace(fps: Double, refreshHz: Double): Boolean =
fps >= 10 && refreshHz > 0 && (1..4).any { k -> abs(fps - refreshHz / k) < PACE_TOLERANCE * refreshHz / k }
- /**
- * The threads to boost next: those already boosted that are still above [LEAVE], then new ones
- * above [ENTER], busiest first, at most [MAX_THREADS].
- */
internal fun pick(boosted: Set, busy: Map): Set =
busy.entries
.filter { (tid, share) -> share >= if (tid in boosted) LEAVE else ENTER }
@@ -154,7 +112,6 @@ object GameThreadHints {
private fun readName(path: String) = try { File(path).readText().trim() } catch (_: Exception) { "?" }
- /** Busy shares of this UID's threads since the last sample: /proc shows an app only its own processes. */
private class Scanner {
private val self = Process.myPid()
private val hz = Os.sysconf(OsConstants._SC_CLK_TCK).toDouble()
@@ -163,7 +120,6 @@ object GameThreadHints {
private var lastThread = HashMap()
private var lastBusy = HashMap()
- /** Only the busiest few processes have their threads read, so a second costs a few dozen small reads. */
fun sample(): Map {
val now = System.nanoTime()
val seconds = (now - lastNs) / 1e9
@@ -197,7 +153,6 @@ object GameThreadHints {
return busy
}
- /** utime + stime of a /proc stat line; fields counted after the command's closing parenthesis. */
private fun cpuTicks(path: String): Long? = try {
val line = File(path).readText()
val fields = line.substring(line.lastIndexOf(')') + 2).split(' ')
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
index 8e10024fc..019d3b3df 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt
@@ -73,7 +73,6 @@ class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() {
}, "gpu-stats").apply { isDaemon = true; start() }
}
- // kgsl's gpubusy can read 0 0 straight after a read, so feeds that share a reader share one read.
private fun refresh() {
if (layerLoad != null || cycleClock != null || memstore != null) {
shown = hudShown()
@@ -288,8 +287,6 @@ class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() {
return pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }
}
- // kgsl publishes busy/total once per ~1 s of GPU-on time and zeroes it on any read while the GPU is
- // power-collapsed, so direct pollers mostly see 0; latch each published window instead.
private fun latchedBusyFeed(): Feed? {
var load = 0L
var windowAt = 0L
diff --git a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt
index 5632bd27e..f673f282b 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt
@@ -21,7 +21,6 @@ object OfflineMode {
fun account(context: Context): String? {
val root = LinuxRuntime.rootDir(context)
- // Until its first start the x86-64 client has no sign-in; droiddeck-steam-x64 --sync-login copies the arm64 one.
val file = x64LoginUsers(root).takeIf { SessionPrefs.activeSteamClientArch(context) == "x86_64" && it.isFile }
?: loginUsers(root)
val text = try {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
index 5826db97a..39d99bfa6 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
@@ -214,8 +214,6 @@ class RumbleComponent : SessionPart() {
if (any) manager.vibrate(combined.combine()) else cancel()
}
- // Some vendors' input-device vibrator HALs (Samsung's) don't halt a Bluetooth pad's running
- // effect on cancel(), but a new effect always replaces it.
override fun cancel() {
val supersede = CombinedVibration.startParallel()
ids.forEach { supersede.addVibrator(it, VibrationEffect.createOneShot(1L, 1)) }
@@ -255,7 +253,6 @@ class RumbleComponent : SessionPart() {
private const val TAG = "SessionService"
const val NAME = "droiddeck-rumble"
const val MIN_GAP_MS = 50L
- // Must match RUMBLE_HOLD_MS in fakeinput_steam.cpp.
const val HOLD_MS = 0xFFFF
const val HOLD_EFFECT_MS = 2000L
const val KEEPALIVE_MS = 1500L
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
index b4408c48b..4fc004304 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt
@@ -31,7 +31,6 @@ object SessionArtifacts {
private val collectingDone = collectingLock.newCondition()
private val collecting = HashSet()
- /** Recovery leaves [dir] alone from the stop until [collect] has finished it. */
fun collectionStarted(dir: File) {
collectingLock.lock()
try { collecting.add(dir) } finally { collectingLock.unlock() }
@@ -317,7 +316,6 @@ object SessionArtifacts {
runCatching { File(dir, CLIENT_FILE).writeText("$arch\n") }
}
- /** The Steam client a session ran, when it recorded one. */
internal fun recordedClient(dir: File): String? =
runCatching { File(dir, CLIENT_FILE).readText().trim() }.getOrNull()?.takeIf { it == "arm64" || it == "x86_64" }
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 056fc205a..09122644d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -33,7 +33,6 @@ object SessionFiles {
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
- /** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root) && !DesktopCatalog.desktopFromPack(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
@@ -171,7 +170,6 @@ object SessionFiles {
} + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
- // The gamescope pack carries its own WSI layer (patch 0117); it replaces the runtime's stock copy.
"usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so" to "usr/lib/libVkLayer_FROG_gamescope_wsi_aarch64.so",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
@@ -282,7 +280,6 @@ object SessionFiles {
val movieDir = File(home, "config/uioverrides/movies")
val config = File(home, "config/config.vdf")
if (startupMovie == null) {
- // Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
File(movieDir, name).delete()
File(movieDir, ".$name.source").delete()
@@ -302,17 +299,10 @@ object SessionFiles {
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
}
- // A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
- // before that, and a Steam that is already running writes its own choice back when it exits.
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
- /**
- * Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
- * could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
- * no longer names a missing movie.
- */
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
if (!config.isFile) return true
val text = runCatching { config.readText() }.getOrElse {
@@ -391,7 +381,6 @@ object SessionFiles {
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) {
FileUtils.writeString(stamp, identity)
- // Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
}
} catch (e: Exception) {
@@ -408,10 +397,6 @@ object SessionFiles {
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
- /**
- * Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
- * settings is where older clients kept it, and DroidDeck still keeps it in step.
- */
private fun customizationMovie(text: String): VdfBlock? {
var from = 0
while (true) {
@@ -421,7 +406,6 @@ object SessionFiles {
}
}
- /** The older `StartupMovie` section: the first one that is not inside `Customization`. */
private fun olderMovie(text: String): VdfBlock? {
val inside = customizationMovie(text)
var from = 0
@@ -432,10 +416,8 @@ object SessionFiles {
}
}
- /** The section Steam reads: the one under `Customization` when there is one. */
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
- /** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? = startupMovieLocalPath(config)?.takeIf { it.isNotEmpty() }
internal fun startupMovieLocalPath(config: File): String? {
@@ -444,7 +426,6 @@ object SessionFiles {
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")
}
- /** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
internal fun startupMovieBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val parts = listOfNotNull(
@@ -454,7 +435,6 @@ object SessionFiles {
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
- /** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
internal fun customizationBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val customization = findObject(text, "Customization", 0, text.length) ?: return null
@@ -467,7 +447,6 @@ object SessionFiles {
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
}
- /** True when Steam's config selects DroidDeck's staged movie once this returns. */
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
val path = BOOT_MOVIE_PATH
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
@@ -494,7 +473,6 @@ object SessionFiles {
return false
}
}
- // Later sections first, so the positions of earlier ones stay valid.
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
for (target in targets) text = pointAt(text, target, path, newline)
if (olderMovie(text) == null) {
@@ -538,11 +516,6 @@ object SessionFiles {
private val IDLE_SLEEP_TIMERS = listOf("IdleSuspendBatterySeconds", "IdleSuspendACSeconds")
- /**
- * Sets Steam's Power > "Sleep after" (battery and plugged in) to Disabled at every session start: Android
- * owns the device's sleep, and a client that suspends inside the session only shows a black screen. Dimming stays the user's. Runs
- * only before the client starts, which rewrites its config on exit.
- */
internal fun disableIdleSleep(steamHome: File): Boolean {
val config = File(steamHome, "config/config.vdf")
val text = runCatching { config.readText() }.getOrNull() ?: return false
@@ -573,7 +546,6 @@ object SessionFiles {
}
}
- /** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
@@ -643,13 +615,6 @@ object SessionFiles {
fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() }
- /**
- * Registers the Android system fonts (which include CJK faces such as Noto Sans CJK /
- * MiSans) with the container's fontconfig, so the Steam Deck UI (CEF/webkit renderer)
- * resolves Chinese/Japanese glyphs instead of showing tofu / mojibake.
- * The fonts themselves are bind-mounted by LinuxRuntime.bindSystemFonts() into
- * /usr/local/share/fonts/android; here we only need to point fontconfig at them.
- */
private val DESKTOP_STRINGS = listOf(
"desktop_restore_title" to R.string.desktop_restore_title,
"desktop_restore_body" to R.string.desktop_restore_body,
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt
index ded53162c..185550370 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt
@@ -69,7 +69,6 @@ object SessionLogShare {
return zip
}
- /** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
val w = z.bufferedWriter()
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
index f73609f44..6f335f231 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt
@@ -44,7 +44,6 @@ object SessionPaths {
fun current(): File? = dir
- /** Whether the current session folder will survive teardown. No session means the next one can retain logs. */
fun currentSessionLogsRetained(context: Context): Boolean =
dir?.let { it.parentFile == SessionFiles.logDirectory(context) } ?: true
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index 9ed3b6d27..bf189a174 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -125,14 +125,12 @@ object SessionPrefs {
prefs(context).edit().putString("statsDash", id).apply()
}
- /** In-game stats "Send dashboard data" Automatic: on unless the user switched it off. */
fun telemetryAuto(context: Context): Boolean = prefs(context).getBoolean("telemetryAuto", true)
fun setTelemetryAuto(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("telemetryAuto", on).apply()
}
- /** Games the user switched off by hand, as TelemetryOutput keys; Automatic leaves them alone. */
fun telemetryOff(context: Context): Set = prefs(context).getStringSet("telemetryOff", null)?.toSet().orEmpty()
fun setTelemetryOff(context: Context, keys: Set) {
@@ -163,13 +161,6 @@ object SessionPrefs {
prefs(context).edit().putString("gameStoresSpeedTier", tier).apply()
}
- /**
- * Hidden A/B switch for the Epic installer: "adaptive" (default: native parallel fetch written
- * straight into staged files), "lookahead" (the native ordered-lookahead engine that caches each
- * chunk and copies it into staged files), "batch" (the older whole-cache native fetch, then
- * native assembly; needs about twice the game's size free) or "java" (the bounded Java writer).
- * No UI; set it in the `session` preferences for benchmarks.
- */
fun storesEpicMode(context: Context): String = prefs(context).getString("storesEpicMode", "adaptive") ?: "adaptive"
fun storesShowMature(context: Context): Boolean = prefs(context).getBoolean("storesShowMature", false)
@@ -246,7 +237,6 @@ object SessionPrefs {
prefs(context).edit().putString("storesInstallTarget", path).apply()
}
- /** Empty means follow the app/system language for GOG and Epic installs. */
fun storesInstallLanguage(context: Context): String = prefs(context).getString("storesInstallLanguage", "") ?: ""
fun setStoresInstallLanguage(context: Context, code: String) {
@@ -472,10 +462,6 @@ object SessionPrefs {
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
}
- /**
- * After a game launched from the front end (or another launcher's link) closes, the client
- * exits cleanly and the session returns there, instead of staying in Big Picture.
- */
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
fun setReturnAfterGame(context: Context, on: Boolean) {
@@ -485,11 +471,6 @@ object SessionPrefs {
private fun shaderPrecacheApplied(context: Context) =
java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied")
- /**
- * Shader pre-caching as the launcher last set it, until a session reports it written into the
- * client's config.vdf; after that the client's own toggle is the user's again. A client that
- * has never written a config.vdf starts with pre-caching off.
- */
fun pendingShaderPrecache(context: Context): Boolean? {
val prefs = prefs(context)
if (!prefs.contains("shaderPrecache")) {
@@ -660,7 +641,6 @@ object SessionPrefs {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
- /** Whether the game's busiest threads are named to the power HAL for a boost (GameThreadHints). */
fun gameThreadHints(context: Context): Boolean = prefs(context).getBoolean("gameThreadHints", true)
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
@@ -675,18 +655,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootFastPath", on).apply()
}
- /** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */
fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false)
fun setCursorComposite(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("cursorComposite", on).apply()
}
- /**
- * The rootfs ships Termux's unpatched proot. A session uses it only when this file exists,
- * so a missing add-on pack cannot silently fall back to it.
- * Path: Android/data/<package>/files/droiddeck-stock-proot
- */
@JvmStatic
fun allowStockProot(context: Context): Boolean =
context.getExternalFilesDir(null)?.let { File(it, "droiddeck-stock-proot").isFile } == true
@@ -772,7 +746,6 @@ object SessionPrefs {
)
}
- /** Extra Proton, Wine and graphics diagnostics for the next game launch; off unless chosen. */
fun gameDebugLogsEnabled(context: Context): Boolean =
prefs(context).getBoolean("gameDebugLogsEnabled", false)
@@ -926,7 +899,6 @@ object SessionPrefs {
prefs(context).edit().putString("steamClientArch", id).apply()
}
- /** The running session's client, else the selected one: a changed setting applies from the next session. */
fun activeSteamClientArch(context: Context): String =
SessionState.steamClientArch?.takeIf { SessionState.running } ?: steamClientArch(context)
@@ -944,13 +916,10 @@ object SessionPrefs {
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
- /** How long the boot movie plays, in seconds; 0 plays all of it. */
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
- /** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
- /** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
@@ -1114,13 +1083,11 @@ object SessionPrefs {
prefs(context).edit().putString("theme", id).apply()
}
- /** The custom Gradient theme's primary and secondary colours (ARGB). */
fun gradientColors(context: Context): Pair = prefs(context).let {
it.getInt("gradientPrimary", com.droiddeck.launcher.ui.Themes.DEFAULT_PRIMARY) to
it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY)
}
- /** How fast the Inner Gamer theme cycles its colours, 1 to 10. */
fun rgbSpeed(context: Context): Int =
prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)
@@ -1150,18 +1117,11 @@ object SessionPrefs {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
- /** Compositor frame limit that releases buffers to the game unpaced. */
const val FPS_UNPACED = -1
- /** The drawer's frame limit slider: its first stop is Off. */
const val FPS_LIMIT_OFF = 15
const val FPS_LIMIT_MAX = 165
- /**
- * Buffers go back to the game as soon as they are replaced instead of once per refresh;
- * Steam's Frame Limit in the Quick Access menu caps Steam games, and the drawer's limit caps games
- * launched without Steam. Mirroring still follows the glasses' refresh rate.
- */
const val PIP_FPS = 30
fun compositorFpsLimit(mirrorRefreshLimit: Int, sessionLimit: Int = 0, pip: Boolean = false): Int {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index 6a631341a..f43a7f55a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -107,7 +107,6 @@ class SessionService : Service() {
private var freezeCancelled = false
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
- // Bumped to cancel a running game-exit watch (a new launch, or the session ending).
@Volatile private var gameExitWatchToken = 0
private val flushSteamGame = object : Runnable {
override fun run() = flushQueuedSteamGame()
@@ -477,7 +476,6 @@ class SessionService : Service() {
.onFailure { Log.w(TAG, "agent environment skipped", it) }
.getOrDefault(emptyList())
SessionFiles.stage(this, root, startsSession = true)
- // Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this, beforeGuestStart = true)
VacDiagnostics.arm(this)
@@ -535,7 +533,6 @@ class SessionService : Service() {
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
- // gamescope's WSI layer and winex11 reuse their per-present window checks for this long.
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100")
guest.add("WINE_OFFSCREEN_CHECK_MS=100")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
@@ -1111,7 +1108,6 @@ class SessionService : Service() {
}
}
val x64Libraries = org.json.JSONArray()
- // Desktop mode offers the x86-64 client either way; droiddeck-steam-x64 drops library folders it is not given.
val x64Here = SessionPrefs.steamClientArch(this) == "x86_64" ||
(SessionState.mode == MODE_DESKTOP &&
com.droiddeck.launcher.storage.SteamStorage.x64ClientPresent(SessionPrefs.steamRoot(this, "x86_64")))
@@ -1315,13 +1311,6 @@ class SessionService : Service() {
Log.i(TAG, "queued Steam game $gameId for the running client")
}
- /**
- * A game launched from the front end (the Games tab, or another launcher's link): once it has
- * started and then been gone for [GAME_EXIT_GRACE_MS], ask the client to exit through the
- * session script's steam-stop request. The client quits cleanly, finishing its cloud sync, so
- * the session ends on its own and the activity returns to where the launch came from instead
- * of leaving the player in Big Picture. The grace covers launchers that restart the game.
- */
private fun watchGameExit(gameId: String, gen: Int) {
if (!SessionPrefs.returnAfterGame(this)) return
val token = ++gameExitWatchToken
@@ -1336,7 +1325,6 @@ class SessionService : Service() {
seen = true
goneSince = 0L
} else if (!seen) {
- // Never started (an install, an update or a failed launch): leave the session be.
if (now > startBy) {
Log.i(TAG, "game $gameId did not start; not watching for its exit")
return@Thread
@@ -1362,8 +1350,6 @@ class SessionService : Service() {
Log.i(TAG, "game $gameId closed: asking the client to exit and returning to the launcher")
}
- /** A Steam game is running: the client runs each one under its reaper, and Proton's script
- * waits on the game with waitforexitandrun. Read from the command lines, as ComponentsManager does. */
private fun steamGameRunning(): Boolean = SteamGameProcess.running()
private fun resumeSession(panelVisible: Boolean = true) {
@@ -1576,7 +1562,6 @@ class SessionService : Service() {
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
manualPauseRequested = true
} else if (!hidden && !SessionState.suspended) {
- // Coming back before the freeze finished keeps the game running instead of pausing it a moment later.
manualPauseRequested = false
}
val controller = suspendController ?: return
@@ -1599,7 +1584,6 @@ class SessionService : Service() {
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
- // A frozen game never sends the stop for a held rumble, so the keepalive would run on.
val quietRumble = !screenOn || shouldSuspend || SessionState.suspended
components.forEach { (it as? RumbleComponent)?.quiet(quietRumble) }
if (freezePending && !shouldSuspend && !freezeCancelled) {
@@ -1919,7 +1903,7 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
- GameThreadHints.stop() // also when the session never got as far as running
+ GameThreadHints.stop()
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
@@ -2158,9 +2142,7 @@ class SessionService : Service() {
private const val DOWNLOAD_POLL_MS = 2_000L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val GAME_URL_PREFIX = "steam://rungameid/"
- /** How long a front-end launch may take to start (downloads, first-run installs) before the exit watch gives up. */
private const val GAME_START_TIMEOUT_MS = 20 * 60_000L
- /** How long the game must stay gone before the client is asked to exit: launchers restart games. */
private const val GAME_EXIT_GRACE_MS = 10_000L
private const val GAME_EXIT_POLL_MS = 2_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index c93a2758b..8e63d05c6 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -72,8 +72,6 @@ object SessionState {
var steamUrl: String? = null
@Volatile
var steamClientArch: String? = null
- // A Play SteamVR request can arrive while an existing session is visible.
- // Wake the status/launch effect even if no glasses surface has attached yet.
var steamVr by mutableStateOf(false)
var relaunch: android.content.Intent? = null
@JvmStatic var hdr = false
@@ -96,7 +94,6 @@ object SessionState {
@Volatile var secondScreenAutoOpened = false
@Volatile var secondScreenProvisional = false
- /** Guest library paths on removable storage and the host folders they are mounted from, set by SessionService. */
@Volatile var steamLibraryMounts: Map = emptyMap()
@Volatile
@@ -115,7 +112,6 @@ object SessionState {
@Volatile
var upscaleRatio: Float = 0f
- /** Frame limit chosen in the drawer for a game launched without Steam; 0 is off. */
@Volatile
var fpsLimit: Int = 0
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt
index adb792e71..b0a685214 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionSuspendController.kt
@@ -26,9 +26,7 @@ internal class SessionSuspendController(
private val nowNanos: () -> Long = System::nanoTime,
private val pagedOut: (processes: Int, unacknowledged: Int, residentKb: Long, swapKb: Long, swapBeforeKb: Long, millis: Long) -> Unit =
{ _, _, _, _, _, _ -> },
- // False with a RAM cap set: the user asked for the session to use less memory, so nothing is read back.
private val readBackAllowed: () -> Boolean = { true },
- // processes is 0 when the read-back was skipped: a RAM cap, or MemAvailable could not take it.
private val pagedIn: (processes: Int, swapKb: Long, swapBeforeKb: Long, availableKb: Long, millis: Long) -> Unit =
{ _, _, _, _, _ -> },
) {
@@ -113,12 +111,10 @@ internal class SessionSuspendController(
if (interrupted) Thread.currentThread().interrupt()
}
completion(complete)
- // A cancelled or failed freeze leaves the game running with whatever the page-out already moved.
if (!complete && frozen.isEmpty() && !interrupted) readBack()
}, { completion(false) })
}
- // A freeze still queued or paging out stops before its next SIGSTOP pass and rolls back.
fun cancelFreeze() {
cancelled = true
}
@@ -252,7 +248,6 @@ internal class SessionSuspendController(
}
private fun stoppedSnapshot(records: List, root: ProcessRecord): Map? {
- // PRoot can leave the leader stopped while native worker threads keep running.
val tasks = LinkedHashMap()
var stopped = true
for (record in records) {
@@ -279,9 +274,6 @@ internal class SessionSuspendController(
return tasks.takeIf { stopped && it.isNotEmpty() }
}
- // A stopped process cannot run libblsession's SIGURG handler, so the page-out has to finish
- // before the stop. Android's low-memory killer otherwise finds the frozen game resident in RAM
- // with most of zram still free.
private fun pageOut(records: MutableMap, ProcessRecord>, root: Int, helpers: List) {
val control = zramControl?.takeIf { it.isFile } ?: return
try {
@@ -304,8 +296,6 @@ internal class SessionSuspendController(
val start = nowMillis()
val before = memoryKb(targets)
for (record in targets) send(record, SIGURG)
- // A program can replace libblsession's SIGURG handler (PyInstaller's bootloader, the Go runtime);
- // such a process never creates its ack and is not waited for once the start window has passed.
val waiting = targets.toMutableList()
while (!closing && !cancelled && nowMillis() - start < PAGE_OUT_TIMEOUT_MS) {
val started = nowMillis() - start >= PAGE_OUT_START_MS
@@ -319,13 +309,6 @@ internal class SessionSuspendController(
pagedOut(targets.size, unacknowledged, after.first, after.second, before.second, nowMillis() - start)
}
- // Left in zram, the paged-out memory comes back one major fault at a time while the game runs; in a heavy
- // game that is hundreds of faults per frame and stalls of a quarter second and more, long after the resume.
- // libblsession's handler reads back only the swapped pages (mode 0, as the session script does for the client).
- // The handler runs on whichever thread takes the signal, so that thread pauses for the read-back,
- // once and right after the resume instead of mid-game.
- // It signals even with nothing in zram yet: after a cancelled freeze a page-out handler may not have started,
- // and it reads the mode only when it starts, so it reads back instead of paging out.
private fun readBack() {
val targets = pagedOutTargets.filterNot { isGone(it) }
pagedOutTargets = emptyList()
@@ -360,15 +343,10 @@ internal class SessionSuspendController(
pagedIn(targets.size, swap, before, available, nowMillis() - start)
}
- // Reading back moves the swap into resident memory, out of MemAvailable. On a device short on memory that
- // could push Android's low-memory killer onto DroidDeck itself, so the memory comes back only when
- // MemAvailable is at least twice the swap and keeps 1 GB or a tenth of MemTotal, whichever is more, after it.
- // Otherwise it stays in zram and faults back in as the game touches it, as before.
private fun canTake(swapKb: Long, totalKb: Long, availableKb: Long): Boolean =
availableKb >= swapKb * READ_BACK_AVAILABLE_FACTOR &&
availableKb - swapKb >= maxOf(READ_BACK_HEADROOM_KB, totalKb / READ_BACK_TOTAL_SHARE)
- // MemTotal and MemAvailable, 0 when unreadable (which skips the read-back).
private fun meminfoKb(): Pair = try {
val values = HashMap()
File(procRoot, "meminfo").forEachLine { line ->
@@ -402,9 +380,6 @@ internal class SessionSuspendController(
return resident to swap
}
- // PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
- // thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
- // signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
private fun stop(records: List) {
for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP)
@@ -469,7 +444,6 @@ internal class SessionSuspendController(
val current = try { readProcess(record.pid) } catch (_: Exception) { return false }
if (current == null || current.started != record.started) return true
if (current.state == 'T' || current.state == 't') return false
- // A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop.
return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry ->
entry.name.toIntOrNull() != null &&
try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false }
diff --git a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt
index d0ff1486a..a8857c739 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.session
-/** The In-game stats page's top strip, picked on the page (tap or the Layout row) and remembered. */
enum class StatsStrip(val id: String) {
PANEL("panel"), TILES("tiles"), RINGS("rings");
@@ -11,28 +10,23 @@ enum class StatsStrip(val id: String) {
}
}
-/** Speeds on the racing dashes; telemetry arrives in km/h. */
enum class SpeedUnit(val id: String, val label: String, val perKmh: Float) {
AUTO("auto", "", 1f), KMH("kmh", "km/h", 1f), MPH("mph", "mph", 0.621371f);
- /** Auto is mph where road speed limits are signed in mph, by the device's region, else km/h. */
fun resolve(country: String): SpeedUnit = if (this != AUTO) this else if (country.uppercase() in MPH_REGIONS) MPH else KMH
fun next(): SpeedUnit = entries[(ordinal + 1) % entries.size]
companion object {
- /** The United States and its territories, the United Kingdom and its Crown Dependencies, Liberia, the Bahamas, Belize. */
private val MPH_REGIONS = setOf("US", "PR", "GU", "VI", "AS", "MP", "GB", "IM", "JE", "GG", "LR", "BS", "BZ")
fun from(id: String?): SpeedUnit = entries.firstOrNull { it.id == id } ?: AUTO
}
}
-/** The racing dash under the strip; Auto picks one by the game's data format. */
enum class StatsDash(val id: String) {
AUTO("auto"), SPEEDOMETER("speedometer"), CIRCUIT("circuit"), TRACE("trace"), DIALS("dials"), GLANCE("glance");
- /** The dash to draw for data from [source] (Telemetry.source). */
fun resolve(source: String): StatsDash = if (this != AUTO) this else when (source) {
"Forza" -> CIRCUIT
"OutGauge" -> DIALS
@@ -44,7 +38,6 @@ enum class StatsDash(val id: String) {
fun from(id: String?): StatsDash = entries.firstOrNull { it.id == id } ?: AUTO
- /** A tap on the dash moves on from the one on screen, so Auto never looks like a tap did nothing. */
fun tapped(shown: StatsDash): StatsDash = DRAWN[(DRAWN.indexOf(shown) + 1).mod(DRAWN.size)]
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt
index 2a940a96f..9503e8e2f 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckRepo.kt
@@ -7,12 +7,10 @@ import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
-/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
object SteamDeckRepo {
private const val TAG = "SteamDeckRepo"
const val BASE = "https://steamdeckrepo.com"
- /** The site shows twelve movies a page. */
const val PAGE_SIZE = 12
const val MAX_DOWNLOAD_BYTES = 120L shl 20
private const val MAX_JSON_BYTES = 1 shl 20
@@ -23,7 +21,6 @@ object SteamDeckRepo {
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
}
- /** `null` is every device. */
val DEVICES = listOf("steam_deck", "steam_machine")
class Post(
@@ -36,7 +33,6 @@ object SteamDeckRepo {
object Failed : Page()
}
- /** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun parse(json: String): List? {
@@ -83,10 +79,6 @@ object SteamDeckRepo {
return result
}
- /**
- * The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
- * page past the end is empty and the last one is short. Null when the site cannot be reached.
- */
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
}): Int? {
@@ -111,7 +103,6 @@ object SteamDeckRepo {
return good
}
- /** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
@@ -142,7 +133,6 @@ object SteamDeckRepo {
}
}
- /** Fetches a small picture such as a cover; null when it cannot be had. */
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
if (!url.startsWith("https://")) return null
var connection: HttpURLConnection? = null
@@ -161,10 +151,6 @@ object SteamDeckRepo {
}
}
- /**
- * Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
- * its website, and falls back to the video's address. True when a file was saved.
- */
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
if (save(url, target, onProgress)) return true
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt
index b8b898457..83b101db4 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.session
import java.io.File
import java.io.RandomAccessFile
-/** A Steam download or update the client is working on. [total] is 0 while Steam has not sized it yet. */
data class SteamDownload(
val appId: String,
val name: String,
@@ -13,7 +12,6 @@ data class SteamDownload(
val paused: Boolean = false,
)
-/** Reads Steam's own appmanifests and content log, the same files [SteamDownloadMonitor] watches. */
object SteamDownloads {
fun read(runtimeRoot: File, mounts: Map = emptyMap(), client: String? = null): List {
val names = HashMap()
@@ -32,7 +30,6 @@ object SteamDownloads {
return apps + shaders
}
- /** The arm64 and x86-64 Steam homes can both hold an unfinished manifest of one app; the page keys rows by app. */
internal fun onePerApp(downloads: List): List =
downloads.groupBy { it.appId }.values.map { same -> same.maxBy { it.done } }
@@ -46,7 +43,6 @@ object SteamDownloads {
return SteamDownload(id, name, done, total, paused = (flags and UPDATE_PAUSED) != 0L)
}
- /** App ids whose latest shader-cache line in the content log says the update is still running. */
internal fun runningShaderUpdates(lines: Sequence): List {
val state = LinkedHashMap()
for (line in lines) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt
index f9075857f..356df06f7 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamWebLogin.kt
@@ -8,20 +8,12 @@ import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
-/** A cookie for one https host, in the form CookieManager.setCookie takes. */
data class WebCookie(val host: String, val header: String)
-/**
- * The web sign-in the Steam client in the session already has, so the bottom-screen chat opens signed in.
- * Steam's built-in browser keeps it in its Chromium cookie store under config/htmlcache. With no keyring
- * (SteamOS and DroidDeck) Chromium encrypts values as "v10": AES-128-CBC, key PBKDF2-SHA1("peanuts",
- * "saltysalt", 1 round), IV of 16 spaces. Values are never logged.
- */
object SteamWebLogin {
val HOSTS = listOf("steamcommunity.com", "steam-chat.com")
private const val COOKIES = "config/htmlcache/Default/Cookies"
private const val V10 = "v10"
- // From cookie store version 24 Chromium puts the SHA-256 of the host before each value.
private const val HOST_HASH_VERSION = 24
private const val HOST_HASH_BYTES = 32
@@ -39,19 +31,16 @@ object SteamWebLogin {
String(value, Charsets.UTF_8)
}.getOrNull()
- /** Steam's own pages send the sign-in with its "||" separator percent-encoded. */
internal fun header(name: String, value: String, httpOnly: Boolean): String {
val v = if (name == "steamLoginSecure") value.replace("||", "%7C%7C") else value
return "$name=$v; Path=/; Secure" + if (httpOnly) "; HttpOnly" else ""
}
- /** Empty when the client has no web sign-in, for example before the first login. */
fun read(runtimeRoot: File, client: String?, scratch: File): List {
val homes = client?.let { listOf(if (it == "x86_64") SteamDownloadMonitor.STEAM_HOMES[1] else SteamDownloadMonitor.STEAM_HOMES[0]) }
?: SteamDownloadMonitor.STEAM_HOMES
val store = homes.map { File(runtimeRoot, "$it/$COOKIES") }.filter { it.isFile }.maxByOrNull { it.lastModified() }
?: return emptyList()
- // A copy, journal included: Steam keeps the store open and may be in the middle of a write.
scratch.deleteRecursively()
if (!scratch.mkdirs()) return emptyList()
return try {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt b/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt
index afd60b02d..b3d884148 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/TelemetryOutput.kt
@@ -4,17 +4,11 @@ import android.content.Context
import com.droiddeck.launcher.frontend.Library
import java.io.File
-/**
- * Codemasters games keep their UDP / motion output in My Games//hardwaresettings/hardware_settings_config.xml
- * inside the game's Proton prefix and read it when the game starts. Turning it on points that element at the
- * In-game stats listener; the file as it was is kept beside it, and its element is put back when turned off.
- */
object TelemetryOutput {
data class Game(val name: String, val prefixId: String, val file: File, val on: Boolean)
data class MenuGame(val name: String, val setting: String, val port: Int)
- /** Games whose output is only a setting in the game's own menus, by Steam app id. */
val MENU_GAMES: Map = mapOf(
1293830 to MenuGame("Forza Horizon 4", "Data Out", GameTelemetry.FORZA_PORT),
1551360 to MenuGame("Forza Horizon 5", "Data Out", GameTelemetry.FORZA_PORT),
@@ -28,7 +22,6 @@ object TelemetryOutput {
private const val ADDRESS = "127.0.0.1"
private const val EXTRADATA = "3"
private val TAGS = listOf("udp", "motion")
- // Byte-for-byte: every byte maps to one char and back, whatever the file's own encoding is.
private val BYTES = Charsets.ISO_8859_1
fun scan(compatdata: List): List = compatdata.asSequence()
@@ -43,7 +36,6 @@ object TelemetryOutput {
.sortedBy { it.name.lowercase() }
.toList()
- /** Turns the game's output on or off; false when the file could not be read or written. */
fun set(game: Game, on: Boolean): Boolean = runCatching {
val file = game.file
val original = File(file.path + ORIGINAL_SUFFIX)
@@ -62,7 +54,6 @@ object TelemetryOutput {
true
}.getOrDefault(false)
- /** Automatic: turns the output on for every found game the user has not switched off and that is not running. */
fun applyAutomatic(context: Context, running: String?) {
if (!SessionPrefs.telemetryAuto(context)) return
automaticTargets(scan(Library.compatdataDirs(context)), SessionPrefs.telemetryOff(context), running).forEach { set(it, true) }
@@ -73,11 +64,9 @@ object TelemetryOutput {
internal fun automaticTargets(games: List, off: Set, running: String?): List =
games.filter { !it.on && key(it) !in off && !isRunning(it.prefixId, running) }
- /** Games DroidDeck changed, which go back to their own setting when Automatic is switched off. */
internal fun restoreTargets(games: List, running: String?): List =
games.filter { File(it.file.path + ORIGINAL_SUFFIX).isFile && !isRunning(it.prefixId, running) }
- /** Whether [running] (the focused Steam app id, or another key for non-Steam programs) may be the game in [prefixId]. */
internal fun isRunning(prefixId: String, running: String?): Boolean {
val id = running?.toLongOrNull() ?: return running != null
return prefixId == id.toString() || prefixId == id.toInt().toString()
diff --git a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt
index 2f0d03c28..e38d1c454 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt
@@ -10,17 +10,9 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-/**
- * Source games state VAC-secure or insecure only in their console, which reaches a file (console.log in the
- * mod folder) only with -condebug. The report quotes the lines it found and says inconclusive rather than guessing.
- */
object VacDiagnostics {
private const val TAG = "VacDiagnostics"
- /**
- * Checked by the launch wrapper. Lines: session start (epoch seconds), the Steam client it ran, then
- * "lengthcanonical path" for each console.log already there.
- */
private const val MARKER = "root/.droiddeck-vac-diagnostics"
private const val LAUNCHES = "root/.droiddeck-vac-launches"
@@ -30,7 +22,6 @@ object VacDiagnostics {
private const val MAX_QUOTED = 40
private val INSECURE = Regex("insecure mode", RegexOption.IGNORE_CASE)
- // "Connecting to" is only an attempt: L4D2 refuses a VAC server with a dialog that never reaches the console.
private val CONNECTED = Regex("^Connected to", RegexOption.IGNORE_CASE)
private val ATTEMPTED = Regex("^Connecting to", RegexOption.IGNORE_CASE)
private val NOTABLE = Regex("\\bVAC\\b|secure|Connected to|Connecting to|Disconnect|challenge|game coordinator", RegexOption.IGNORE_CASE)
@@ -45,7 +36,6 @@ object VacDiagnostics {
File(root, LAUNCHES).delete()
if (enabled(context)) {
val arch = SessionPrefs.steamClientArch(context)
- // -condebug appends, so each console.log's length now is where this session's lines start.
val sizes = consoleLogs(context, arch).joinToString("") { "${it.length()}\t${canonical(it)}\n" }
marker.writeText("${System.currentTimeMillis() / 1000}\n$arch\n$sizes")
} else marker.delete()
@@ -57,7 +47,6 @@ object VacDiagnostics {
private const val SNAPSHOT_MARKER = ".vac-marker"
private const val SNAPSHOT_LAUNCHES = ".vac-launches"
- /** Call when a session stops, before the next [arm] replaces its marker and launch list. */
fun snapshot(context: Context, dir: File) {
val root = LinuxRuntime.rootDir(context)
try {
@@ -145,7 +134,6 @@ object VacDiagnostics {
"Inconclusive - the game never connected to a server. Join a VAC-secured server, then check again."
}
- /** Source writes //console.log, Source 2 /game//console.log. */
private fun consoleLogs(context: Context, arch: String): List {
val found = LinkedHashMap()
for (library in libraryRoots(context, arch)) {
@@ -176,7 +164,6 @@ object VacDiagnostics {
return roots.filter { it.isDirectory }.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.path) }
}
- /** Two libraries can hold the same game and mod folder; a repeated name takes a tag from the log's path. */
internal fun consoleNames(logs: List): List {
val used = HashSet()
return logs.map { log ->
diff --git a/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt
index 8e4b24730..b7cc20a43 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/VideoEncodeBridge.kt
@@ -4,7 +4,6 @@ import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import java.io.File
-/** Steam's Remote Play V4L2 encoder in the guest talks to this MediaCodec encoder (venc.c, venc_server.cpp). */
class VideoEncodeBridge(val socket: File) : SessionPart() {
override fun start() {
socket.parentFile?.mkdirs()
diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
index 4b355caa4..21398bc4c 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
@@ -103,11 +103,6 @@ object WinComponents {
const val VCREDIST_ARM64 = "vcredist2022_arm64"
- /**
- * The ARM64 Visual C++ 2015-2022 runtime as registry values only (the version a 14.44.35211 installer writes, in both
- * registry views). Xbox games' launchers running as ARM64EC check for it; Wine's own CRT does the work. Same approach as
- * MGamer404's Bannerlator #627. Used until the catalog carries a component of this name.
- */
internal fun builtInArm64Runtime(): Component {
fun value(view: String, name: String, type: String, data: String) = Step("set_register_key", JSONObject()
.put("action", "set_register_key").put("key", "HKLM\\Software\\${view}Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64")
@@ -388,7 +383,6 @@ object WinComponents {
val archiveName = archive.optString("name")
val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName
val file = File(work, "${c.name}.files.tar.xz")
- // The snapshot came straight from github.com, so its digest may vouch for a mirror's copy of the archive.
val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) }
if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) {
return null to "download failed: $archiveName"
diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
index 9697addd6..de3648e83 100644
--- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
@@ -48,7 +48,6 @@ object SteamStorage {
)
}
- /** The folder alone does not mean installed: Decky's CEF marker creates it. */
fun x64ClientPresent(root: File): Boolean = File(root, "steamrt64/steam").isFile ||
File(root, "package/droiddeck-installed").isFile || File(root, "steamapps").isDirectory
@@ -58,7 +57,6 @@ object SteamStorage {
private val PENDING_MANIFEST = Regex("appmanifest_(\\d+)\\.acf\\..+")
- // A partial download has no manifest yet, only a temporary one and its staging folder.
fun manifests(library: File): Listing? {
val apps = File(library, "steamapps")
val names = apps.list() ?: return null
@@ -91,8 +89,6 @@ object SteamStorage {
enum class Kind { ARM_PROTON, REQUIRED, OLD_RUNTIME, X86_PROTON }
- // DroidDeck's ARM64 Steam client cannot run Valve's x86 Protons, and its own Proton and FEX tools
- // replace the scout and soldier container runtimes.
fun kind(m: Manifest): Kind {
val dir = m.installDir.orEmpty()
return when {
@@ -146,7 +142,6 @@ object SteamStorage {
private val ARM_IN_NAME = Regex("arm64|aarch64")
- // The x86-64 client ignores tools named for arm64; droiddeck-steam-x64 names its wrappers with a64 instead.
fun x64ToolName(name: String): String = name.replace(ARM_IN_NAME, "a64")
class ToolUse(val mappings: Map, val installed: Set, val x64: Boolean = false) {
diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt
index aa0a76cb2..9c5fff91a 100644
--- a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt
+++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt
@@ -89,7 +89,6 @@ object StorageScan {
fun measure(file: File): Long = size(file).also { known[file.path] = it }
}
- // Proton tools' files live only in the arm64 client's root; the x86-64 client has wrappers of them.
fun steamRoot(context: Context) = SessionPrefs.steamRoot(context, "arm64")
fun scan(context: Context): StorageReport {
@@ -113,7 +112,6 @@ object StorageScan {
Library.steamLibraries(context, arch).map { (root, label) ->
val internal = canonical(root) == primary
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
- // The x86-64 card folder and its steamapps appear only with that client's first session and library write.
val listing = SteamStorage.manifests(root)
?: SteamStorage.Listing(emptyList(), emptySet(), true).takeIf {
x64 && (if (internal) !File(root, "steamapps").exists() else !root.exists() && root.parentFile?.isDirectory == true)
@@ -127,7 +125,6 @@ object StorageScan {
.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
val armUse = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
?.let { SteamStorage.ToolUse(it, installedGames(false)) }
- // An x86-64 client without a config.vdf has never mapped a game to anything.
val x64Vdf = x64Root?.let { File(it, "config/config.vdf") }
val x64Use = if (x64Vdf == null || !x64Vdf.exists()) SteamStorage.ToolUse(emptyMap(), emptySet(), x64 = true)
else runCatching { SteamStorage.ToolUse(SteamStorage.compatMappings(x64Vdf.readText()), installedGames(true), x64 = true) }.getOrNull()
diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt
index f7086336e..503e769be 100644
--- a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt
@@ -115,7 +115,6 @@ object StorageState {
}, "storage-remove-tool").start()
}
- // Every action works from a fresh scan so nothing is deleted on the strength of an older one.
private fun act(context: Context, name: String, action: (Context, StorageReport) -> Boolean, onDone: (Long?) -> Unit = {}) {
if (cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
@@ -134,7 +133,6 @@ object StorageState {
}, "storage-$name").start()
}
- // A game can be installed in both Steam clients, and an id can be left over in several libraries.
fun clearShaders(context: Context, appId: Int, x64: Boolean = false) = act(context, "shaders-$appId", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == appId && it.x64 == x64 }?.shaderDir ?: return@act false
FileOps.delete(dir)
@@ -204,7 +202,6 @@ object StorageState {
}
}
- // Steam treats an app as installed while its manifest exists, so the manifest goes first.
private fun deleteTool(tool: ToolUsage) {
tool.manifests.forEach { check(!it.exists() || it.delete()) { "cannot delete $it" } }
tool.dirs.forEach { FileOps.delete(it) }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt
index 9dfc6b665..2b52fe358 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSavePaths.kt
@@ -12,10 +12,6 @@ object CloudSavePaths {
class GogCloudLocation(val name: String, val template: String)
- /**
- * cloudStorage.enabled with an empty locations list is Galaxy SDK IStorage, whose objects the
- * API names under a location called `__default`.
- */
fun gogSaveFromRemote(enabled: Boolean, locations: List>, clientId: String): GogCloudLocation? {
if (!enabled) return null
val named = locations.filter { it.second.isNotEmpty() }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
index 659ae7005..ace330415 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudSaves.kt
@@ -131,10 +131,6 @@ object CloudSaves {
}
}
- /**
- * The selected cloud location's name: GOG prefixes every cloud file with it, and the local folder
- * is the location itself. Older builds had no name cached; fetch it once for those.
- */
private fun gogLocationName(context: Context, id: String, folder: File): String? {
val p = GogPrefs.get(context)
p.getString("cloud_location_$id", null)?.ifEmpty { null }?.let { return it }
@@ -194,11 +190,6 @@ object CloudSaves {
internal class Migration(val baseline: Map, val moved: Int, val cleaned: Int, val failed: Int)
- /**
- * Moves only files whose `"/"` key proves the old mapping wrote them; anything
- * the game itself created under that name is left where it is. Identical duplicates are removed
- * only when they prove the same. The name must be one path segment: it comes from remote config.
- */
internal fun migrateNested(dir: File, name: String, base: Map): Migration? {
if (name.isEmpty() || name == "." || name == ".." || name.contains('/') || name.contains('\\')) return null
val nested = File(dir, name)
@@ -254,8 +245,6 @@ object CloudSaves {
private fun migrateGogNested(context: Context, id: String, folder: File, dir: File) {
val p = GogPrefs.get(context)
- // Run once per game: after this the flat layout is authoritative, even if the game itself
- // keeps a folder named like the cloud location.
if (p.getInt("cloud_layout_$id", 0) >= CLOUD_LAYOUT) return
val name = gogLocationName(context, id, folder) ?: return
val base = baseline(context, Store.GOG, id).orEmpty()
@@ -281,9 +270,6 @@ object CloudSaves {
fun upload(context: Context, store: Store, id: String, force: Boolean = false): Result =
sync(context, store, id, "up") { dir, cloud -> upWith(context, store, id, dir, cloud, force) }
- fun keepCloud(context: Context, store: Store, id: String): Result = download(context, store, id, force = true)
- fun keepLocal(context: Context, store: Store, id: String): Result = upload(context, store, id, force = true)
-
fun downloadOrDefer(context: Context, store: Store, id: String): Result =
downloadOrDefer(context, store, id, Executor { Thread(it, "cloud-deferred").start() })
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt
index 9c32bd4c1..3d46a28da 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CloudTransport.kt
@@ -47,7 +47,6 @@ internal fun gunzip(data: ByteArray): ByteArray? = try {
java.util.zip.GZIPInputStream(data.inputStream()).use { it.readBytes() }
} catch (e: Exception) { null }
-/** Same gzip GOG uses on PUT: deflate level 6, mtime 0, OS Unix. */
internal fun gzipGogPut(data: ByteArray): ByteArray {
val deflater = Deflater(6, true)
val raw = ByteArrayOutputStream()
@@ -98,17 +97,14 @@ internal fun isoDate(s: String?): Long {
class NoCloudSaves : Exception("no cloud saves")
-/** GOG cloud names are `/`; the name is not part of the folder. */
internal fun splitGogName(name: String): Pair {
val slash = name.indexOf('/')
return if (slash <= 0 || slash == name.length - 1) null to name
else name.substring(0, slash) to name.substring(slash + 1)
}
-/** Maps cloud names to paths under the selected location's folder; the chosen location name is returned. */
internal fun gogRelativeNames(files: List, location: String?): Pair> {
val locations = files.mapNotNull { splitGogName(it.name).first }.distinct()
- // Only infer a location when every name is prefixed; a mixed listing keeps its old shape.
val allPrefixed = files.isNotEmpty() && files.all { splitGogName(it.name).first != null }
val chosen = when {
location != null && (location in locations || allPrefixed) -> location
@@ -167,10 +163,6 @@ class GogCloud(
return relative(raw)
}
- /**
- * The API names every file `/`. The location's folder is the sync root, so the
- * first segment is dropped. Older builds kept it, writing saves one folder too deep.
- */
private fun relative(raw: List): List {
val (chosen, files) = gogRelativeNames(raw, location)
prefix = chosen?.let { "$it/" }
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt
index 01c7eebcf..89b393654 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/CredentialCipher.kt
@@ -12,7 +12,6 @@ import javax.crypto.spec.GCMParameterSpec
object CredentialCipher {
const val VERSION = 1
- /** Sealed with the hardware-keystore (TEE) key; [VERSION] envelopes keep opening with the older, possibly StrongBox, key. */
const val VERSION_TEE = 2
const val ALG = "AES/GCM"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
@@ -23,7 +22,6 @@ object CredentialCipher {
fun existing(): SecretKey = key()
- /** Key for [VERSION] envelopes written before [VERSION_TEE]. */
fun legacy(): SecretKey = existing()
val version: Int get() = VERSION
@@ -35,8 +33,6 @@ object CredentialCipher {
e is android.security.keystore.KeyPermanentlyInvalidatedException ||
e is KeyMissing || e is org.json.JSONException || e is IllegalArgumentException
- // StrongBox took over 10 s per Xbox credential write on a tester's phone, past the backend's
- // 10 s reply timeout, so new writes use a TEE key. The old alias stays readable.
object Keystore : KeyProvider {
private const val LEGACY_ALIAS = "droiddeck-store-credentials"
private const val ALIAS = "droiddeck-store-credentials-tee"
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt
index 027ab1243..ced9ae8fb 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/SteamLiveShortcuts.kt
@@ -130,7 +130,6 @@ object SteamLiveShortcuts {
if (evaluate(context, js) != null) Log.i(TAG, "live shortcuts: art $assetType for $id")
}
- /** Moves and resizes a game's logo in the running client; with null the client's default position comes back. */
fun setLogoPosition(context: Context, appId: Long, position: LogoPosition.Value?) {
if (!clientRunning()) return
val id = appId and 0xFFFFFFFFL
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
index f69380f7c..5de9b5e1a 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreAccounts.kt
@@ -95,7 +95,6 @@ object StoreAccounts {
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
- /** The name the Stores page shows. Xbox shows the gamertag, else the sign-in email with its name part masked. */
fun displayName(context: Context, store: Store): String? {
if (store != Store.XBOX) return signedInAs(context, store)
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt
index 14415fd9c..2b86be8b2 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreArt.kt
@@ -29,7 +29,6 @@ object StoreArt {
return writeLogo(get(url) ?: return false, dst)
}
- /** A logo is drawn over the hero, so it stays a PNG with its transparency instead of going through [place]. */
internal fun writeLogo(bytes: ByteArray, dst: File): Boolean {
if (dst.isFile && dst.length() > 0) return true
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return false
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
index 967e56ca7..ad59e511a 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt
@@ -26,7 +26,6 @@ data class CatalogItem(
val logoUrl: String? = null,
) {
val accessible: Boolean get() = owned || subscription
- /** Licensed now, or a supported package the account's subscription can install. */
val installable: Boolean get() = accessible || includedWith.isNotBlank()
val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank()
val key: String get() = "${store.id}:$id"
@@ -95,7 +94,6 @@ internal fun retainOrder(items: List, previous: List, key: (T) -> Stri
return previous.map { byKey.getValue(key(it)) }
}
-/** A detail page opens for search results too; the library wins when it already knows the product. */
fun detailCatalogItem(
store: Store,
id: String,
@@ -114,11 +112,6 @@ fun mergeItems(old: List?, new: List