mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 11:47:14 -04:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26fa0981c | ||
|
|
ec24b3a472 | ||
|
|
6455b1dade | ||
|
|
542ad3e060 | ||
|
|
910fb47692 | ||
|
|
3dd14dd625 | ||
|
|
d1b0ac4df1 | ||
|
|
0e225d0c6e | ||
|
|
d9cd0d2cc8 | ||
|
|
eadebc46d5 | ||
|
|
96d7c66535 | ||
|
|
90690d1c5c | ||
|
|
abbcb8e3ad | ||
|
|
fe60b3c279 | ||
|
|
c7455b74ff | ||
|
|
c875ceda4d | ||
|
|
666abd134f | ||
|
|
c115916962 | ||
|
|
0579e688ba | ||
|
|
bc50de3b55 | ||
|
|
6489232598 | ||
|
|
c23c436502 | ||
|
|
fad52ec125 | ||
|
|
d22e458d64 | ||
|
|
1b7cb4179d | ||
|
|
c1c22dfd37 | ||
|
|
0043cefab5 | ||
|
|
41f68ef1a1 | ||
|
|
9743ac3abc | ||
|
|
ca6cfcd193 |
No files matched your search
@@ -44,6 +44,19 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check co-authors
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
base=$(git merge-base origin/main HEAD)
|
||||
messages=$(git log --format=%B "$base..HEAD")
|
||||
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
|
||||
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
|
||||
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
|
||||
Granted at install below targetSdk 31; no scan, so no location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,6 +136,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!arg_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for argv");
|
||||
argv[i] = strdup(arg_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, arg_java_string, arg_utf8);
|
||||
(*env)->DeleteLocalRef(env, arg_java_string);
|
||||
}
|
||||
argv[size] = NULL;
|
||||
}
|
||||
@@ -151,6 +152,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
if (!env_utf8) return throw_runtime_exception(env, "GetStringUTFChars() failed for env");
|
||||
envp[i] = strdup(env_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, env_java_string, env_utf8);
|
||||
(*env)->DeleteLocalRef(env, env_java_string);
|
||||
}
|
||||
envp[size] = NULL;
|
||||
}
|
||||
@@ -160,7 +162,7 @@ JNIEXPORT jint JNICALL Java_com_termux_terminal_JNI_createSubprocess(
|
||||
char const* cmd_utf8 = (*env)->GetStringUTFChars(env, cmd, NULL);
|
||||
int ptm = create_subprocess(env, cmd_utf8, cmd_cwd, argv, envp, &procId, rows, columns);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_utf8);
|
||||
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
|
||||
(*env)->ReleaseStringUTFChars(env, cwd, cmd_cwd);
|
||||
|
||||
if (argv) {
|
||||
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
|
||||
|
||||
@@ -22,8 +22,6 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs, so the first session already runs in Deck mode.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
|
||||
@@ -14,6 +14,7 @@ class HomeActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
routeHome()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.io.File
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -17,6 +18,7 @@ import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -119,7 +121,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
@@ -162,6 +163,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var showPhantomGate by mutableStateOf(false)
|
||||
private var directAudio by mutableStateOf(false)
|
||||
private var stretch16x9 by mutableStateOf(false)
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
@@ -341,7 +343,6 @@ class MainActivity : ComponentActivity() {
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var forceSsbs by mutableStateOf(false)
|
||||
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
|
||||
private var runSteamAtStartup by mutableStateOf(false)
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
|
||||
@@ -363,6 +364,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
private var romsDir by mutableStateOf<String?>(null)
|
||||
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
|
||||
@Volatile private var componentSweepDone = false
|
||||
@Volatile private var componentSweepSession: String? = null
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
@@ -532,6 +535,8 @@ class MainActivity : ComponentActivity() {
|
||||
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
|
||||
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
@@ -589,6 +594,7 @@ class MainActivity : ComponentActivity() {
|
||||
updates = updates.state(),
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
@@ -610,7 +616,15 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else {
|
||||
// The launch takes the Windows components already on; the game's list is
|
||||
// looked at again in the background once the session is over.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||
}
|
||||
},
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
@@ -735,6 +749,11 @@ class MainActivity : ComponentActivity() {
|
||||
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
|
||||
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
|
||||
},
|
||||
onOrientation = { value ->
|
||||
SessionPrefs.setOrientation(this, value)
|
||||
orientation = value
|
||||
requestedOrientation = value
|
||||
},
|
||||
onLauncherFullscreen = { on ->
|
||||
SessionPrefs.setLauncherFullscreen(this, on)
|
||||
launcherFullscreen = on
|
||||
@@ -824,6 +843,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
|
||||
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
|
||||
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
@@ -893,6 +913,7 @@ class MainActivity : ComponentActivity() {
|
||||
// A session folder left without its ending - the process was killed - gets it now.
|
||||
if (!SessionState.running) Thread({
|
||||
com.droiddeck.launcher.session.LogMigration.run(this)
|
||||
com.droiddeck.launcher.frontend.GamesFolderMigration.run(this)
|
||||
SessionLogShare.clear(this)
|
||||
SessionArtifacts.finishAbandoned(this)
|
||||
SessionArtifacts.scrubOlder(this)
|
||||
@@ -934,6 +955,15 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
window.attributes = window.attributes.apply {
|
||||
layoutInDisplayCutoutMode = if (launcherFullscreen) {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
} else {
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
@@ -960,6 +990,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
orientation = SessionPrefs.orientation(this)
|
||||
requestedOrientation = orientation
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
@@ -1254,6 +1286,8 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = steamDownloadsInBackground,
|
||||
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
|
||||
pipAutoEnter = pipAutoEnter,
|
||||
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
|
||||
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
|
||||
@@ -1265,8 +1299,6 @@ class MainActivity : ComponentActivity() {
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
steamRepairQueued = steamRepairQueued,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
@@ -1311,6 +1343,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamDownloadsInBackground = enabled
|
||||
},
|
||||
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
|
||||
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
@@ -1343,13 +1376,7 @@ class MainActivity : ComponentActivity() {
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
@@ -1420,6 +1447,8 @@ class MainActivity : ComponentActivity() {
|
||||
val fresh = folders.mapNotNull { f ->
|
||||
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
|
||||
}
|
||||
// An added or edited game's Windows components, from its source's list.
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
|
||||
ui.post {
|
||||
val paths = folders.toSet()
|
||||
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
|
||||
@@ -1465,9 +1494,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
@@ -1478,8 +1509,6 @@ class MainActivity : ComponentActivity() {
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
steamRepairQueued = SteamRepair.queued(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
@@ -1538,7 +1567,6 @@ class MainActivity : ComponentActivity() {
|
||||
clientOverride = SessionPrefs.clientCpusOverride(this)
|
||||
glThread = SessionPrefs.glThread(this)
|
||||
noGlError = SessionPrefs.noGlError(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() }
|
||||
tuSysmem = SessionPrefs.tuSysmem(this)
|
||||
@@ -1579,13 +1607,39 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then one update with the whole list: the wall places games by their position in it,
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
|
||||
// And again after each session (a game installed in it, files changed).
|
||||
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
|
||||
if (ready && (!componentSweepDone || endedSession != null)) {
|
||||
componentSweepDone = true
|
||||
endedSession?.let { componentSweepSession = it }
|
||||
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.Display
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
@@ -51,6 +52,7 @@ import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SessionClipboard
|
||||
import com.droiddeck.launcher.input.OnScreenControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.input.PointerGestures
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
@@ -109,6 +111,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
|
||||
private var pipUi by mutableStateOf(false)
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
/** Unfolded controls: the setting, and the Deck controls under the game while they show. */
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
private var unfoldedPanel: com.droiddeck.launcher.ui.DeckControlsPanel? = null
|
||||
private lateinit var sessionRoot: FrameLayout
|
||||
private val guestKeysDown = mutableSetOf<Int>()
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var sessionOverlay: ComposeView
|
||||
@@ -119,7 +125,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } }
|
||||
|
||||
/**
|
||||
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
|
||||
@@ -132,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
// Every pad already connected is a player from the start, before its first press.
|
||||
padBridge?.claimConnected()
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
@@ -142,8 +150,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
|
||||
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
|
||||
else padMotion?.stop()
|
||||
}
|
||||
private var steamController: SteamControllerBle? = null
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
@@ -241,6 +252,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
/** What the pad is to the Steam client (SessionPrefs.CONTROLLER_*), switched from the drawer. */
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
@@ -299,6 +312,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.claimConnected()
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -315,6 +329,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving() ||
|
||||
com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isMaintaining()) {
|
||||
runtimeRemovalBlocked = true
|
||||
@@ -370,13 +385,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
|
||||
}
|
||||
}
|
||||
sessionRoot = root
|
||||
surfaceView = SurfaceView(this)
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
// The window's size is only known once laid out, and changes as the phone folds or turns.
|
||||
root.addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
|
||||
if (r - l != or - ol || b - t != ob - ot) uiHandler.post { applyUnfoldedControls() }
|
||||
}
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
|
||||
padBridge = bridge
|
||||
SessionState.padBridge = bridge
|
||||
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
|
||||
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
|
||||
steamController = SteamControllerBle(this).also { it.start() }
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
@@ -568,6 +592,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
// Only where the session set the pad up to be switched (SessionService: deckPad).
|
||||
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
|
||||
onPadType = { id ->
|
||||
SessionPrefs.setSteamController(this@SessionActivity, id)
|
||||
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
|
||||
padType = id
|
||||
},
|
||||
backActionsInverted = backActionsInverted,
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
@@ -620,6 +652,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
pipAutoEnter = on
|
||||
pip.refresh(true)
|
||||
},
|
||||
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
|
||||
onUnfoldedControls = ::chooseUnfoldedControls,
|
||||
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
|
||||
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
@@ -800,6 +834,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
upscaler = SessionPrefs.upscaler(this)
|
||||
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
|
||||
@@ -815,7 +850,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, it, 0)
|
||||
}
|
||||
}
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
routedTouchMode = effectiveTouchMode()
|
||||
@@ -825,6 +860,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
padType = SessionPrefs.steamController(this)
|
||||
}
|
||||
|
||||
// ── Compositor ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -1125,6 +1161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
applyFollowOrientation(newConfig)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
@@ -1352,7 +1389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (pipUi) return true
|
||||
val fromController = PadBridge.isControllerKey(event)
|
||||
val fromController = PadBridge.isControllerKeyEvent(event)
|
||||
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
@@ -1408,8 +1445,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
|
||||
// activity, which opens the drawer.
|
||||
val fromPad = fromController
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
if (down || event.action == KeyEvent.ACTION_UP) {
|
||||
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
|
||||
@@ -1447,7 +1483,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (pipUi) return true
|
||||
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
|
||||
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
|
||||
if (!drawerControllerActive) sessionOverlay.requestFocus()
|
||||
drawerControllerActive = true
|
||||
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
|
||||
@@ -1458,7 +1494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchGenericMotionEvent(event)
|
||||
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
@@ -1682,17 +1718,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
|
||||
},
|
||||
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
|
||||
onSteamMenu = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
},
|
||||
onQam = {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
},
|
||||
onSteamMenu = ::tapSteamButton,
|
||||
onQam = ::tapQam,
|
||||
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
|
||||
)
|
||||
secondScreenPresentation = presentation
|
||||
@@ -1709,6 +1736,64 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapSteamButton() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) {
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.droiddeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tapQam() {
|
||||
if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
private fun chooseUnfoldedControls(on: Boolean) {
|
||||
SessionPrefs.setUnfoldedControls(this, on)
|
||||
unfoldedControls = on
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfolded controls: on a foldable's inner screen, the game above and the Steam Deck controller's
|
||||
* trackpads and back grips below ([com.droiddeck.launcher.ui.DeckControlsPanel], as a second
|
||||
* screen shows them, plus a whole gamepad). The on-screen pad is hidden meanwhile. The surface shrinks to the top part, so a Follow screen session resizes
|
||||
* to it and any other is fitted into it. Folded onto the cover screen - a phone-sized panel -
|
||||
* or in picture-in-picture, the game is full screen again.
|
||||
*/
|
||||
private fun applyUnfoldedControls() {
|
||||
if (!::sessionRoot.isInitialized) return
|
||||
val width = sessionRoot.width
|
||||
val height = sessionRoot.height
|
||||
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
|
||||
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
|
||||
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
|
||||
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
|
||||
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
|
||||
else FrameLayout.LayoutParams.MATCH_PARENT
|
||||
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
|
||||
if (params.height != gameHeight) {
|
||||
surfaceView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, gameHeight, Gravity.TOP)
|
||||
Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}"
|
||||
else "unfolded controls: game full screen")
|
||||
}
|
||||
var panel = unfoldedPanel
|
||||
if (!wanted) {
|
||||
if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() }
|
||||
return
|
||||
}
|
||||
if (panel == null) {
|
||||
panel = com.droiddeck.launcher.ui.DeckControlsPanel(this, onClose = { chooseUnfoldedControls(false) },
|
||||
onSteamMenu = ::tapSteamButton, onQam = ::tapQam, pad = padBridge)
|
||||
// Just above the surface: the on-screen pad, keyboard and drawer still draw over it.
|
||||
sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1)
|
||||
unfoldedPanel = panel
|
||||
updateOnScreenControls()
|
||||
}
|
||||
panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM)
|
||||
}
|
||||
|
||||
private fun moveSecondScreenPointer(x: Float, y: Float, width: Float, height: Float) {
|
||||
if (width <= 0f || height <= 0f) return
|
||||
val nx = (x / width).coerceIn(0f, 1f)
|
||||
@@ -1936,7 +2021,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
|
||||
?: SessionPrefs.oscMode(this)
|
||||
val controls = onScreenControls ?: return
|
||||
if (pipUi) {
|
||||
// In picture-in-picture, or with the unfolded screen's gamepad below the game, the on-screen
|
||||
// pad would only cover the picture or that panel.
|
||||
if (pipUi || unfoldedPanel != null) {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
@@ -1971,7 +2058,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Auto: the touch pad when there is no controller - except on the desktop, where the
|
||||
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
|
||||
SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
@@ -2056,6 +2144,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (::pip.isInitialized) pip.changed(inPip)
|
||||
// The panel may have changed while the session was a thumbnail (folded or unfolded in PiP).
|
||||
if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH)
|
||||
applyUnfoldedControls()
|
||||
}
|
||||
|
||||
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
|
||||
@@ -2112,6 +2201,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requestedOrientation = SessionPrefs.orientation(this)
|
||||
if (runtimeRemovalBlocked) return
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
refreshHomeApp()
|
||||
@@ -2218,6 +2308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = false)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
steamController?.stop()
|
||||
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.json.JSONObject
|
||||
class AgentStartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST))
|
||||
if (request == null) {
|
||||
finish()
|
||||
|
||||
@@ -182,26 +182,30 @@ public class PulseAudioComponent extends SessionPart {
|
||||
* client is never left without an output. Says so in the audio log either way.
|
||||
*/
|
||||
private void ensureClientSink(java.io.PrintWriter out) {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
try {
|
||||
long deadline = System.currentTimeMillis() + 15000L;
|
||||
String sinks = null;
|
||||
while (System.currentTimeMillis() < deadline && pid > 1) {
|
||||
if (socket().exists() && (sinks = pactl("list", "short", "sinks")) != null) break;
|
||||
try { Thread.sleep(250L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "ensureClientSink failed", t);
|
||||
}
|
||||
String line;
|
||||
if (sinks == null) {
|
||||
line = "== client sink: the daemon did not answer in 15 s; no fallback attempted";
|
||||
} else if (sinks.contains("\tDirectAudio\t") || sinks.startsWith("DirectAudio\t") || sinks.contains("\tDirectAudio\n") || sinks.matches("(?s).*\\bDirectAudio\\b.*")) {
|
||||
line = "== client sink: DirectAudio (module-directaudio-native-sink)";
|
||||
} else {
|
||||
boolean loaded = pactl("load-module", "module-aaudio-sink", "sink_name=AAudioSink", "performance_mode=1", "adaptive=1", "volume=1.0") != null
|
||||
&& pactl("set-default-sink", "AAudioSink") != null;
|
||||
line = loaded ? "== client sink: DirectAudio did not come up; FALLBACK to the plain AAudio sink (AAudioSink)"
|
||||
: "== client sink: DirectAudio did not come up and the AAudio fallback failed too - no client sound";
|
||||
Log.w(TAG, line);
|
||||
}
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
}
|
||||
|
||||
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
|
||||
@@ -223,8 +227,14 @@ public class PulseAudioComponent extends SessionPart {
|
||||
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
|
||||
process = builder.start();
|
||||
String output;
|
||||
try (InputStream in = process.getInputStream()) {
|
||||
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
try (InputStream in = process.getInputStream();
|
||||
java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
outStream.write(buf, 0, n);
|
||||
}
|
||||
output = outStream.toString(java.nio.charset.StandardCharsets.UTF_8.name());
|
||||
}
|
||||
if (!process.waitFor(5, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
@@ -234,7 +244,7 @@ public class PulseAudioComponent extends SessionPart {
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) process.destroyForcibly();
|
||||
|
||||
@@ -152,7 +152,6 @@ object DeviceReport {
|
||||
k("Zink lazy descriptors", SessionPrefs.zinkLazy(context))
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
|
||||
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
|
||||
|
||||
@@ -31,7 +31,8 @@ object GameEnvironment {
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
|
||||
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
|
||||
"DOTNET_GCRegionRange" to "0x800000000",
|
||||
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
|
||||
"DOTNET_GCRegionRange" to "800000000",
|
||||
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
|
||||
// running the stale translation and the game hangs at startup.
|
||||
"DOTNET_EnableWriteXorExecute" to "0",
|
||||
|
||||
@@ -93,9 +93,12 @@ object LogRedactor {
|
||||
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
|
||||
)
|
||||
|
||||
/** A learned pattern and the text (lowercase) a line must hold for it to match; null when none can be said. */
|
||||
private class Learned(val regex: Regex, val needs: String?)
|
||||
|
||||
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
|
||||
@Volatile
|
||||
private var accounts: List<Regex> = emptyList()
|
||||
private var accounts: List<Learned> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
|
||||
@@ -103,12 +106,13 @@ object LogRedactor {
|
||||
* Names under three characters are skipped: blanking every "a" would ruin a log.
|
||||
*/
|
||||
fun learnAccounts(loginUsers: java.io.File) {
|
||||
val found = ArrayList<Regex>()
|
||||
val found = ArrayList<Learned>()
|
||||
try {
|
||||
if (loginUsers.isFile) {
|
||||
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
|
||||
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
|
||||
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
|
||||
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
|
||||
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -125,7 +129,7 @@ object LogRedactor {
|
||||
|
||||
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
|
||||
@Volatile
|
||||
private var own: List<Regex> = emptyList()
|
||||
private var own: List<Learned> = emptyList()
|
||||
|
||||
/**
|
||||
* Learns the device's addresses from the link file the app writes for the session
|
||||
@@ -133,7 +137,7 @@ object LogRedactor {
|
||||
* private, link-local and loopback addresses identify nobody and are left out.
|
||||
*/
|
||||
fun learnOwnAddresses(linkFile: java.io.File) {
|
||||
val found = ArrayList<Regex>()
|
||||
val found = ArrayList<Learned>()
|
||||
try {
|
||||
if (linkFile.isFile) linkFile.forEachLine { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
|
||||
@@ -142,9 +146,12 @@ object LogRedactor {
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEachLine
|
||||
// Any spelling of an address in this /64: leading zeros dropped, :: anywhere after.
|
||||
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
|
||||
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
|
||||
found += Learned(
|
||||
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
|
||||
groups[0].trimStart('0').ifEmpty { "0" },
|
||||
)
|
||||
}
|
||||
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
|
||||
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -263,41 +270,87 @@ object LogRedactor {
|
||||
return "$q<redacted:account>$q"
|
||||
}
|
||||
|
||||
/**
|
||||
* A line on its way through [redact]. Each rule runs only on a line holding what it cannot match
|
||||
* without - a word, enough separators, a long enough run: most of a Steam log is lines no rule
|
||||
* touches, and a session's logs went through every rule at about a megabyte a second. A line with anything but ASCII in it goes through
|
||||
* every case-insensitive rule, since Android's regex folds case more widely than lowercase().
|
||||
*/
|
||||
private class Line(var text: String) {
|
||||
private val unfolded = text.any { it.code >= 0x80 }
|
||||
private var lower: String? = null
|
||||
|
||||
fun has(vararg words: String): Boolean {
|
||||
if (unfolded) return true
|
||||
val folded = lower ?: text.lowercase().also { lower = it }
|
||||
return words.any { folded.contains(it) }
|
||||
}
|
||||
|
||||
fun set(next: String) {
|
||||
if (next != text) {
|
||||
text = next
|
||||
lower = null
|
||||
}
|
||||
}
|
||||
|
||||
fun holds(c: Char, times: Int): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (ch == c && ++n >= times) return true
|
||||
return false
|
||||
}
|
||||
|
||||
inline fun hasRun(length: Int, inRun: (Char) -> Boolean): Boolean {
|
||||
var n = 0
|
||||
for (ch in text) if (!inRun(ch)) n = 0 else if (++n >= length) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||
|
||||
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
|
||||
fun redact(line: String): String {
|
||||
if (line.isEmpty()) return line
|
||||
return try {
|
||||
var out = line
|
||||
out = MAC_FIELD.replace(out) { "${it.groupValues[1]}<redacted:mac>" }
|
||||
out = MAC.replace(out, "<redacted:mac>")
|
||||
out = DEVICE_IDENTIFIER.replace(out) {
|
||||
val out = Line(line)
|
||||
if (out.has("mac")) out.set(MAC_FIELD.replace(out.text) { "${it.groupValues[1]}<redacted:mac>" })
|
||||
if (out.holds(':', 5) || out.holds('-', 5)) out.set(MAC.replace(out.text, "<redacted:mac>"))
|
||||
if (out.has("serial", "android")) out.set(DEVICE_IDENTIFIER.replace(out.text) {
|
||||
val value = it.groupValues[2]
|
||||
val quote = value.first().takeIf { char -> char == '\"' || char == '\'' }?.toString().orEmpty()
|
||||
"${it.groupValues[1]}${quote}<redacted:serial>${quote}"
|
||||
})
|
||||
if (out.holds('-', 4)) out.set(GUID.replace(out.text, "<redacted:guid>"))
|
||||
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
|
||||
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
|
||||
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
|
||||
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
|
||||
}
|
||||
out = GUID.replace(out, "<redacted:guid>")
|
||||
out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}<redacted:jwt>" }
|
||||
out = JWT_BASE64.replace(out, "<redacted:jwt>")
|
||||
out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" }
|
||||
out = GUARD_CODE.replace(out) { "${it.groupValues[1]}<redacted:code>" }
|
||||
out = WEBAPI_KEY.replace(out, "<redacted:key>")
|
||||
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
|
||||
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
|
||||
// Mask, not delete: the last four digits let a reader correlate lines to one account.
|
||||
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
|
||||
out = STEAMID3.replace(out) { m ->
|
||||
if ("76561" in out.text) out.set(STEAMID64.replace(out.text) { "${it.groupValues[1]}********${it.groupValues[3]}" })
|
||||
if ("[U:1:" in out.text) out.set(STEAMID3.replace(out.text) { m ->
|
||||
val id = m.groupValues[1]
|
||||
"[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]"
|
||||
})
|
||||
if (out.has("external")) out.set(EXTERNAL_ADDR.replace(out.text) { "${it.groupValues[1]}<redacted:ip>" })
|
||||
for (r in own) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:ip>"))
|
||||
if (out.has("onloginstatechange")) out.set(LOGIN_STATE.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if ("OnLoginUsersChanged " in out.text) out.set(LOGIN_USERS.replace(out.text) { "${it.groupValues[1]}<redacted:account>" })
|
||||
if (out.has("account", "user")) {
|
||||
out.set(ACCOUNT_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
}
|
||||
out = EXTERNAL_ADDR.replace(out) { "${it.groupValues[1]}<redacted:ip>" }
|
||||
for (r in own) out = r.replace(out, "<redacted:ip>")
|
||||
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
|
||||
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
|
||||
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
|
||||
for (r in accounts) out = r.replace(out, "<redacted:account>")
|
||||
out = EMAIL.replace(out, "<redacted:email>")
|
||||
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
|
||||
out = LONG_TOKEN.replace(out, "<redacted:token>")
|
||||
out
|
||||
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
|
||||
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
|
||||
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
|
||||
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
|
||||
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
|
||||
}
|
||||
if (out.hasRun(88) { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '_' || it == '-' }) {
|
||||
out.set(LONG_TOKEN.replace(out.text, "<redacted:token>"))
|
||||
}
|
||||
out.text
|
||||
} catch (t: Throwable) {
|
||||
// A log line is never worth crashing a session over, but an unscrubbed one must not
|
||||
// reach the file either.
|
||||
|
||||
@@ -22,6 +22,7 @@ class FileManagerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class FilePickerActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
// targetSdk 28: the old storage permission is exactly what lets us list the card.
|
||||
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
|
||||
@@ -137,8 +137,7 @@ object AddedGameArt {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = runCatching { GameIdentifier.identify(game.exe).appId?.toString() }.getOrNull()
|
||||
?: File(cache, "steam-appid").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() && it != "none" }
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
@@ -222,10 +221,11 @@ object AddedGameArt {
|
||||
val cache = cacheDir(context, game)
|
||||
if (File(cache, "p.jpg").isFile) continue
|
||||
cache.mkdirs()
|
||||
val appId = runCatching { GameIdentifier.identify(game.exe).appId }.getOrNull()
|
||||
val id = steamAppId(game.name, appId, File(cache, "steam-appid")) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
val match = steamMatch(context, game) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
|
||||
}
|
||||
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && download(id, cache)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
@@ -237,16 +237,13 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
/**
|
||||
* The game's Steam appid for its art: the one its files name ([appId]) as it is, else the
|
||||
* remembered or freshly searched one ([search] on [name]); null for none.
|
||||
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
|
||||
* files name, else a store search on its title ([search]) whose result has the same name.
|
||||
*/
|
||||
internal fun steamAppId(name: String, appId: Int?, lookup: File, search: (String) -> String?): String? {
|
||||
if (appId != null) {
|
||||
lookup.writeText(appId.toString())
|
||||
Log.i(TAG, "$name: Steam app $appId (from its files)")
|
||||
return appId.toString()
|
||||
}
|
||||
return remembered(lookup) { search(name)?.also { Log.i(TAG, "$name: Steam app $it") } }
|
||||
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
|
||||
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
|
||||
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
|
||||
return match
|
||||
}
|
||||
|
||||
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
|
||||
|
||||
@@ -43,22 +43,17 @@ object AddedGames {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
|
||||
/** One Games folder and where the session sees it. */
|
||||
/** A Games folder an earlier build had, and where the session sees it. */
|
||||
class Root(val host: File, val guest: String)
|
||||
|
||||
/**
|
||||
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
|
||||
* the host path when two chosen folders share a name (so a folder's guest path, and with it
|
||||
* every shortcut's appid, does not change when another folder is added or removed).
|
||||
* The Games folders earlier builds had, bound into the session at the guest paths they had
|
||||
* then (/root/Games/<name>), so the games in them (entries now, [GamesFolderMigration]) keep
|
||||
* their paths and shortcuts. Nothing adds one any more.
|
||||
*/
|
||||
fun roots(context: Context): List<Root> {
|
||||
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
Root(host, "$GUEST_DIR/$guestName")
|
||||
}
|
||||
GamesFolderMigration.run(context)
|
||||
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
@@ -98,18 +93,26 @@ object AddedGames {
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]); also what its exe box lists, in that order. */
|
||||
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
|
||||
fun candidates(folder: File): List<File> = rank(folder).exes
|
||||
|
||||
/** [GameExePicker]'s ranking of [folder], without the names this list never offers. */
|
||||
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
|
||||
fun rank(folder: File): GameExePicker.Ranking {
|
||||
val key = "${folder.path}|${folder.lastModified()}"
|
||||
val now = System.currentTimeMillis()
|
||||
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
|
||||
return GameExePicker.rank(folder) { !SKIP.matches(it.name) }.also {
|
||||
if (ranked.size > 256) ranked.clear()
|
||||
ranked[key] = now to it
|
||||
}
|
||||
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
|
||||
val declared = declaredExes(folder)
|
||||
val preferred = declared.map { it.path }.toSet()
|
||||
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
|
||||
declared + guessed.exes.filter { it.path !in preferred },
|
||||
List(declared.size) { Int.MAX_VALUE } + guessed.exes.zip(guessed.scores)
|
||||
.filter { it.first.path !in preferred }.map { it.second },
|
||||
uncertain = false,
|
||||
)
|
||||
if (ranked.size > 256) ranked.clear()
|
||||
ranked[key] = now to result
|
||||
return result
|
||||
}
|
||||
|
||||
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
|
||||
@@ -160,10 +163,29 @@ object AddedGames {
|
||||
else -> peName
|
||||
}
|
||||
}
|
||||
/** The executables MicrosoftGame.config or appxmanifest.xml declares that exist inside [folder]. */
|
||||
internal fun declaredExes(folder: File): List<File> {
|
||||
val names = DECLARED.flatMap { (file, pattern) ->
|
||||
runCatching { pattern.findAll(File(folder, file).readText()).map { it.groupValues[1] }.toList() }.getOrDefault(emptyList())
|
||||
}
|
||||
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
|
||||
return names.asSequence()
|
||||
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
|
||||
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
|
||||
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
|
||||
.distinctBy { it.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private val DECLARED = listOf(
|
||||
"MicrosoftGame.config" to Regex("<Executable\\b[^>]*?\\bName\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
val folders = roots(context).map { it.host } + listOfNotNull(
|
||||
GamesFolderMigration.run(context)
|
||||
val folders = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
@@ -237,17 +259,19 @@ object AddedGames {
|
||||
private fun inside(path: String, folder: String) = path.startsWith(folder.trimEnd('/') + "/")
|
||||
|
||||
/**
|
||||
* The game folder [exe] belongs to, without a scan: an entry from the Games tab, a removed
|
||||
* game, a store install, or the first-level folder under a Games folder (or the Steam library)
|
||||
* that holds it. Null when it is in none.
|
||||
* The game folder [exe] belongs to, without a scan: an entry from the Games tab (Games folders
|
||||
* of earlier builds included), a removed game, a store install, or the first-level folder under
|
||||
* the Steam library that holds it. Null when it is in none.
|
||||
*/
|
||||
private fun ownerFolder(context: Context, exe: File): File? {
|
||||
GamesFolderMigration.run(context)
|
||||
val path = canonical(exe)
|
||||
fun holds(f: File) = inside(path, canonical(f))
|
||||
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
|
||||
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
|
||||
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
|
||||
val dirs = roots(context).map { it.host } + listOfNotNull(
|
||||
// Games folders of earlier builds are entries now, found above.
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
)
|
||||
@@ -321,12 +345,13 @@ object AddedGames {
|
||||
|
||||
/**
|
||||
* The game folders already listed, without building any game: the first-level folders of the
|
||||
* Games folders and the Steam library (as [scan] walks them), the Games tab's entries and the
|
||||
* store installs. Canonical paths; removed games are left out.
|
||||
* Steam library (as [scan] walks them), the Games tab's entries (Games folders of earlier builds
|
||||
* included) and the store installs. Canonical paths; removed games are left out.
|
||||
*/
|
||||
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val dirs = roots(context).map { it.host } + listOfNotNull(
|
||||
GamesFolderMigration.run(context)
|
||||
val dirs = listOfNotNull(
|
||||
GameStorage.effective(context)?.let { File(it.path) },
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
|
||||
/**
|
||||
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
|
||||
* findings marked auto are turned on, only the ones that install here, and never one the user
|
||||
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
|
||||
*
|
||||
* All the work is done by one background thread (low priority, so are the runtime helpers it
|
||||
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
|
||||
* it) and downloading a component (each once, whichever games want it). The queue waits while a
|
||||
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
|
||||
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
|
||||
*/
|
||||
object AutoComponents {
|
||||
private const val TAG = "AutoComponents"
|
||||
|
||||
/** The key a game's picks are kept under: the appid its prefix is named after. */
|
||||
fun appKey(game: Library.SteamGame): String? =
|
||||
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
|
||||
|
||||
/**
|
||||
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
|
||||
* name each auto finding installs as, with its reason. A component that needs a Windows
|
||||
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
|
||||
*/
|
||||
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
|
||||
val out = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
for (f in findings) {
|
||||
if (!f.auto || f.component == null) continue
|
||||
val id = WinComponents.installable(f.component, catalog)
|
||||
val c = catalog[id] ?: continue
|
||||
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
|
||||
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- seams for tests ----
|
||||
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
|
||||
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
|
||||
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
|
||||
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
|
||||
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
|
||||
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
|
||||
/** A usable network: a download waits for one. */
|
||||
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
|
||||
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
|
||||
@VisibleForTesting @Volatile internal var runNow = false
|
||||
|
||||
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
|
||||
val changes = MutableStateFlow(0)
|
||||
|
||||
/** The components downloading for an automatic pick right now, with their progress. */
|
||||
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
|
||||
|
||||
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
|
||||
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Components queued for an automatic pick, not yet downloaded. */
|
||||
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
private fun mark(job: Job, on: Boolean) {
|
||||
if (job.pics != null) return
|
||||
val flow = if (job.component != null) pendingComponents else pendingGames
|
||||
val id = job.component ?: job.game?.let { appKey(it) } ?: return
|
||||
flow.value = if (on) flow.value + id else flow.value - id
|
||||
}
|
||||
|
||||
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
|
||||
internal class Job(
|
||||
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
|
||||
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
|
||||
val inSession: Boolean = false,
|
||||
) {
|
||||
val key get() = when {
|
||||
pics != null -> "pics"
|
||||
component != null -> "c:$component"
|
||||
else -> "g:" + (game?.let { appKey(it) } ?: "")
|
||||
}
|
||||
/** Needs the network: a download, or Steam asked anonymously. */
|
||||
val online get() = component != null || pics != null
|
||||
}
|
||||
|
||||
private val seq = java.util.concurrent.atomic.AtomicLong()
|
||||
/** What the user asked for first, then in the order queued. */
|
||||
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
|
||||
@Volatile private var draining = false
|
||||
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||
/** The games waiting on a component's download, to turn it on for when it lands. */
|
||||
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
|
||||
@Volatile private var worker: Thread? = null
|
||||
private const val PAUSE_MS = 15_000L
|
||||
|
||||
/**
|
||||
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
|
||||
* user is waiting (its page is open, or they installed or added it).
|
||||
*/
|
||||
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
|
||||
}
|
||||
|
||||
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
|
||||
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
|
||||
|
||||
/** Adds [job] unless the same one is queued (a game or a component, once each). */
|
||||
private fun enqueue(context: Context, job: Job) {
|
||||
if (!queuedKeys.add(job.key)) {
|
||||
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
|
||||
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
|
||||
if (runNow && !draining) drain(context)
|
||||
return
|
||||
}
|
||||
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
|
||||
mark(job, true)
|
||||
jobs.add(job)
|
||||
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
|
||||
}
|
||||
|
||||
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
|
||||
internal fun mustWait(context: Context, job: Job): Boolean =
|
||||
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
|
||||
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
|
||||
|
||||
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
|
||||
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
|
||||
|
||||
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
|
||||
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
|
||||
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
|
||||
} == true
|
||||
|
||||
/**
|
||||
* A game Steam installed during the running session: its list worked out and its components
|
||||
* readied in the session (one job, low priority), so a launch right after finds them; it
|
||||
* waits while a game runs. All other work stays paused until the session ends.
|
||||
*/
|
||||
fun queueInSession(context: Context, game: Library.SteamGame) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
|
||||
}
|
||||
|
||||
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
|
||||
@VisibleForTesting
|
||||
internal fun drain(context: Context) {
|
||||
draining = true
|
||||
try {
|
||||
while (true) {
|
||||
val job = next(context) ?: return
|
||||
queuedKeys.remove(job.key)
|
||||
run(context, job)
|
||||
}
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The first job in order that may run now, taken off the queue; null when none may. */
|
||||
private fun next(context: Context): Job? = synchronized(jobs) {
|
||||
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
|
||||
jobs.remove(job)
|
||||
job
|
||||
}
|
||||
|
||||
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
|
||||
|
||||
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
|
||||
|
||||
@Synchronized
|
||||
private fun ensureWorker(app: Context) {
|
||||
if (worker?.isAlive == true) return
|
||||
worker = Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
while (true) {
|
||||
// Something queued, then the first that may run (one for a game installed in the
|
||||
// session goes ahead of paused work); else a look again in a while.
|
||||
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
|
||||
val job = next(app)
|
||||
if (job == null) {
|
||||
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
|
||||
continue
|
||||
}
|
||||
queuedKeys.remove(job.key)
|
||||
WinComponents.background { run(app, job) }
|
||||
}
|
||||
}, "auto-components").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun run(context: Context, job: Job) {
|
||||
try {
|
||||
when {
|
||||
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
|
||||
job.component != null -> download(context, job.component, job.asked, job.inSession)
|
||||
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} finally {
|
||||
mark(job, false)
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [game]'s list (worked out again only when what it is read from changed), its automatic
|
||||
* components that are downloaded turned on, and the rest queued for download.
|
||||
*/
|
||||
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
|
||||
val key = appKey(game) ?: return
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
|
||||
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
|
||||
}
|
||||
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
|
||||
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
|
||||
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
|
||||
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
|
||||
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
|
||||
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
|
||||
val findings = findings(context, game)
|
||||
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
|
||||
val wanted = autoPicks(findings, catalog)
|
||||
val user = WinComponents.selection(context, key).user
|
||||
val installed = installedOf(context)
|
||||
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
val missing = ArrayList<String>()
|
||||
for ((id, reason) in wanted) {
|
||||
if (user[id] == false) { kept[id] = reason; continue }
|
||||
if (id in installed) { kept[id] = reason; continue }
|
||||
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
|
||||
queueDownload(context, id, asked, inSession)
|
||||
missing.add(id)
|
||||
}
|
||||
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
|
||||
if (inSession) Pending.set(context, key, missing)
|
||||
val before = WinComponents.selection(context, key).auto
|
||||
if (before.keys != kept.keys) {
|
||||
WinComponents.setAuto(context, key, kept)
|
||||
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
|
||||
}
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
|
||||
/** One component downloaded and installed, then turned on for every game waiting on it. */
|
||||
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
|
||||
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
|
||||
if (id !in installedOf(context)) {
|
||||
val c = catalog[id] ?: run { Pending.drop(context, id); return }
|
||||
val started = System.currentTimeMillis()
|
||||
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
|
||||
val problem = try {
|
||||
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
|
||||
} finally {
|
||||
downloading.value = downloading.value - id
|
||||
}
|
||||
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
|
||||
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
|
||||
}
|
||||
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
|
||||
games.forEach { apply(context, it, asked, inSession) }
|
||||
Pending.drop(context, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The components still being readied for games installed in the session, where the launch
|
||||
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
|
||||
*/
|
||||
internal object Pending {
|
||||
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
|
||||
|
||||
@Synchronized fun read(context: Context): JSONObject =
|
||||
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
|
||||
|
||||
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
|
||||
val games = read(context)
|
||||
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
@Synchronized fun drop(context: Context, id: String) {
|
||||
val games = read(context)
|
||||
for (k in games.keys().asSequence().toList()) {
|
||||
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
|
||||
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
|
||||
}
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
private fun write(context: Context, games: JSONObject) {
|
||||
val f = file(context)
|
||||
runCatching {
|
||||
f.parentFile?.mkdirs()
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(JSONObject().put("games", games).toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the list, kept per game ----
|
||||
|
||||
private const val FINDINGS = "wincomp-findings.json"
|
||||
|
||||
/** What the list is read from, by modification time: a change means it is worked out again. */
|
||||
internal fun signature(context: Context, game: Library.SteamGame): String {
|
||||
val folder = game.gameFiles
|
||||
val parts = listOfNotNull(
|
||||
folder?.lastModified(),
|
||||
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
|
||||
folder?.let { StoreGameSidecar.file(it).lastModified() },
|
||||
folder?.let { File(it, "fuel.json").lastModified() },
|
||||
SteamAppInfo.appinfoFile(context).lastModified(),
|
||||
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
|
||||
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
|
||||
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
|
||||
val key = appKey(game) ?: return emptyList()
|
||||
val sig = signature(context, game)
|
||||
val all = readFindings(context)
|
||||
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
|
||||
val fresh = WinCompSources.forGame(context, game)
|
||||
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** The list kept for [appKey], without working anything out; null before the first time. */
|
||||
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
|
||||
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
|
||||
|
||||
@Synchronized
|
||||
private fun readFindings(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
private fun writeFindings(context: Context, json: JSONObject) {
|
||||
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
|
||||
list.forEach { f ->
|
||||
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
|
||||
.apply { f.component?.let { put("component", it) } })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
|
||||
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
|
||||
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
|
||||
}
|
||||
|
||||
// ---- when ----
|
||||
|
||||
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
|
||||
|
||||
/** After an install or an add: the user did it, so it does not wait on a metered network. */
|
||||
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
|
||||
|
||||
/**
|
||||
* Every game, whatever its source, at app start and after each session (Steam installs its
|
||||
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
|
||||
* only queued, one game at a time, behind anything asked for, under the same conditions. A
|
||||
* game whose files did not change costs a look at their times.
|
||||
*/
|
||||
fun sweep(context: Context, games: List<Library.SteamGame>) {
|
||||
// First every Steam app these games are that the client never saw, asked of Steam in one
|
||||
// connection; then each game's list.
|
||||
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
|
||||
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
|
||||
games.forEach { queueGame(context, it, asked = false) }
|
||||
}
|
||||
|
||||
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
|
||||
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
|
||||
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
|
||||
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
|
||||
|
||||
/** The default network is there and validated (reaches the internet). */
|
||||
fun isOnline(context: Context): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
|
||||
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
/** Battery saver, thermal status moderate or worse, or a metered network. */
|
||||
private fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,9 @@ object DependencyDetector {
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
|
||||
// Framework to install.
|
||||
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
@@ -157,6 +160,19 @@ object DependencyDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
|
||||
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
|
||||
* with no year is the latest; empty for one that is none of ours.
|
||||
*/
|
||||
fun componentsFor(path: String): List<String> {
|
||||
val p = path.replace('\\', '/').lowercase(Locale.US)
|
||||
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
|
||||
}
|
||||
|
||||
/** The name the catalog component [name] goes by, for a reason line. */
|
||||
fun label(name: String): String = LABELS[name] ?: name
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
|
||||
@@ -48,7 +48,7 @@ object GameFileSync {
|
||||
check(File(previousStorage).isDirectory) { context.getString(R.string.game_sync_err_previous_sd) }
|
||||
}
|
||||
storage?.let { check(File(it.path).listFiles() != null) { context.getString(R.string.game_sync_err_game_storage) } }
|
||||
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
|
||||
GameFiles.sync(context, folder, Library.launchableGames(context, strictRead = true))
|
||||
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* Games folders (the roots earlier builds let the user add in Steam settings) become what the
|
||||
* Games tab's "Add all games in this folder" makes: one entry per game ([AddedExes]). Everything
|
||||
* a game had is keyed by its own folder and stays as it was - the chosen exe, the shortcut appid,
|
||||
* the name, Start in, launch options, art choices, a removal. The roots stay bound into the
|
||||
* session at the same guest paths ([SessionPrefs.gameFolderBinds]), so every exe keeps its path
|
||||
* inside the session and every Steam shortcut its target; they are no longer Steam libraries.
|
||||
* Runs whenever saved roots remain (so twice is the same as once) and clears them after.
|
||||
*/
|
||||
object GamesFolderMigration {
|
||||
private const val TAG = "GamesFolderMigration"
|
||||
|
||||
@Synchronized
|
||||
fun run(context: Context) {
|
||||
val dirs = SessionPrefs.addedGamesDirs(context)
|
||||
if (dirs.isEmpty()) return
|
||||
val roots = legacyRoots(SessionPrefs.gameFolderBinds(context).map { it.first } + dirs)
|
||||
.filter { root -> root.host.path in dirs }
|
||||
// The binds first: a game's guest path (and with it the shortcut's appid) depends on them.
|
||||
SessionPrefs.setGameFolderBinds(context, SessionPrefs.gameFolderBinds(context) + roots.map { it.host.path to it.guest })
|
||||
val removed = SessionPrefs.removedAddedGames(context).toSet()
|
||||
val entries = AddedExes.list(context).map { it.folder }.toMutableSet()
|
||||
val storeRoots = StoreInstallRoot.roots(context).map { it.absolutePath }
|
||||
for (root in roots) {
|
||||
var n = 0
|
||||
val steamInstalls = AddedGames.steamInstallDirs(root.host)
|
||||
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
|
||||
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
|
||||
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
|
||||
if (folder.path in entries) continue
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = picked ?: AddedGames.rank(folder).exes.firstOrNull() ?: continue
|
||||
AddedExes.put(context, AddedExes.Entry(folder.path, exe.path, picked = picked != null))
|
||||
entries.add(folder.path)
|
||||
if (folder.path !in removed) n++
|
||||
}
|
||||
Log.i(TAG, "games: migrated ${root.host} → $n games")
|
||||
}
|
||||
SessionPrefs.setAddedGamesDirs(context, emptyList())
|
||||
SessionPrefs.setGamesFoldersMigrated(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* The guest paths earlier builds gave Games folders: /root/Games/<folder name>, with a short
|
||||
* hash of the host path when two share a name. Kept exactly, so nothing moves in the session.
|
||||
*/
|
||||
internal fun legacyRoots(paths: List<String>): List<AddedGames.Root> {
|
||||
val hosts = paths.distinct().map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
AddedGames.Root(host, "${AddedGames.GUEST_DIR}/$guestName")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
|
||||
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
|
||||
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
|
||||
* Windows components are in place when the user plays it right after the download.
|
||||
*/
|
||||
object SessionInstalls {
|
||||
private const val TAG = "SessionInstalls"
|
||||
private const val POLL_MS = 5_000L
|
||||
|
||||
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
|
||||
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
|
||||
|
||||
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal fun parse(file: File): Manifest? = runCatching {
|
||||
val text = file.readText()
|
||||
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
|
||||
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
|
||||
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
|
||||
}.getOrNull()
|
||||
|
||||
/** The libraries the session's client installs to, as host folders. */
|
||||
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
|
||||
).filter { it.isDirectory }
|
||||
|
||||
/**
|
||||
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
|
||||
* [poll] returns the apps that became fully installed since the last look (none on the first).
|
||||
*/
|
||||
internal class Watch(private val dirs: () -> List<File>) {
|
||||
private val seen = HashMap<String, Pair<Long, Manifest?>>()
|
||||
private var first = true
|
||||
|
||||
fun poll(): List<Manifest> {
|
||||
val fresh = ArrayList<Manifest>()
|
||||
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
|
||||
val before = seen[f.path]
|
||||
if (before != null && before.first == f.lastModified()) continue
|
||||
val now = parse(f)
|
||||
seen[f.path] = f.lastModified() to now
|
||||
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
|
||||
}
|
||||
first = false
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
/** A finished install as the Games tab would list it. */
|
||||
internal fun gameOf(m: Manifest): Library.SteamGame =
|
||||
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
|
||||
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
|
||||
|
||||
/**
|
||||
* Looks every five seconds while the session runs, on a thread of its own at background
|
||||
* priority; each finished install becomes one job for its Windows components.
|
||||
*/
|
||||
fun watch(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
val watch = Watch { steamappsDirs(app) }
|
||||
try {
|
||||
// The session is still starting when this is called: up to two minutes for it.
|
||||
var waited = 0L
|
||||
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
|
||||
watch.poll()
|
||||
while (SessionState.running) {
|
||||
for (m in watch.poll()) {
|
||||
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
|
||||
AutoComponents.queueInSession(app, gameOf(m))
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "install watch: ${e.message}")
|
||||
}
|
||||
}, "session-installs").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Steam's own data for an app, from the client in the runtime: its app-info cache
|
||||
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
|
||||
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
|
||||
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
|
||||
* app the client has not seen is looked for again after a week.
|
||||
*/
|
||||
object SteamAppInfo {
|
||||
private const val TAG = "SteamAppInfo"
|
||||
private const val CACHE = "steam-appinfo.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
/** The Steamworks Shared app the redistributable depots belong to. */
|
||||
const val SHARED_APP = "228980"
|
||||
|
||||
class Info(val name: String, val sharedDepots: List<String>)
|
||||
|
||||
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
|
||||
|
||||
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
|
||||
@Synchronized
|
||||
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
|
||||
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
|
||||
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfo.lastModified()
|
||||
val known = cache.optJSONObject(appId.toString())
|
||||
known?.let { o ->
|
||||
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
|
||||
// A miss is read again when the client has written its cache since (the user may have
|
||||
// looked at the app in Steam), else a week later.
|
||||
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
|
||||
}
|
||||
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime)
|
||||
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
|
||||
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
|
||||
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
|
||||
cache.put(appId.toString(), entry)
|
||||
val out = cacheFile.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
|
||||
return read
|
||||
}
|
||||
|
||||
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
|
||||
|
||||
/**
|
||||
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
|
||||
* answer has, and that Steam was not asked about anonymously in the past week.
|
||||
*/
|
||||
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
|
||||
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
|
||||
if (wanted.isEmpty()) return emptyList()
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
|
||||
}
|
||||
|
||||
/**
|
||||
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
|
||||
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
|
||||
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
|
||||
*/
|
||||
@Synchronized
|
||||
fun lookup(context: Context, appIds: List<Int>): Boolean {
|
||||
val need = needLookup(context, appIds)
|
||||
if (need.isEmpty()) return false
|
||||
val answers = pics(need) ?: return false
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfoFile(context).lastModified()
|
||||
for (id in need) {
|
||||
val info = answers[id]
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
|
||||
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
|
||||
cache.put(id.toString(), entry)
|
||||
}
|
||||
val file = AtomicFile(File(context.filesDir, CACHE))
|
||||
val out = file.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
|
||||
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
|
||||
|
||||
private fun cache(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
|
||||
|
||||
/**
|
||||
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
|
||||
* table), or null when the file does not have it.
|
||||
*/
|
||||
internal fun read(file: File, appId: Int): Info? {
|
||||
if (!file.isFile) return null
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
raf.readFully(head.array(), 0, 8)
|
||||
val magic = head.getInt(0)
|
||||
val version = magic and 0xFF
|
||||
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
|
||||
var strings: List<String>? = null
|
||||
var pos = 8L
|
||||
if (version >= 0x29) {
|
||||
raf.readFully(head.array(), 8, 8)
|
||||
val tableAt = head.getLong(8)
|
||||
strings = readStrings(raf, tableAt)
|
||||
pos = 16L
|
||||
}
|
||||
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
|
||||
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (pos + 8 <= raf.length()) {
|
||||
raf.seek(pos)
|
||||
raf.readFully(entry.array(), 0, 8)
|
||||
val id = entry.getInt(0)
|
||||
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
|
||||
if (id == 0) return null
|
||||
if (id == appId) {
|
||||
if (size > 64L * 1024 * 1024) return null
|
||||
val body = ByteArray(size.toInt())
|
||||
raf.readFully(body)
|
||||
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val tree = readMap(kv, strings)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
|
||||
return infoOf(app)
|
||||
}
|
||||
pos += 8 + size
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun infoOf(app: Map<String, Any>): Info {
|
||||
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
|
||||
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
|
||||
val shared = depots.mapNotNull { (key, value) ->
|
||||
val depot = value as? Map<String, Any> ?: return@mapNotNull null
|
||||
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
|
||||
}
|
||||
return Info(name, shared)
|
||||
}
|
||||
|
||||
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
|
||||
raf.seek(at)
|
||||
val count = Integer.reverseBytes(raf.readInt())
|
||||
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
|
||||
raf.readFully(rest)
|
||||
val out = ArrayList<String>(count)
|
||||
var start = 0
|
||||
for (i in rest.indices) {
|
||||
if (out.size == count) break
|
||||
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun cstring(b: ByteBuffer): String {
|
||||
val start = b.position()
|
||||
while (b.get() != 0.toByte()) { }
|
||||
val bytes = ByteArray(b.position() - start - 1)
|
||||
b.position(start); b.get(bytes); b.get()
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
|
||||
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (b.hasRemaining()) {
|
||||
val type = b.get().toInt() and 0xFF
|
||||
if (type == 0x08) return out
|
||||
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
|
||||
when (type) {
|
||||
0x00 -> out[key] = readMap(b, strings)
|
||||
0x01 -> out[key] = cstring(b)
|
||||
0x02 -> out[key] = b.int.toString()
|
||||
0x03 -> out[key] = b.float.toString()
|
||||
0x07, 0x0A -> out[key] = b.long.toString()
|
||||
else -> return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
|
||||
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
|
||||
* so the two never disagree.
|
||||
*
|
||||
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
|
||||
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
|
||||
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
|
||||
* [Certainty.NONE]: neither; a miss is tried again after a week.
|
||||
*/
|
||||
object SteamMatch {
|
||||
enum class Certainty { FILES, NAME, NONE }
|
||||
|
||||
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
|
||||
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
|
||||
|
||||
private const val FILE = "steam-matches.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
|
||||
|
||||
@Synchronized
|
||||
private fun all(context: Context): JSONObject =
|
||||
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
|
||||
Match(
|
||||
o.optInt("appId", 0).takeIf { it > 0 },
|
||||
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
|
||||
o.optString("source"), o.optLong("at"),
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(context: Context, key: String, match: Match) {
|
||||
val json = all(context).put(
|
||||
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
|
||||
.apply { match.appId?.let { put("appId", it) } },
|
||||
)
|
||||
val atomic = store(context)
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
|
||||
* first, always read again; else the one kept (a name match stays, a miss for a week); else
|
||||
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
|
||||
* Blocking ([search] asks the network).
|
||||
*/
|
||||
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
|
||||
val now = System.currentTimeMillis()
|
||||
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
identity?.appId?.let { id ->
|
||||
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
|
||||
}
|
||||
val known = get(context, key)
|
||||
if (known != null && known.certainty == Certainty.NAME) return known
|
||||
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
|
||||
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
|
||||
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
|
||||
}
|
||||
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
|
||||
val id = runCatching { search(name) }.getOrNull()
|
||||
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
|
||||
put(context, key, match)
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
|
||||
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
|
||||
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
|
||||
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
|
||||
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
|
||||
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
|
||||
*/
|
||||
object SteamPics {
|
||||
private const val TAG = "SteamPics"
|
||||
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
|
||||
/** At most this many apps per request. */
|
||||
const val BATCH = 25
|
||||
private const val PROTO = 0x80000000.toInt()
|
||||
private const val EMSG_MULTI = 1
|
||||
private const val EMSG_LOGON = 5514
|
||||
private const val EMSG_LOGON_RESPONSE = 751
|
||||
private const val EMSG_LOGOFF = 706
|
||||
private const val EMSG_TOKENS_REQUEST = 8905
|
||||
private const val EMSG_TOKENS_RESPONSE = 8906
|
||||
private const val EMSG_INFO_REQUEST = 8903
|
||||
private const val EMSG_INFO_RESPONSE = 8904
|
||||
private const val PROTOCOL_VERSION = 65580
|
||||
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
|
||||
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
|
||||
|
||||
/**
|
||||
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
|
||||
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
|
||||
*/
|
||||
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
|
||||
if (appIds.isEmpty()) return emptyMap()
|
||||
val host = runCatching { cmHost() }.getOrNull() ?: return null
|
||||
return try {
|
||||
Wss(host, 20_000).use { ws ->
|
||||
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
|
||||
var steamId = 0L
|
||||
var session = 0
|
||||
// The logon's answer (other messages before it are passed over).
|
||||
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
|
||||
val h = fields(hdr); val b = fields(body)
|
||||
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
|
||||
steamId = h[1]?.firstOrNull() as? Long ?: 0L
|
||||
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
|
||||
break@loop
|
||||
}
|
||||
val out = HashMap<Int, SteamAppInfo.Info?>()
|
||||
for (batch in batches(appIds)) {
|
||||
val tokens = HashMap<Int, Long>()
|
||||
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
|
||||
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
|
||||
for (t in fields(body)[3].orEmpty()) {
|
||||
val f = fields(t as ByteArray)
|
||||
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
|
||||
}
|
||||
break@tokens
|
||||
}
|
||||
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
|
||||
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
|
||||
varint(3, 0)
|
||||
}.bytes()))
|
||||
var pending = true
|
||||
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
|
||||
val r = parseInfoResponse(body)
|
||||
out.putAll(r.apps)
|
||||
r.unknown.forEach { out[it] = null }
|
||||
pending = r.pending
|
||||
}
|
||||
batch.forEach { if (it !in out) out[it] = null }
|
||||
}
|
||||
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
|
||||
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** [appIds] in requests of at most [BATCH], each once. */
|
||||
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
|
||||
|
||||
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
|
||||
private fun cmHost(): String? {
|
||||
val c = URL(CM_LIST).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000; c.readTimeout = 10_000
|
||||
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
|
||||
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
|
||||
}
|
||||
|
||||
// ---- the product info answer ----
|
||||
|
||||
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
|
||||
|
||||
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
|
||||
@VisibleForTesting
|
||||
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
|
||||
val f = fields(body)
|
||||
val apps = HashMap<Int, SteamAppInfo.Info>()
|
||||
for (a in f[1].orEmpty()) {
|
||||
val app = fields(a as ByteArray)
|
||||
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
|
||||
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
|
||||
infoFromKeyValues(text)?.let { apps[id] = it }
|
||||
}
|
||||
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
|
||||
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
|
||||
}
|
||||
|
||||
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
|
||||
@VisibleForTesting
|
||||
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
|
||||
// The buffer ends in a NUL, kept or not by whoever handed it over.
|
||||
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = tree["appinfo"] as? Map<String, Any> ?: return null
|
||||
return SteamAppInfo.infoOf(app)
|
||||
}
|
||||
|
||||
/** Valve's text KeyValues: quoted keys and values, nested braces. */
|
||||
private class KeyValues(private val s: String) {
|
||||
private var i = 0
|
||||
fun read(): Map<String, Any> = map(top = true)
|
||||
private fun map(top: Boolean): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (true) {
|
||||
skip()
|
||||
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
|
||||
if (s[i] == '}') { i++; return out }
|
||||
val key = token()
|
||||
skip()
|
||||
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
|
||||
}
|
||||
}
|
||||
private fun skip() {
|
||||
while (i < s.length) {
|
||||
if (s[i].isWhitespace() || s[i] == '\u0000') i++
|
||||
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
|
||||
else break
|
||||
}
|
||||
}
|
||||
private fun token(): String {
|
||||
if (i < s.length && s[i] == '"') {
|
||||
i++
|
||||
val b = StringBuilder()
|
||||
while (i < s.length && s[i] != '"') {
|
||||
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
return b.toString()
|
||||
}
|
||||
val start = i
|
||||
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
|
||||
if (start == i) throw IOException("token expected at $i")
|
||||
return s.substring(start, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
|
||||
|
||||
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
|
||||
|
||||
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
|
||||
@VisibleForTesting
|
||||
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
|
||||
if (data.size < 8) return emptyList()
|
||||
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val raw = b.int
|
||||
if (raw and PROTO == 0) return emptyList()
|
||||
val emsg = raw and PROTO.inv()
|
||||
val hl = b.int
|
||||
if (hl < 0 || 8 + hl > data.size) return emptyList()
|
||||
val header = data.copyOfRange(8, 8 + hl)
|
||||
val body = data.copyOfRange(8 + hl, data.size)
|
||||
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
|
||||
val f = fields(body)
|
||||
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
|
||||
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
|
||||
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
|
||||
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (ib.remaining() >= 4) {
|
||||
val len = ib.int
|
||||
if (len < 0 || len > ib.remaining()) break
|
||||
val one = ByteArray(len).also { ib.get(it) }
|
||||
out.addAll(messages(one))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- protobuf, the little needed ----
|
||||
|
||||
/** Writes protobuf fields. */
|
||||
@VisibleForTesting
|
||||
internal class Proto {
|
||||
private val out = ByteArrayOutputStream()
|
||||
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
|
||||
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
|
||||
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
|
||||
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
|
||||
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
|
||||
fun bytes(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
|
||||
@VisibleForTesting
|
||||
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
|
||||
val out = HashMap<Int, MutableList<Any>>()
|
||||
var i = 0
|
||||
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
|
||||
while (i < b.size) {
|
||||
val key = varint()
|
||||
val field = (key ushr 3).toInt()
|
||||
val value: Any = when ((key and 7).toInt()) {
|
||||
0 -> varint()
|
||||
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
|
||||
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
|
||||
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
|
||||
else -> throw IOException("wire type ${key and 7}")
|
||||
}
|
||||
out.getOrPut(field) { ArrayList() }.add(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun packed(b: ByteArray): List<Int> {
|
||||
val out = ArrayList<Int>(); var i = 0
|
||||
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- WebSocket over TLS, binary frames ----
|
||||
|
||||
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
|
||||
// Made with the host name, so it goes out as SNI.
|
||||
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
// Host name checked against the certificate, as HTTPS does.
|
||||
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
|
||||
socket.startHandshake()
|
||||
out = socket.outputStream
|
||||
input = DataInputStream(socket.inputStream)
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = line(input) ?: throw IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
|
||||
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun line(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(payload: ByteArray) {
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x82)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next binary message (fragments joined); null on close. */
|
||||
fun receive(): ByteArray? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
|
||||
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.epic.EpicApiClient
|
||||
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
import com.droiddeck.launcher.stores.gog.GogDownloadManager
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
|
||||
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
|
||||
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
|
||||
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
|
||||
* sign-in or a network it is skipped quietly and tried again a week later.
|
||||
*/
|
||||
object StoreListBackfill {
|
||||
private const val TAG = "StoreListBackfill"
|
||||
private const val FILE = "store-list-backfill.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
|
||||
fun interface Fetcher {
|
||||
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
|
||||
}
|
||||
|
||||
/** The store itself, with the saved sign-in. */
|
||||
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
|
||||
|
||||
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
|
||||
|
||||
/** Whether [sidecar] was written before the list was kept. */
|
||||
/**
|
||||
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
|
||||
* that could have written an empty one for a manifest it did not read right.
|
||||
*/
|
||||
fun needs(sidecar: StoreGameSidecar): Boolean =
|
||||
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
|
||||
|
||||
/**
|
||||
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
|
||||
* tried in the past week. True when the sidecar changed. Blocking (network).
|
||||
*/
|
||||
@Synchronized
|
||||
fun fill(context: Context, folder: File): Boolean {
|
||||
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
|
||||
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
|
||||
// Offline is no try: it waits for a network rather than a week.
|
||||
if (!AutoComponents.online(context)) return false
|
||||
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
|
||||
if (list == null) {
|
||||
tries.put(folder.path, now)
|
||||
val atomic = AtomicFile(File(context.filesDir, FILE))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
|
||||
return false
|
||||
}
|
||||
// Read fresh and written whole, as an install writes it: only the list is added.
|
||||
val current = StoreGameSidecar.read(folder) ?: return false
|
||||
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
|
||||
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
|
||||
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
|
||||
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
|
||||
return when (sidecar.store) {
|
||||
Store.GOG -> {
|
||||
val token = GogAuth.validToken(context) ?: return null
|
||||
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
|
||||
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
|
||||
val namespace = sidecar.extra["namespace"].orEmpty()
|
||||
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
|
||||
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
|
||||
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Which Windows components a game needs, from the list its source keeps - the source badge decides
|
||||
* which list is trusted:
|
||||
*
|
||||
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
|
||||
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
|
||||
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
|
||||
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
|
||||
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
|
||||
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
|
||||
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
|
||||
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
|
||||
* ([DependencyDetector]) as Recommended.
|
||||
*
|
||||
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
|
||||
* suggestion under its own name. The folder scan is offered as Recommended for every game.
|
||||
*/
|
||||
object WinCompSources {
|
||||
/** Whose list a finding came from; the reason line says it. */
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
|
||||
|
||||
/**
|
||||
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
|
||||
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
|
||||
* the reason line names (Steam: the label, the game, the appid).
|
||||
*/
|
||||
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||
|
||||
// ---- mapping tables: a store's names -> catalog components ----
|
||||
|
||||
private val VC_YEAR = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
|
||||
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
private val DOTNET = mapOf(
|
||||
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
|
||||
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
|
||||
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
|
||||
)
|
||||
|
||||
/**
|
||||
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
|
||||
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
|
||||
*/
|
||||
fun gog(id: String): List<String> {
|
||||
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
|
||||
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
|
||||
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
|
||||
return when {
|
||||
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
|
||||
key.startsWith("PHYSX") -> listOf("physx")
|
||||
key.startsWith("OPENAL") -> listOf("oalinst")
|
||||
key == "XNA31" || key == "XNA3" -> listOf("xna31")
|
||||
key.startsWith("XNA") -> listOf("xna40")
|
||||
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
|
||||
key.startsWith("XACT") -> listOf("xact")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
|
||||
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
|
||||
|
||||
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
|
||||
fun epic(path: String, name: String): List<String> = when {
|
||||
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
|
||||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
|
||||
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
|
||||
}
|
||||
|
||||
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
|
||||
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
|
||||
|
||||
/** Steamworks Shared depot ids to (component, Steam's label). */
|
||||
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
|
||||
|
||||
// ---- what each store left at install ----
|
||||
|
||||
/** Sidecar keys the store installs fill. */
|
||||
const val GOG_DEPENDENCIES = "dependencies"
|
||||
const val EPIC_PREREQ_PATH = "prereqPath"
|
||||
const val EPIC_PREREQ_NAME = "prereqName"
|
||||
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
|
||||
const val LIST_READ = "listRead"
|
||||
|
||||
/** fuel.json's PostInstall commands. */
|
||||
fun amazonPostInstall(folder: File): List<String> = runCatching {
|
||||
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
|
||||
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
// ---- per game ----
|
||||
|
||||
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
|
||||
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
|
||||
val local = SteamRedists.detect(gameDir, appId)
|
||||
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
|
||||
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
|
||||
return pairs.map { (component, label) ->
|
||||
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything known for [game], blocking (files; the client's app info; no network). */
|
||||
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
|
||||
val folder = game.gameFiles
|
||||
val out = ArrayList<Finding>()
|
||||
val store = Store.byId(game.source)
|
||||
val sidecar = folder?.let { StoreGameSidecar.read(it) }
|
||||
when {
|
||||
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
|
||||
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
|
||||
val parts = gog(id)
|
||||
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
|
||||
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
|
||||
}
|
||||
store == Store.EPIC -> {
|
||||
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
|
||||
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
|
||||
if (path.isNotEmpty() || name.isNotEmpty()) {
|
||||
val parts = epic(path, name)
|
||||
val original = name.ifEmpty { path.substringAfterLast('/') }
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
|
||||
}
|
||||
}
|
||||
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
|
||||
val parts = installer(command)
|
||||
val original = command.replace('\\', '/').substringAfterLast('/')
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
|
||||
// on by itself when its files say so, else only recommended.
|
||||
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
||||
val id = match.appId ?: return@let
|
||||
when (match.certainty) {
|
||||
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
|
||||
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// The folder scan, for every game: what it bundles, as Recommended.
|
||||
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
|
||||
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ object Lossless {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class ControllerEditorActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
|
||||
immersive()
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(0xFF0B0F14.toInt())
|
||||
|
||||
@@ -46,6 +46,7 @@ object ControllerPrefs {
|
||||
val steamButton: Boolean,
|
||||
val qamButton: Boolean,
|
||||
val keyboardButton: Boolean,
|
||||
val selectSteam: Boolean,
|
||||
val snap: Boolean,
|
||||
val customLayout: Boolean,
|
||||
val mapping: Map<String, String>,
|
||||
@@ -65,6 +66,7 @@ object ControllerPrefs {
|
||||
steamButton = p.getBoolean("steamButton", true),
|
||||
qamButton = p.getBoolean("qamButton", true),
|
||||
keyboardButton = p.getBoolean("keyboardButton", true),
|
||||
selectSteam = p.getBoolean("selectSteam", false),
|
||||
snap = p.getBoolean("snap", true),
|
||||
customLayout = p.all.keys.any { it.startsWith("layout.") },
|
||||
mapping = mappable.associate { (id, _) -> id to target(context, id) },
|
||||
@@ -121,6 +123,11 @@ object ControllerPrefs {
|
||||
prefs(context).edit().putBoolean("keyboardButton", on).apply()
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select. */
|
||||
fun setSelectSteam(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("selectSteam", on).apply()
|
||||
}
|
||||
|
||||
fun setSnap(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("snap", on).apply()
|
||||
}
|
||||
|
||||
@@ -94,18 +94,52 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
|
||||
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
|
||||
// native BLE reports and output reports, so the Steam client can see it as its own device.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
|
||||
private static final int IMU_MODE_OFFSET = IMU_OFFSET + 4;
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
// Slot 0 only: what the pad is to the Steam client (libfakeinput's PAD_TYPE_*), | PAD_TYPE_SET.
|
||||
private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44;
|
||||
private static final int PAD_TYPE_SET = 0x80000000;
|
||||
public static final int PAD_TYPE_DECK = 0;
|
||||
public static final int PAD_TYPE_STEAM = 1;
|
||||
public static final int PAD_TYPE_XBOX = 2;
|
||||
public static final int PAD_TYPE_DS4 = 3;
|
||||
public static final int PAD_TYPE_DUALSENSE = 4;
|
||||
/** The type chosen this process, written into a ring made after the choice too; -1 for none. */
|
||||
private static volatile int padType = -1;
|
||||
/** The paired controller's state, likewise written into a ring made after it connected. */
|
||||
private static volatile boolean tritonConnected = false;
|
||||
private static final int TRITON_OFFSET = RING_SIZE + 64;
|
||||
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
|
||||
// Whether the paired controller is connected: libfakeinput offers its device only then.
|
||||
private static final int TRITON_CONNECTED_OFFSET = TRITON_OFFSET + 4;
|
||||
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
|
||||
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
|
||||
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
|
||||
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
|
||||
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
|
||||
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
|
||||
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
|
||||
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
|
||||
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
|
||||
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
|
||||
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
|
||||
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
|
||||
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
|
||||
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
|
||||
private static final int TRITON_REPORT_MAX = 64;
|
||||
private static final int TRITON_BLOCK_SIZE = 256;
|
||||
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -215,11 +249,24 @@ public class FakeInputWriter {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putInt(IMU_MODE_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
int type = padType;
|
||||
data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0);
|
||||
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
|
||||
data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0);
|
||||
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
|
||||
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
|
||||
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
|
||||
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
|
||||
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
|
||||
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
@@ -476,6 +523,23 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The motion the Steam client asked the Deck controller of slot {@code slot} for: its
|
||||
* SETTING_IMU_MODE (0 = off), or -1 until it has said. Safe from any thread.
|
||||
*/
|
||||
public static int readImuMode(int slot) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return -1;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
int value = ring == null ? 0 : ring.getInt(IMU_MODE_OFFSET);
|
||||
return value < 0 ? value & 0xffff : -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
@@ -502,6 +566,121 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches what the pad is to the Steam client while the session runs ([PAD_TYPE_DECK],
|
||||
* [PAD_TYPE_STEAM] or [PAD_TYPE_XBOX]); libfakeinput unplugs the old device and plugs in the new.
|
||||
*/
|
||||
public static void setPadType(int type) {
|
||||
padType = type;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = RING_SLOTS[0];
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(PAD_TYPE_OFFSET, PAD_TYPE_SET | type);
|
||||
}
|
||||
}
|
||||
|
||||
/** The paired Steam Controller came or went (SteamControllerBle): its device is plugged in or out. */
|
||||
public static void setTritonConnected(int slot, boolean connected) {
|
||||
tritonConnected = connected;
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_CONNECTED_OFFSET, connected ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes a native Triton input report (without the HID report ID byte). */
|
||||
public static void writeTritonState(int slot, int reportId, byte[] report) {
|
||||
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0) seq++;
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
|
||||
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
|
||||
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
|
||||
}
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
|
||||
public static byte[] readTritonOutput(int slot, long lastSequence) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return null;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
|
||||
nativeStoreFence();
|
||||
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
|
||||
if ((seq & 1) != 0 || seq <= lastSequence) return null;
|
||||
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
|
||||
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
|
||||
byte[] output = new byte[9 + length];
|
||||
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
|
||||
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
|
||||
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
|
||||
nativeStoreFence();
|
||||
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Steam client's newest unanswered feature read, or 0: its request number. */
|
||||
public static long readTritonFeatureRequest(int slot) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return 0L;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
|
||||
nativeStoreFence();
|
||||
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
|
||||
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
|
||||
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
|
||||
RingSlot ringSlot = tritonSlot(slot);
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
|
||||
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
|
||||
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
|
||||
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
|
||||
nativeStoreFence();
|
||||
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
|
||||
}
|
||||
}
|
||||
|
||||
private static RingSlot tritonSlot(int slot) {
|
||||
synchronized (RING_LOCK) {
|
||||
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
|
||||
@@ -29,13 +29,27 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*
|
||||
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
|
||||
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
|
||||
* it, and played as the handheld while its built-in pad became player 2.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
/** Player slots: the session prepares a ring for each (SessionService). */
|
||||
public static final int SLOTS = 4;
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
private static final int[] JOYSTICK_AXES = {
|
||||
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
|
||||
};
|
||||
private static final int[] CONTROLLER_BUTTONS = {
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BUTTON_X,
|
||||
KeyEvent.KEYCODE_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_BUTTON_R1,
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_START,
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBL, KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||
KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2
|
||||
};
|
||||
// The client takes A as part of the chord only once it has had Guide held for a while, and a
|
||||
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
|
||||
// had focused before opening QAM: with 80 ms of lead that was 3 times in 20 at rest, and 250 ms
|
||||
@@ -65,6 +79,7 @@ public final class PadBridge {
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private volatile boolean selectSteam;
|
||||
private boolean systemGuidePressed;
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
@@ -88,6 +103,17 @@ public final class PadBridge {
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
|
||||
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
|
||||
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
|
||||
private static final int AYN_VENDOR = 0x2020;
|
||||
private static final int AYN_BUILT_IN_PAD = 0x0112;
|
||||
|
||||
/** Whether this controller is the device's own controls rather than a pad connected to it. */
|
||||
static boolean isBuiltIn(InputDevice device) {
|
||||
return !device.isExternal()
|
||||
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
|
||||
}
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove any slot, or
|
||||
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
|
||||
@@ -113,6 +139,11 @@ public final class PadBridge {
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
/** The physical Select button opens the Steam menu instead of View/Select (Setup → Controller). */
|
||||
public void setSelectSteam(boolean on) {
|
||||
selectSteam = on;
|
||||
}
|
||||
|
||||
/** Opens the ring; safe to call more than once. */
|
||||
public synchronized boolean start() {
|
||||
if (!open) {
|
||||
@@ -154,8 +185,58 @@ public final class PadBridge {
|
||||
// counting it would hide the on-screen controls with nothing to replace them.
|
||||
if (device.isVirtual()) return false;
|
||||
int sources = device.getSources();
|
||||
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|
||||
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
|
||||
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
|
||||
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
|
||||
if (device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC) return true;
|
||||
// Some composite keyboards advertise joystick input without any sticks or pad buttons.
|
||||
// A keyboard's mouse ranges do not establish a joystick; a real hybrid's joystick ranges
|
||||
// or physical controller buttons do. GAMEPAD above remains authoritative even with keys.
|
||||
for (int axis : JOYSTICK_AXES) {
|
||||
InputDevice.MotionRange range = device.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
|
||||
if (range != null && range.getMax() > range.getMin()) return true;
|
||||
}
|
||||
for (boolean present : device.hasKeys(CONTROLLER_BUTTONS)) {
|
||||
if (present) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether this key belongs to the pad, rather than a keyboard on the same input device. */
|
||||
public static boolean isControllerKeyEvent(KeyEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
if (device == null || device.isVirtual()) return false;
|
||||
// An explicit pad button is unambiguous even when a hybrid omits its capabilities.
|
||||
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
|
||||
}
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_MENU:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
if (!isFromController(device)) return false;
|
||||
// Without GAMEPAD, an alphabetic hybrid's keyboard arrows remain keyboard keys.
|
||||
// Its ambiguous navigation keys need explicit pad/joystick event provenance;
|
||||
// DPAD alone also describes ordinary keyboard arrows.
|
||||
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|
||||
|| event.isFromSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| event.isFromSource(InputDevice.SOURCE_JOYSTICK);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mouse motion on a composite controller still belongs to the pointer, not its sticks. */
|
||||
public static boolean isControllerMotionEvent(MotionEvent event) {
|
||||
InputDevice device = event.getDevice();
|
||||
// An actual joystick event, like a pad button, survives a hybrid's missing capabilities.
|
||||
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
|
||||
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|
||||
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
|
||||
}
|
||||
|
||||
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
|
||||
@@ -209,10 +290,9 @@ public final class PadBridge {
|
||||
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isControllerKey(event)) return false;
|
||||
if (!isControllerKeyEvent(event)) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
|
||||
@@ -222,6 +302,11 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
// Optionally (Setup → Controller) the physical Select button opens the Steam menu
|
||||
// instead of View/Select: on some handhelds it sits next to the Steam button.
|
||||
if (selectSteam) pad.press(PadState.GUIDE, pressed);
|
||||
else pad.press(6, pressed);
|
||||
break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
@@ -239,6 +324,7 @@ public final class PadBridge {
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
noteDevice(event.getDevice(), slot);
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
@@ -248,7 +334,7 @@ public final class PadBridge {
|
||||
|
||||
/** @return true when the event was a pad's sticks/triggers and has been consumed. */
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (!isControllerMotionEvent(event)) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
@@ -296,6 +382,23 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/**
|
||||
* A controller the app reads itself rather than through Android's input (the Steam Controller
|
||||
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
|
||||
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
|
||||
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
|
||||
*/
|
||||
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
|
||||
mutation.accept(state);
|
||||
if (changed) {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
}
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
|
||||
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
|
||||
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
|
||||
@@ -321,6 +424,19 @@ public final class PadBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every connected controller its slot now, so the client lists each one as it is
|
||||
* connected instead of on its first press: a further player's pad is plugged in, at rest.
|
||||
*/
|
||||
public synchronized void claimConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (!isFromController(device)) continue;
|
||||
int slot = slotFor(device);
|
||||
if (slot > 0 && playerWriters[slot] == null) publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
|
||||
public synchronized void onDeviceRemoved(int deviceId) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
@@ -381,23 +497,55 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
|
||||
/**
|
||||
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
|
||||
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
|
||||
* of its own), else player 1's.
|
||||
*/
|
||||
private int slotFor(InputDevice device) {
|
||||
int id = device.getId();
|
||||
if (owners[0] == id) return 0;
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == id) return slot;
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (isBuiltIn(device)) {
|
||||
// A connected pad playing as the device's own controls moves to a player's slot of its own.
|
||||
int was = owners[0];
|
||||
if (was != NO_CONTROLLER) {
|
||||
free(0);
|
||||
InputDevice other = InputDevice.getDevice(was);
|
||||
if (other != null && !isBuiltIn(other)) {
|
||||
int to = claim(other, 1);
|
||||
if (to > 0) publish(to);
|
||||
}
|
||||
}
|
||||
owners[0] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
|
||||
return 0;
|
||||
}
|
||||
int slot = claim(device, builtInConnected() ? 1 : 0);
|
||||
return slot >= 0 ? slot : 0;
|
||||
}
|
||||
|
||||
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
|
||||
private int claim(InputDevice device, int from) {
|
||||
for (int slot = from; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER) continue;
|
||||
owners[slot] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
|
||||
owners[slot] = device.getId();
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
|
||||
return slot;
|
||||
}
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean builtInConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (isFromController(device) && isBuiltIn(device)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PadState stateFor(int slot) {
|
||||
|
||||
@@ -20,9 +20,10 @@ import kotlin.math.roundToInt
|
||||
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
|
||||
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
|
||||
*
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
|
||||
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
|
||||
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, and the client
|
||||
* wants motion (see [follow]), sampled as often as the Deck reports (4 ms); each reading goes
|
||||
* straight into the pad's ring ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report
|
||||
* picks up the latest.
|
||||
*/
|
||||
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(SensorManager::class.java)
|
||||
@@ -32,6 +33,11 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
// Touched only on the worker thread.
|
||||
private val accel = ShortArray(3)
|
||||
private val gyro = ShortArray(3)
|
||||
private var reading = false
|
||||
private var idlePolls = 0
|
||||
// Asking the display costs more than the rest of a reading, which arrives every 2 ms.
|
||||
private var turn = Surface.ROTATION_0
|
||||
private var turnAt = 0L
|
||||
|
||||
fun start() {
|
||||
if (thread != null || sensors == null) return
|
||||
@@ -45,8 +51,13 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
thread = worker
|
||||
val handler = Handler(worker.looper)
|
||||
active = true
|
||||
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (!active) return
|
||||
follow(handler, gyroscope, accelerometer)
|
||||
handler.postDelayed(this, POLL_MS)
|
||||
}
|
||||
})
|
||||
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
|
||||
}
|
||||
|
||||
@@ -54,31 +65,71 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
val worker = thread ?: return
|
||||
thread = null
|
||||
active = false
|
||||
sensors?.unregisterListener(this)
|
||||
// A still controller, not one frozen mid-turn - published on the worker, after any reading
|
||||
// already queued there, so nothing can overwrite it.
|
||||
// On the worker, after any poll or reading already queued there, so nothing registers again
|
||||
// or overwrites the still controller.
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
sensors?.unregisterListener(this)
|
||||
reading = false
|
||||
idlePolls = 0
|
||||
still()
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the sensors while the client wants motion: the Deck's IMU mode (unknown counts as
|
||||
* wanted), or Steam's touch controls asking for gyro. A game whose layout has no gyro turns the
|
||||
* mode off; Steam's own menus keep it on. The client also turns it off and on again for a few
|
||||
* ms whenever it re-applies a layout, so the sensors stop only after two polls without it.
|
||||
*/
|
||||
private fun follow(handler: Handler, gyroscope: Sensor?, accelerometer: Sensor?) {
|
||||
val touch = SteamTouchDevice.current
|
||||
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
|
||||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
|
||||
idlePolls = if (wanted) 0 else idlePolls + 1
|
||||
if (wanted && !reading) {
|
||||
reading = true
|
||||
turnAt = 0L
|
||||
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: reading")
|
||||
} else if (reading && idlePolls >= 2) {
|
||||
reading = false
|
||||
sensors?.unregisterListener(this)
|
||||
still()
|
||||
Log.i(TAG, "pad motion: paused, the client wants none")
|
||||
}
|
||||
}
|
||||
|
||||
/** A still controller, not one frozen mid-turn. */
|
||||
private fun still() {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (!active) return
|
||||
val values = event.values
|
||||
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
|
||||
turn = rotation()
|
||||
turnAt = event.timestamp
|
||||
}
|
||||
// Natural orientation to the screen's: Android's display-rotation transform.
|
||||
val (x, y) = when (rotation()) {
|
||||
Surface.ROTATION_90 -> -values[1] to values[0]
|
||||
Surface.ROTATION_180 -> -values[0] to -values[1]
|
||||
Surface.ROTATION_270 -> values[1] to -values[0]
|
||||
else -> values[0] to values[1]
|
||||
val x: Float
|
||||
val y: Float
|
||||
when (turn) {
|
||||
Surface.ROTATION_90 -> { x = -values[1]; y = values[0] }
|
||||
Surface.ROTATION_180 -> { x = -values[0]; y = -values[1] }
|
||||
Surface.ROTATION_270 -> { x = values[1]; y = -values[0] }
|
||||
else -> { x = values[0]; y = values[1] }
|
||||
}
|
||||
val z = values[2]
|
||||
val (target, scale) = when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
|
||||
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
|
||||
val target: ShortArray
|
||||
val scale: Float
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> { target = gyro; scale = GYRO_COUNTS_PER_RAD_S }
|
||||
Sensor.TYPE_ACCELEROMETER -> { target = accel; scale = ACCEL_COUNTS_PER_M_S2 }
|
||||
else -> return
|
||||
}
|
||||
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
|
||||
@@ -99,6 +150,9 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
private const val SLOT = 0
|
||||
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
|
||||
private const val SAMPLING_US = 4000
|
||||
/** How often [follow] checks whether the client wants motion. */
|
||||
private const val POLL_MS = 500L
|
||||
private const val ROTATION_REFRESH_NS = 250_000_000L
|
||||
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
|
||||
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
|
||||
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
|
||||
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
|
||||
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
|
||||
*
|
||||
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
|
||||
*/
|
||||
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
|
||||
class SteamControllerBle(private val context: Context) {
|
||||
private val thread = HandlerThread("steam-controller-ble").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
private val outputReports = mutableMapOf<Int, UUID>()
|
||||
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
|
||||
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var running = false
|
||||
private var input: UUID? = null
|
||||
private var reportId = 0
|
||||
private var lizardOffSent = false
|
||||
private var reportedNone = false
|
||||
private var discoveries = 0
|
||||
private var outputSequence = 0L
|
||||
private var outputWritePending = false
|
||||
/** The Steam client's feature read being answered, so the poll queues each one once. */
|
||||
private var featureRead = 0L
|
||||
|
||||
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
|
||||
fun start() = handler.post {
|
||||
if (running) return@post
|
||||
running = true
|
||||
Log.i(TAG, "looking for a paired Steam Controller")
|
||||
connect()
|
||||
}
|
||||
|
||||
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
|
||||
fun stop() = handler.post {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
disconnect("session ended")
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (!running || gatt != null) return
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrNull()
|
||||
val device = bonded?.firstOrNull(::isSteamController)
|
||||
if (device == null) {
|
||||
if (!reportedNone) {
|
||||
reportedNone = true
|
||||
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
|
||||
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
|
||||
}
|
||||
handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "connecting to \"${device.name}\"")
|
||||
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
|
||||
}
|
||||
|
||||
private fun disconnect(why: String) {
|
||||
val g = gatt ?: return
|
||||
gatt = null
|
||||
discoveries = 0
|
||||
input = null
|
||||
lizardOffSent = false
|
||||
outputWritePending = false
|
||||
featureRead = 0L
|
||||
queuedWrites.clear()
|
||||
outputReports.clear()
|
||||
connected = false
|
||||
FakeInputWriter.setTritonConnected(SLOT, false)
|
||||
g.disconnect()
|
||||
g.close()
|
||||
release()
|
||||
Log.i(TAG, "disconnected: $why")
|
||||
listener?.invoke()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (g !== gatt) return
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
g.discoverServices()
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
disconnect("link lost (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
disconnect("service discovery failed (status $status)")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
return
|
||||
}
|
||||
// The Triton needs data length extensions, which Android enables for a large MTU.
|
||||
if (!g.requestMtu(517)) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
|
||||
if (g === gatt) subscribe(g)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (g !== gatt || descriptor.characteristic.uuid != input) return
|
||||
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
|
||||
connected = status == BluetoothGatt.GATT_SUCCESS
|
||||
FakeInputWriter.setTritonConnected(SLOT, connected)
|
||||
listener?.invoke()
|
||||
if (connected) {
|
||||
refreshLizardMode()
|
||||
pollOutput()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (g !== gatt) return
|
||||
if (characteristic.uuid == REPORT && !lizardOffSent) {
|
||||
lizardOffSent = true
|
||||
Log.i(TAG, "lizard mode off (status $status)")
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
|
||||
}
|
||||
outputWritePending = false
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
|
||||
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
|
||||
}
|
||||
|
||||
// Android 13 hands the value over with the call; older ones only through the characteristic.
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
if (g === gatt && characteristic.uuid == input) onState(value)
|
||||
}
|
||||
|
||||
@Deprecated("Below Android 13 only", ReplaceWith(""))
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
@Suppress("DEPRECATION")
|
||||
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribe(g: BluetoothGatt) {
|
||||
val service = g.getService(SERVICE)
|
||||
if (service == null) {
|
||||
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
|
||||
g.services.joinToString { it.uuid.toString().take(8) })
|
||||
// Android may answer from the service list its HID host cached, which can lack the
|
||||
// vendor service: ask the controller again before giving up on it.
|
||||
if (++discoveries < 3) {
|
||||
refreshCache(g)
|
||||
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
|
||||
} else {
|
||||
disconnect("no Valve service")
|
||||
if (running) handler.postDelayed(::connect, RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
|
||||
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
|
||||
if (chr == null) {
|
||||
disconnect("no state report characteristic")
|
||||
return
|
||||
}
|
||||
outputReports.clear()
|
||||
service.characteristics.forEach { characteristic ->
|
||||
val uuid = characteristic.uuid.toString().lowercase()
|
||||
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
|
||||
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
|
||||
val outputId = characteristicId - OUTPUT_UUID_OFFSET
|
||||
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
|
||||
input = chr.uuid
|
||||
g.setCharacteristicNotification(chr, true)
|
||||
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
|
||||
@Suppress("DEPRECATION")
|
||||
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
@Suppress("DEPRECATION")
|
||||
g.writeDescriptor(cccd)
|
||||
}
|
||||
|
||||
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
|
||||
private fun refreshCache(g: BluetoothGatt) {
|
||||
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
|
||||
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
|
||||
}
|
||||
|
||||
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
|
||||
private fun refreshLizardMode() {
|
||||
if (!connected) return
|
||||
val msg = ByteArray(FEATURE_BYTES)
|
||||
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
|
||||
enqueueOutput(REPORT, msg)
|
||||
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
|
||||
}
|
||||
|
||||
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
|
||||
private fun onState(data: ByteArray) {
|
||||
if (data.size < STATE_BYTES) return
|
||||
FakeInputWriter.writeTritonState(SLOT, reportId, data)
|
||||
}
|
||||
|
||||
/** A neutral report prevents a link lost mid-press from leaving controls held. */
|
||||
private fun release() {
|
||||
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
|
||||
}
|
||||
|
||||
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
|
||||
private fun pollOutput() {
|
||||
if (!running || !connected || gatt == null) return
|
||||
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
|
||||
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
|
||||
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
|
||||
}
|
||||
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
|
||||
if (request != 0L && request != featureRead) {
|
||||
featureRead = request
|
||||
// Behind any queued write, so a read answers the feature report written before it.
|
||||
queuedWrites.addLast(REPORT to null)
|
||||
writeNextOutput()
|
||||
}
|
||||
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
|
||||
}
|
||||
|
||||
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
|
||||
private fun onFeatureRead(value: ByteArray?) {
|
||||
outputWritePending = false
|
||||
if (featureRead != 0L) {
|
||||
if (value == null) Log.w(TAG, "feature read failed")
|
||||
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
|
||||
}
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
|
||||
if (report.size < 2) return
|
||||
val characteristic = if (feature) {
|
||||
REPORT
|
||||
} else {
|
||||
val id = report[0].toInt() and 0xff
|
||||
outputReports[id] ?: run {
|
||||
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
|
||||
return
|
||||
}
|
||||
}
|
||||
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
|
||||
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
|
||||
}
|
||||
|
||||
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
|
||||
queuedWrites.addLast(characteristic to value)
|
||||
writeNextOutput()
|
||||
}
|
||||
|
||||
private fun writeNextOutput() {
|
||||
if (outputWritePending) return
|
||||
val g = gatt ?: return
|
||||
val (uuid, value) = queuedWrites.pollFirst() ?: return
|
||||
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
|
||||
if (characteristic == null) {
|
||||
Log.w(TAG, "BLE output characteristic $uuid disappeared")
|
||||
return
|
||||
}
|
||||
if (value == null) {
|
||||
if (g.readCharacteristic(characteristic)) {
|
||||
outputWritePending = true
|
||||
} else {
|
||||
queuedWrites.addFirst(uuid to null)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
@Suppress("DEPRECATION")
|
||||
characteristic.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
if (!g.writeCharacteristic(characteristic)) {
|
||||
queuedWrites.addFirst(uuid to value)
|
||||
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
|
||||
} else {
|
||||
outputWritePending = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamControllerBle"
|
||||
/** The paired controller's native Triton reports live in slot 0's extended ring. */
|
||||
private const val SLOT = 0
|
||||
private const val RETRY_MS = 5_000L
|
||||
private const val LIZARD_REFRESH_MS = 3_000L
|
||||
private const val OUTPUT_POLL_MS = 4L
|
||||
private const val OUTPUT_RETRY_MS = 10L
|
||||
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
|
||||
private const val FEATURE_BYTES = 62
|
||||
private const val STATE_BYTES = 45
|
||||
private const val OUTPUT_UUID_PREFIX = "100f6c"
|
||||
private const val OUTPUT_UUID_OFFSET = 0x35
|
||||
|
||||
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
|
||||
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
|
||||
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
|
||||
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private fun isSteamController(device: BluetoothDevice): Boolean =
|
||||
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
|
||||
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
|
||||
|
||||
/** Whether a session should expose a Triton HID node for a paired controller. */
|
||||
@JvmStatic
|
||||
fun hasPairedController(context: Context): Boolean {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
|
||||
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
|
||||
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** A Steam Controller is connected and feeding its native HID reports. */
|
||||
@Volatile @JvmStatic
|
||||
var connected = false
|
||||
private set
|
||||
|
||||
/** Notifies the activity when [connected] changes. */
|
||||
@Volatile @JvmStatic
|
||||
var listener: (() -> Unit)? = null
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,20 @@ object AppImageManager {
|
||||
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
|
||||
}
|
||||
|
||||
/** The desktop's import of [key]; older ones of the image at [path], since replaced, are removed. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
var found: String? = null
|
||||
root(context).listFiles()?.forEach { dir ->
|
||||
val asked = FileUtils.readString(File(dir, UserApps.DESKTOP_IMPORT))?.lines() ?: return@forEach
|
||||
if (asked.firstOrNull() == key) {
|
||||
if (File(dir, "app").isDirectory) found = dir.name
|
||||
} else if (asked.getOrNull(1) == path) {
|
||||
remove(context, dir.name)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
|
||||
FileUtils.delete(File(root(context), id))
|
||||
|
||||
@@ -20,12 +20,12 @@ import java.io.File
|
||||
*/
|
||||
object DesktopCatalog {
|
||||
private const val TAG = "DesktopCatalog"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/desktop.json"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/desktop.json"
|
||||
/**
|
||||
* Valve's Proton Experimental (ARM64) with its appmanifest, laid out as the client keeps it.
|
||||
* In a catalog of its own so it never shows among the packages a user picks from.
|
||||
*/
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/steam-seed.json"
|
||||
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
|
||||
const val PROTON_SEED_ID = "proton-arm64"
|
||||
|
||||
class Entry(
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class LinuxRuntimeInstaller {
|
||||
|
||||
/** Catalog row, beside the other component catalogs in winlator-contents. */
|
||||
public static final String CATALOG_URL =
|
||||
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
|
||||
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
|
||||
|
||||
private static final String VERSION_FILE = ".version";
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ object UserApps {
|
||||
private const val TAG = "UserApps"
|
||||
const val GUEST_DIR = "/opt/droiddeck-apps"
|
||||
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
|
||||
/** An app the desktop asked for (droiddeck-open): its key, then the path it was started from there. */
|
||||
const val DESKTOP_IMPORT = "desktop-import"
|
||||
private const val FLATPAK_OVERRIDES = ".flatpak"
|
||||
private const val ICON_SIZE = 256
|
||||
private const val ELF_SCAN_LIMIT = 4000
|
||||
@@ -72,8 +74,8 @@ object UserApps {
|
||||
class FlatpakBundle(val file: File) : Source()
|
||||
}
|
||||
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?)
|
||||
/** [icon]: a picked file's path or a suggested icon's URL. [meta]: files written into the added app's folder. */
|
||||
class Request(val source: Source, val name: String?, val icon: String?, val meta: Map<String, String> = emptyMap())
|
||||
|
||||
/** How a script's folder reaches the session: linked in place, or copied in ([bytes] to copy). */
|
||||
class ScriptPlan(val folder: File, val copy: Boolean, val bytes: Long)
|
||||
@@ -121,8 +123,8 @@ object UserApps {
|
||||
val name = request.name?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return withIcon(context, request.icon) { icon ->
|
||||
when (val s = request.source) {
|
||||
is Source.Script -> addScript(context, s.file, name, icon, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon) { onProgress(it, -1) }
|
||||
is Source.Script -> addScript(context, s.file, name, icon, request.meta, onProgress)
|
||||
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
|
||||
is Source.GitHub -> addFromGitHub(context, s.repo, name, icon, onProgress)
|
||||
is Source.Flatpak -> addFlatpak(context, s.id, name, icon, onProgress)
|
||||
is Source.FlatpakBundle -> addFlatpakBundle(context, s.file, name, icon, onProgress)
|
||||
@@ -197,6 +199,14 @@ object UserApps {
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
/** The desktop's import of [key] as the session sees it: an added script's folder, or an AppImage's. */
|
||||
fun desktopImport(context: Context, key: String, path: String): String? {
|
||||
root(context).listFiles()?.firstOrNull { dir ->
|
||||
File(dir, "entry").isFile && FileUtils.readString(File(dir, DESKTOP_IMPORT))?.lineSequence()?.firstOrNull() == key
|
||||
}?.let { return "$GUEST_DIR/${it.name}" }
|
||||
return AppImageManager.desktopImport(context, key, path)?.let { "${AppImageManager.GUEST_DIR}/$it" }
|
||||
}
|
||||
|
||||
fun planScript(script: File): ScriptPlan {
|
||||
val folder = script.canonicalFile.parentFile ?: script.parentFile!!
|
||||
var scanned = 0
|
||||
@@ -209,7 +219,9 @@ object UserApps {
|
||||
RandomAccessFile(f, "r").use { val b = ByteArray(4); it.read(b) == 4 && b[0] == 0x7f.toByte() && b[1] == 'E'.code.toByte() && b[2] == 'L'.code.toByte() && b[3] == 'F'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
private fun addScript(context: Context, script: File, name: String?, icon: File?, onProgress: (String, Int) -> Unit): String? {
|
||||
private fun addScript(
|
||||
context: Context, script: File, name: String?, icon: File?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
|
||||
): String? {
|
||||
if (!script.isFile) return context.getString(R.string.user_apps_file_gone)
|
||||
val plan = planScript(script)
|
||||
val base = root(context).apply { mkdirs() }
|
||||
@@ -232,6 +244,7 @@ object UserApps {
|
||||
FileUtils.writeString(File(dir, "name"), title)
|
||||
FileUtils.writeString(File(dir, "source"), script.canonicalPath)
|
||||
if (icon != null && !saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
|
||||
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
|
||||
writeScriptEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -68,12 +68,19 @@ object GameStorage {
|
||||
|
||||
class Library(val host: File, val guest: String, val label: String)
|
||||
|
||||
/**
|
||||
* Games folders an earlier build registered as Steam libraries that Steam has installed games
|
||||
* into (an appmanifest in their steamapps/): those stay libraries, so the games are not lost.
|
||||
* Any other Games folder is no longer one; the session's library list leaves it out and the
|
||||
* runtime's droiddeck-steam-library forgets it.
|
||||
*/
|
||||
fun gamesFolderLibraries(context: Context): List<Library> {
|
||||
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
|
||||
val card = effective(context)?.let { canonical(File(it.path)) }
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
return AddedGames.roots(context).mapNotNull { root ->
|
||||
val host = canonical(root.host)
|
||||
if (!hasInstalledGames(root.host)) return@mapNotNull null
|
||||
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
|
||||
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
|
||||
@@ -83,6 +90,10 @@ object GameStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/** Steam has installed a game into [root] as a library: a manifest in its steamapps/. */
|
||||
internal fun hasInstalledGames(root: File): Boolean =
|
||||
File(root, "steamapps").listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }?.isNotEmpty() == true
|
||||
|
||||
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
|
||||
|
||||
/** The label the client shows for a chosen folder: its last name, or the volume's. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.LocalServerSocket
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.SystemClock
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
@@ -33,6 +34,8 @@ class RumbleComponent : SessionPart() {
|
||||
private var phone: Motors? = null
|
||||
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
|
||||
private val playing = HashMap<Int, Motors>()
|
||||
/** Per slot, the last effect handed to the motors and when, to drop the repeats games stream. */
|
||||
private val sent = HashMap<Int, Sent>()
|
||||
/** Each controller's motors, looked up once; null when it has none. */
|
||||
private val controllers = HashMap<Int, Motors?>()
|
||||
private var preferences: SharedPreferences? = null
|
||||
@@ -95,13 +98,21 @@ class RumbleComponent : SessionPart() {
|
||||
val previous = playing[slot]
|
||||
if (target !== previous) {
|
||||
previous?.cancel()
|
||||
sent.remove(slot)
|
||||
if (target == null) playing.remove(slot) else playing[slot] = target
|
||||
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); sent.remove(slot); return }
|
||||
// Games re-send their effect every frame; restarting the motor that often floods the haptics
|
||||
// driver. ponytail: a change inside MIN_GAP_MS is dropped, the game's next packet brings it.
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val last = sent[slot]
|
||||
if (last != null && (now - last.at < MIN_GAP_MS || (last.strong == strong && last.weak == weak && now < last.until))) return
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
|
||||
target.play(strong, weak, duration)
|
||||
sent[slot] = Sent(strong, weak, now, now + duration)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: ${target.name}: $e")
|
||||
}
|
||||
@@ -118,8 +129,11 @@ class RumbleComponent : SessionPart() {
|
||||
private fun cancelAll() {
|
||||
playing.values.toSet().forEach { it.cancel() }
|
||||
playing.clear()
|
||||
sent.clear()
|
||||
}
|
||||
|
||||
private data class Sent(val strong: Int, val weak: Int, val at: Long, val until: Long)
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
internal interface Motors {
|
||||
val name: String
|
||||
@@ -160,6 +174,8 @@ class RumbleComponent : SessionPart() {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "droiddeck-rumble"
|
||||
/** The shortest time between two effects started on one slot's motors. */
|
||||
const val MIN_GAP_MS = 50L
|
||||
|
||||
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
|
||||
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
|
||||
@@ -52,6 +52,26 @@ object SessionDisplay {
|
||||
fun followSize(width: Int, height: Int): Pair<Int, Int>? =
|
||||
if (minOf(width, height) < FOLLOW_MIN_SIDE) null else (width and 1.inv()) to (height and 1.inv())
|
||||
|
||||
/** Unfolded controls: the most of the window the game may take, so a window near 16:9 itself
|
||||
* still leaves the Deck controls room. */
|
||||
const val UNFOLDED_MAX_GAME_SHARE = 0.8f
|
||||
|
||||
/** A foldable: Android 11's hinge angle sensor, which foldables report and slates do not. Unfolded
|
||||
* controls are offered only there - untested on tablets, whose 16:10 landscape leaves them
|
||||
* little room under a 16:9 game. */
|
||||
fun foldable(context: Context): Boolean =
|
||||
context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")
|
||||
|
||||
/** A panel at least this wide on its shortest side (dp) is a foldable's unfolded inner screen. */
|
||||
const val UNFOLDED_MIN_DP = 600
|
||||
|
||||
/** Unfolded controls: the game's height in a window of this size - 16:9 across the full width,
|
||||
* held either way, with even sides - and the Deck controls take the rest below it, laid out to
|
||||
* whatever that leaves (DeckControlsPanel's GamepadLayout). */
|
||||
fun unfoldedGameHeight(width: Int, height: Int): Int =
|
||||
minOf(width * 9 / 16, (height * UNFOLDED_MAX_GAME_SHARE).toInt()) and 1.inv()
|
||||
|
||||
|
||||
fun panelSize(context: Context): Pair<Int, Int> {
|
||||
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -94,6 +94,10 @@ object SessionFiles {
|
||||
// The user's own AppImages, extracted at import (AppImageManager).
|
||||
"usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run",
|
||||
"usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run",
|
||||
// The desktop's Run entries and its exec fallback: Windows programs through Steam's Proton,
|
||||
// x86 Linux programs through FEX.
|
||||
"usr/local/bin/droiddeck-open" to "usr/local/bin/droiddeck-open",
|
||||
"usr/local/bin/droiddeck-proton-run" to "usr/local/bin/droiddeck-proton-run",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
@@ -129,8 +133,8 @@ object SessionFiles {
|
||||
.map { "usr/local/lib/droiddeck-msitools/$it" } +
|
||||
listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" }
|
||||
val fexPreloads = listOf("x86_64", "i386").flatMap { arch ->
|
||||
listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" }
|
||||
listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" }
|
||||
} + listOf("libthunkaudit.so", "libvulkan-thunk.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",
|
||||
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
@@ -18,6 +19,9 @@ object SessionPrefs {
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val CONTROLLER_STEAM = "steamcontroller"
|
||||
const val CONTROLLER_DS4 = "dualshock4"
|
||||
const val CONTROLLER_DUALSENSE = "dualsense"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
@@ -35,6 +39,22 @@ object SessionPrefs {
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
|
||||
val orientationOptions = listOf(
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE to R.string.setup_orientation_reverse,
|
||||
)
|
||||
|
||||
fun orientation(context: Context): Int =
|
||||
prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
.takeIf { value -> orientationOptions.any { it.first == value } }
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
|
||||
fun setOrientation(context: Context, orientation: Int) {
|
||||
require(orientationOptions.any { it.first == orientation })
|
||||
prefs(context).edit().putInt("orientation", orientation).apply()
|
||||
}
|
||||
|
||||
/** Whether the launcher hides Android's status and navigation bars. */
|
||||
fun launcherFullscreen(context: Context): Boolean = prefs(context).getBoolean("launcherFullscreen", true)
|
||||
|
||||
@@ -114,9 +134,9 @@ object SessionPrefs {
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
|
||||
/** A Steam session with Deck mode's performance overlay (mangoapp). */
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
|
||||
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
|
||||
|
||||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
@@ -432,27 +452,21 @@ object SessionPrefs {
|
||||
|
||||
/**
|
||||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
|
||||
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), [CONTROLLER_STEAM], a 2025 Steam
|
||||
* Controller (the same, plus grip sense and its own haptics), [CONTROLLER_DS4] or
|
||||
* [CONTROLLER_DUALSENSE], a PlayStation pad with its touchpad fed by the trackpads, or
|
||||
* [CONTROLLER_XBOX360], the plain Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
*/
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On by default (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/**
|
||||
* Deck mode is the default: every install, new or from before, is moved to it once. An install
|
||||
* from before 0.3.0 had off written down for it whether or not anyone chose it, so the move
|
||||
* can't tell a choice from that default and moves everyone; turning it off afterwards sticks.
|
||||
* Run once at process start, before anything reads or writes these prefs.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.getBoolean("deckModeMoved", false)) return
|
||||
p.edit().putBoolean("steamDeckMode", true).putBoolean("deckModeMoved", true).apply()
|
||||
/** [steamController]'s choice as libfakeinput's pad type (FakeInputWriter.PAD_TYPE_*). */
|
||||
fun padType(id: String): Int = when (id) {
|
||||
CONTROLLER_STEAM -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM
|
||||
CONTROLLER_XBOX360 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_XBOX
|
||||
CONTROLLER_DS4 -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4
|
||||
CONTROLLER_DUALSENSE -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE
|
||||
else -> com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -586,24 +600,6 @@ object SessionPrefs {
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam client branch used for the first download and forced on the command line:
|
||||
* "steamdeck_publicbeta" by default, or "publicbeta" when chosen with Deck mode off.
|
||||
* Deck mode always takes the Deck branch, whatever was chosen:
|
||||
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
|
||||
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
|
||||
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
|
||||
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
|
||||
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
|
||||
*/
|
||||
fun steamChannel(context: Context): String =
|
||||
if (steamDeckMode(context)) "steamdeck_publicbeta"
|
||||
else prefs(context).getString("steamChannel", null) ?: "steamdeck_publicbeta"
|
||||
|
||||
fun setSteamChannel(context: Context, id: String) {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
}
|
||||
|
||||
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
|
||||
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
|
||||
|
||||
@@ -612,8 +608,9 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders of the user's own Windows games (one game per subfolder), any number of them
|
||||
* from anywhere on the device. The single folder an earlier build kept is carried in.
|
||||
* The Games folders earlier builds let the user add (one game per subfolder); read only by
|
||||
* GamesFolderMigration, which turns them into entries and clears them. The single folder an
|
||||
* even earlier build kept is carried in.
|
||||
*/
|
||||
fun addedGamesDirs(context: Context): List<String> {
|
||||
val p = prefs(context)
|
||||
@@ -627,6 +624,26 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games folders earlier builds had (host path, guest path), still bound into the session so
|
||||
* their games keep their paths; written once by GamesFolderMigration.
|
||||
*/
|
||||
fun gameFolderBinds(context: Context): List<Pair<String, String>> =
|
||||
(prefs(context).getString("gameFolderBinds", "") ?: "").split('\n').mapNotNull { line ->
|
||||
line.split('\t').takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }?.let { it[0] to it[1] }
|
||||
}
|
||||
|
||||
fun setGameFolderBinds(context: Context, binds: List<Pair<String, String>>) {
|
||||
prefs(context).edit().putString("gameFolderBinds", binds.distinctBy { it.first }.joinToString("\n") { "${it.first}\t${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** The Games folders were turned into entries (GamesFolderMigration); a record, not a gate. */
|
||||
fun gamesFoldersMigrated(context: Context): Boolean = prefs(context).getBoolean("gamesFoldersMigrated", false)
|
||||
|
||||
fun setGamesFoldersMigrated(context: Context) {
|
||||
prefs(context).edit().putBoolean("gamesFoldersMigrated", true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Game folders (by path) the user removed from the added games: a scan skips them, so they
|
||||
* stay off the Games tab and out of Steam until restored. Nothing on disk is touched.
|
||||
@@ -883,6 +900,26 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("pipAutoEnter", enabled).apply()
|
||||
}
|
||||
|
||||
/** A foldable's unfolded panel shows the game above the Deck controls (SessionDisplay.unfoldedGameHeight). */
|
||||
fun unfoldedControls(context: Context): Boolean = prefs(context).getBoolean("unfoldedControls", false)
|
||||
|
||||
fun setUnfoldedControls(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean("unfoldedControls", enabled).apply()
|
||||
}
|
||||
|
||||
const val SURFACE_STICK = "stick"
|
||||
const val SURFACE_TRACKPAD = "trackpad"
|
||||
|
||||
/** The Deck controls' gamepad tab: what each side is - an analog stick or a Steam trackpad.
|
||||
* Left a stick and right a trackpad until changed, as a Deck is mostly played. */
|
||||
fun gamepadSurface(context: Context, right: Boolean): String =
|
||||
prefs(context).getString(if (right) "gamepadRight" else "gamepadLeft", null)
|
||||
?: if (right) SURFACE_TRACKPAD else SURFACE_STICK
|
||||
|
||||
fun setGamepadSurface(context: Context, right: Boolean, surface: String) {
|
||||
prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply()
|
||||
}
|
||||
|
||||
fun suspendPolicy(context: Context, mode: String): String =
|
||||
prefs(context).getString("suspendPolicy.${prefMode(mode)}", SUSPEND_MANUAL)
|
||||
?.let { if (it == LEGACY_SUSPEND_DOWNLOADS) SUSPEND_AUTO else it }
|
||||
|
||||
@@ -37,9 +37,12 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.frontend.GameLaunchLink
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.input.SteamControllerBle
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.ProotFastPath
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -407,6 +410,8 @@ class SessionService : Service() {
|
||||
guest.add(it)
|
||||
Log.i(TAG, "steam: handing the client $it")
|
||||
}
|
||||
// A game whose download finishes in this session gets its Windows components readied in it.
|
||||
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
|
||||
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
|
||||
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
|
||||
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
|
||||
@@ -620,8 +625,7 @@ class SessionService : Service() {
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
}
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
// thread and no GL error checks.
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
@@ -634,9 +638,7 @@ class SessionService : Service() {
|
||||
// every thread start paid a SIGSYS that proot answers; the malloc top pad grows the heap
|
||||
// 16 MB at a time instead of 128 KB, and every brk(2) is a proot stop too.
|
||||
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
|
||||
}
|
||||
@@ -680,7 +682,9 @@ class SessionService : Service() {
|
||||
// changes what games do and leaves the client alone. The microphone is its own opt-in on
|
||||
// top, and the helper only opens an input stream when asked - so a user who wants game
|
||||
// sound but no recording gets exactly that, and Android's recording indicator stays off.
|
||||
val wantsDirectAudio = SessionState.mode == MODE_STEAM && SessionPrefs.directAudio(this)
|
||||
// The desktop starts Windows programs through the same Proton wrappers (droiddeck-proton-run).
|
||||
val wantsDirectAudio = (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) &&
|
||||
SessionPrefs.directAudio(this)
|
||||
val wantsMic = SessionPrefs.micEnabled(this) &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
// Both paths sit under the app's files directory, which the session binds at its own path,
|
||||
@@ -738,13 +742,18 @@ class SessionService : Service() {
|
||||
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
|
||||
// Decided only once its sysfs is in place: without it the client would find no Deck, and
|
||||
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
|
||||
// Set up whatever the pad is to be, so the drawer can switch it between a Deck, a Steam
|
||||
// Controller and an Xbox 360 pad while the session runs (FakeInputWriter.setPadType).
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionState.steamUi != "desktop" &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
|
||||
// A paired Triton is exposed as a second native Steam Controller HID, with its original
|
||||
// report IDs and output path, beside whatever the handheld's own pad is.
|
||||
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
|
||||
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -754,6 +763,11 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
|
||||
// What the pad starts as; the drawer's changes reach libfakeinput through the ring.
|
||||
val padType = SessionPrefs.steamController(this)
|
||||
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
|
||||
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
@@ -1214,6 +1228,13 @@ class SessionService : Service() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (path != null && path.startsWith("desktop-import-") && path.endsWith(".request")) {
|
||||
val request = File(dir, path)
|
||||
val text = runCatching { request.readText() }.getOrNull() ?: return
|
||||
request.delete()
|
||||
importForDesktop(dir, text)
|
||||
return
|
||||
}
|
||||
if (path != "steam-launch") return
|
||||
val file = File(dir, path)
|
||||
val text = try { file.readText() } catch (e: Exception) { return }
|
||||
@@ -1243,6 +1264,48 @@ class SessionService : Service() {
|
||||
launchWatcher = watcher
|
||||
}
|
||||
|
||||
private val desktopImports = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
/**
|
||||
* What the desktop cannot run where it lies (droiddeck-open), added as Add an app adds it, menu entry
|
||||
* included: an AppImage of sharun's, whose hard links Android denies apps and only the app's own proot
|
||||
* (link2symlink) makes, or a script or program whose folder of Linux programs is on noexec shared
|
||||
* storage, copied in. The answer goes beside the request, "ok <folder>" or "error <why>".
|
||||
*/
|
||||
private fun importForDesktop(dir: File, text: String) {
|
||||
val fields = text.lineSequence().mapNotNull { line ->
|
||||
line.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].trim() }
|
||||
}.toMap()
|
||||
val key = fields["id"]?.takeIf { it.matches(Regex("[0-9a-f]{16}")) } ?: return
|
||||
val guestPath = fields["path"]?.takeIf { it.startsWith("/") && !it.contains("/../") } ?: return
|
||||
val folder = fields["kind"] == "folder"
|
||||
val binds = LinuxRuntime.lastBinds(this).map { BwrapSpawner.Bind.parse(it) }
|
||||
val file = File(BwrapSpawner.hostPath(binds, LinuxRuntime.rootDir(this).path, guestPath))
|
||||
val context = applicationContext
|
||||
// A request for what is already being added waits for that, then finds it.
|
||||
val lock = desktopImports.computeIfAbsent(key) { Any() }
|
||||
Thread({
|
||||
fun imported() = UserApps.desktopImport(context, key, guestPath)?.let { "ok $it" }
|
||||
val request = UserApps.Request(
|
||||
if (folder) UserApps.Source.Script(file) else UserApps.Source.AppImage(file),
|
||||
if (folder) file.parentFile?.name else null, null, mapOf(UserApps.DESKTOP_IMPORT to "$key\n$guestPath"),
|
||||
)
|
||||
val answer = synchronized(lock) {
|
||||
try {
|
||||
imported() ?: UserApps.add(context, request) { _, _ -> }?.let { "error $it" }
|
||||
?: imported() ?: "error " + context.getString(R.string.user_apps_failed)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Desktop import of $guestPath", e)
|
||||
"error " + (e.message ?: context.getString(R.string.user_apps_failed))
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Desktop import of $guestPath: $answer")
|
||||
val result = File(dir, "desktop-import-$key.result")
|
||||
val partial = File(dir, result.name + ".tmp")
|
||||
runCatching { partial.writeText(answer.replace('\n', ' ') + "\n"); partial.renameTo(result) }
|
||||
}, "desktop-import").start()
|
||||
}
|
||||
|
||||
@Volatile private var syncWatcher: android.os.FileObserver? = null
|
||||
|
||||
private fun watchSyncWanted(root: File) {
|
||||
@@ -1719,7 +1782,7 @@ class SessionService : Service() {
|
||||
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
|
||||
}
|
||||
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
|
||||
"; presented to the guest as " + if (SessionState.deckPad) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -44,6 +44,36 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
|
||||
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
|
||||
private const val TRITON_MINOR = 18
|
||||
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
|
||||
private const val TRITON_SERIAL = "DROIDDECKSC01"
|
||||
// The handheld's own pad as the Steam Controller (SessionPrefs.CONTROLLER_STEAM): the wired
|
||||
// controller (28de:1302) on a USB device of its own, since it is built in.
|
||||
private const val TRITON_USB = "$GUEST_DEVICES/usb3"
|
||||
private const val TRITON_INTERFACE = "$TRITON_USB/3-1:1.0"
|
||||
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
|
||||
/** Must match libfakeinput (DS4_HIDRAW_MINOR, DUALSENSE_HIDRAW_MINOR). */
|
||||
private const val SONY_DS4_MINOR = 20
|
||||
private const val SONY_DUALSENSE_MINOR = 21
|
||||
/** Must match libfakeinput's kDs4ReportDescriptor and kDualSenseReportDescriptor. */
|
||||
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
|
||||
0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xb1, 0x02, 0x85, 0x12, 0x09, 0x25, 0x95, 0x0f, 0xb1, 0x02,
|
||||
0x85, 0xa3, 0x09, 0x26, 0x95, 0x30, 0xb1, 0x02, 0x85, 0x81, 0x09, 0x27, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
private val DUALSENSE_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x02, 0x09, 0x21, 0x95, 0x2f, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x22, 0x95, 0x28, 0xb1, 0x02, 0x85, 0x09, 0x09, 0x23, 0x95, 0x13, 0xb1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x24, 0x95, 0x3f, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
/** Must match libfakeinput (TRITON_PAD_HIDRAW_*): its own node, so a paired controller keeps 18. */
|
||||
private const val TRITON_PAD_MINOR = 19
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
@@ -69,11 +99,65 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
|
||||
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
|
||||
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
|
||||
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
|
||||
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
|
||||
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
|
||||
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
|
||||
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
|
||||
0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** The wired controller's: its 0x42 state report in place of the Bluetooth 0x45. */
|
||||
private val TRITON_WIRED_REPORT_DESCRIPTOR = TRITON_REPORT_DESCRIPTOR.copyOf().also { it[15] = 0x42 }
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
|
||||
/** The pad's own devices by minor (the Deck, its Steam Controller, the PlayStation pads): guest paths. */
|
||||
private val padNodes = mutableMapOf<Int, String>()
|
||||
@Volatile private var hidrawClassDir: File? = null
|
||||
|
||||
/**
|
||||
* Lists only the device the pad is now ([padType], FakeInputWriter.PAD_TYPE_*) in
|
||||
* /sys/class/hidraw, none for an Xbox 360 pad. Device scans then find what is plugged in: SDL's
|
||||
* (the PlayStation pads' driver) keeps a device it found and could not open, and never tries it
|
||||
* again, while one that disappears from the listing is let go. Before the shim is told.
|
||||
*/
|
||||
@Synchronized
|
||||
fun showPad(padType: Int) {
|
||||
val dir = hidrawClassDir ?: return
|
||||
val shown = when (padType) {
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK -> MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
|
||||
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
|
||||
else -> -1
|
||||
}
|
||||
for ((minor, target) in padNodes) {
|
||||
val link = File(dir, "hidraw$minor")
|
||||
try {
|
||||
if (minor == shown) {
|
||||
if (!link.exists()) { link.delete(); Os.symlink(target, link.path) }
|
||||
} else {
|
||||
link.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "pad: could not list /dev/hidraw$minor: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false,
|
||||
padType: Int = com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DECK): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -113,7 +197,8 @@ object SteamDeckPad {
|
||||
link(File(dir(HIDRAW), "device"), HID)
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
padNodes.clear()
|
||||
padNodes[MINOR] = HIDRAW
|
||||
|
||||
// Only the names matter: libudev reads a listing for the subsystems to descend into and
|
||||
// then opens /sys/class/hidraw itself, which is the bind above.
|
||||
@@ -153,6 +238,88 @@ object SteamDeckPad {
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
// A Steam Controller's HID device: the handheld's own pad as the wired one, or the paired
|
||||
// one on Bluetooth. Both are always described; libfakeinput offers whichever is in use.
|
||||
fun describeTriton(wired: Boolean) {
|
||||
val hid = if (wired) TRITON_WIRED_HID else TRITON_HID
|
||||
val product = if (wired) "1302" else "1303"
|
||||
val bus = if (wired) "0003" else "0005"
|
||||
val minor = if (wired) TRITON_PAD_MINOR else TRITON_MINOR
|
||||
val node = "hidraw$minor"
|
||||
if (wired) {
|
||||
write(TRITON_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1302/100\nTYPE=0/0/0\nBUSNUM=003\nDEVNUM=002\n")
|
||||
write(TRITON_USB, "idVendor", "28de\n")
|
||||
write(TRITON_USB, "idProduct", "1302\n")
|
||||
write(TRITON_USB, "bcdDevice", "0100\n")
|
||||
write(TRITON_USB, "manufacturer", "Valve Software\n")
|
||||
write(TRITON_USB, "product", "Steam Controller\n")
|
||||
write(TRITON_USB, "serial", "$TRITON_SERIAL\n")
|
||||
link(File(dir(TRITON_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TRITON_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1302/100\nINTERFACE=3/0/0\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TRITON_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TRITON_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
}
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=$bus:000028DE:0000$product\n" +
|
||||
"HID_NAME=Valve Software Steam Controller\n" +
|
||||
"HID_PHYS=${if (wired) "usb-droiddeck-3/input0" else "bluetooth-droiddeck-1"}\n" +
|
||||
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b${bus}g0001v000028DEp0000$product\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (wired) TRITON_WIRED_REPORT_DESCRIPTOR else TRITON_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
if (wired) padNodes[minor] = hidraw else link(File(hidrawClass, node), hidraw)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
|
||||
}
|
||||
// The handheld's own pad as a PlayStation controller, each a USB device of its own with
|
||||
// the report descriptor libfakeinput serves (kDs4/kDualSenseReportDescriptor).
|
||||
fun describeSony(dualSense: Boolean) {
|
||||
val bus = if (dualSense) 5 else 4
|
||||
val product = if (dualSense) "0ce6" else "09cc"
|
||||
val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
|
||||
val node = "hidraw$minor"
|
||||
val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
|
||||
val usb = "$GUEST_DEVICES/usb$bus"
|
||||
val iface = "$usb/$bus-1:1.3"
|
||||
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
|
||||
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=54c/$product/100\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
|
||||
write(usb, "idVendor", "054c\n")
|
||||
write(usb, "idProduct", "$product\n")
|
||||
write(usb, "bcdDevice", "0100\n")
|
||||
write(usb, "manufacturer", "Sony Interactive Entertainment\n")
|
||||
write(usb, "product", "$name\n")
|
||||
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
|
||||
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=54c/$product/100\nINTERFACE=3/0/0\n")
|
||||
write(iface, "bInterfaceNumber", "03\n")
|
||||
write(iface, "bInterfaceClass", "03\n")
|
||||
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
|
||||
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
|
||||
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
padNodes[minor] = hidraw
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
|
||||
}
|
||||
describeSony(dualSense = false)
|
||||
describeSony(dualSense = true)
|
||||
describeTriton(wired = true)
|
||||
if (triton) describeTriton(wired = false)
|
||||
hidrawClassDir = hidrawClass
|
||||
showPad(padType)
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -38,7 +38,7 @@ import java.util.zip.ZipInputStream
|
||||
*/
|
||||
object WinComponents {
|
||||
private const val TAG = "WinComponents"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/main/components.json"
|
||||
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/components.json"
|
||||
private const val SELECTION = "wincomponents.json"
|
||||
private const val GUEST_SELECTION = "root/.config/droiddeck/wincomponents.json"
|
||||
private const val STORE = "opt/droiddeck/wincomponents"
|
||||
@@ -77,6 +77,21 @@ object WinComponents {
|
||||
|
||||
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
|
||||
|
||||
private val BACKGROUND = ThreadLocal<Boolean>()
|
||||
|
||||
/**
|
||||
* Runs [block] as background work: the runtime helpers an install starts on this thread run
|
||||
* at the lowest CPU and I/O priority (nice 19, ionice idle where the runtime has it).
|
||||
*/
|
||||
fun <T> background(block: () -> T): T {
|
||||
BACKGROUND.set(true)
|
||||
try { return block() } finally { BACKGROUND.remove() }
|
||||
}
|
||||
|
||||
private fun guest(argv: List<String>): List<String> = if (BACKGROUND.get() != true) argv else listOf(
|
||||
"/bin/sh", "-c", "if command -v ionice >/dev/null 2>&1; then exec nice -n 19 ionice -c3 \"\$@\"; else exec nice -n 19 \"\$@\"; fi", "sh",
|
||||
) + argv
|
||||
|
||||
class Step(val action: String, val json: JSONObject) {
|
||||
fun str(key: String): String = json.optString(key, "").ifEmpty {
|
||||
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
|
||||
@@ -118,8 +133,13 @@ object WinComponents {
|
||||
(step.action == "install_msi" || listOf(step.str("file_name"), step.str("url").substringBefore('?'))
|
||||
.any { it.endsWith(".msi", ignoreCase = true) || it.endsWith(".exe", ignoreCase = true) })
|
||||
|
||||
/** Catalog entries nothing here can use: the IE8 security update patches a browser no prefix has. */
|
||||
private val POINTLESS = setOf("ie8_kb2936068")
|
||||
/**
|
||||
* Catalog entries nothing here can use: the IE8 security update patches a browser no prefix
|
||||
* has; the OpenAL setup program is a twin of oalinst_dll, which copies the same DLLs; and
|
||||
* WebView2 can only be installed by running Microsoft's installer in the prefix (its files
|
||||
* sit in a container nothing opens and may not be hosted), which no runtime here does.
|
||||
*/
|
||||
private val POINTLESS = setOf("ie8_kb2936068", "oalinst", "webview2")
|
||||
|
||||
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name in POINTLESS || name.startsWith("mono-")
|
||||
|
||||
@@ -128,8 +148,24 @@ object WinComponents {
|
||||
/** The steps a recording makes redundant: everything that places files or edits the registry. */
|
||||
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
|
||||
|
||||
fun fetch(): List<Component>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
|
||||
|
||||
private const val CATALOG_CACHE = "wincomponents-catalog.json"
|
||||
|
||||
/** The catalog from the network, kept for later; the copy kept last time when offline. */
|
||||
fun fetch(context: Context): List<Component>? {
|
||||
val cache = File(context.filesDir, CATALOG_CACHE)
|
||||
Downloader.downloadString(CATALOG_URL)?.let { body ->
|
||||
parse(body)?.let { runCatching { write(cache, body) }; return it }
|
||||
}
|
||||
return runCatching { parse(cache.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
/** The copy of the catalog kept by [fetch], without asking the network; null before the first fetch. */
|
||||
fun cached(context: Context): List<Component>? =
|
||||
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
|
||||
|
||||
private fun parse(body: String): List<Component>? {
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
@@ -150,6 +186,16 @@ object WinComponents {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog entry that installs [name] here: the detector names installers
|
||||
* (vcredist2022, oalinst), and their DLL-copy twin (vcredist2022_dll, oalinst_dll) is the one
|
||||
* that installs without running anything; [name] itself when there is no such twin.
|
||||
*/
|
||||
fun installable(name: String, all: Map<String, Component>): String {
|
||||
val twin = all[if (name == "oalinst") "oalinst_dll" else "${name}_dll"]
|
||||
return if (twin != null && support(twin, all) == Support.READY) twin.name else name
|
||||
}
|
||||
|
||||
/** Whether [c] can be installed here, its bundled components included. */
|
||||
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
|
||||
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
|
||||
@@ -501,7 +547,7 @@ object WinComponents {
|
||||
val inside = archive.relativeTo(root).path
|
||||
var problem: String? = null
|
||||
var ok = false
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
|
||||
@@ -540,7 +586,7 @@ object WinComponents {
|
||||
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
|
||||
var result: JSONObject? = null
|
||||
var problem: String? = null
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
|
||||
@@ -617,36 +663,101 @@ object WinComponents {
|
||||
fun uninstall(context: Context, id: String) {
|
||||
require(validId(id))
|
||||
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
|
||||
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
|
||||
save(context, selections(context).mapValues { (_, sel) -> Selection(sel.auto - id, sel.user - id) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a component was turned on for a game by itself ([setAuto]): [kind] says whose list it came
|
||||
* from (see AutoComponents), [args] what the reason line names. Kept beside the picks.
|
||||
*/
|
||||
class AutoReason(val kind: String, val args: List<String>)
|
||||
|
||||
/**
|
||||
* Everything kept per game, by the appid its prefix is named after: [auto] - turned on by
|
||||
* itself, with its reason; [user] - a switch the user pressed (true on, false off), which wins
|
||||
* over [auto] either way. What the launch reads is [effective].
|
||||
*/
|
||||
class Selection(val auto: Map<String, AutoReason>, val user: Map<String, Boolean>) {
|
||||
val effective: List<String>
|
||||
get() = (auto.keys.filter { user[it] != false } + user.filterValues { it }.keys).distinct()
|
||||
}
|
||||
|
||||
/** Each game's picks, by the appid its prefix is named after. */
|
||||
@Synchronized
|
||||
fun read(context: Context): Map<String, List<String>> = try {
|
||||
fun selections(context: Context): Map<String, Selection> = try {
|
||||
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
|
||||
val games = json.optJSONObject("games") ?: JSONObject()
|
||||
games.keys().asSequence().associateWith { app ->
|
||||
val ids = games.getJSONArray(app)
|
||||
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
|
||||
val autos = json.optJSONObject("auto") ?: JSONObject()
|
||||
val users = json.optJSONObject("user")
|
||||
val keys = games.keys().asSequence().toSet() + autos.keys().asSequence().toSet() + (users?.keys()?.asSequence()?.toSet() ?: emptySet())
|
||||
keys.associateWith { app ->
|
||||
val auto = autos.optJSONObject(app)?.let { o ->
|
||||
o.keys().asSequence().filter { validId(it) }.associateWith { id ->
|
||||
val r = o.optJSONObject(id) ?: JSONObject()
|
||||
val a = r.optJSONArray("args") ?: JSONArray()
|
||||
AutoReason(r.optString("kind"), (0 until a.length()).map { a.optString(it) })
|
||||
}
|
||||
}.orEmpty()
|
||||
// Before there were overrides, every pick was the user's own.
|
||||
val user = if (users == null) games.optJSONArray(app)?.let { ids -> (0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }.associateWith { true } }.orEmpty()
|
||||
else users.optJSONObject(app)?.let { o -> o.keys().asSequence().filter { validId(it) }.associateWith { o.optBoolean(it) } }.orEmpty()
|
||||
Selection(auto, user)
|
||||
}
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
|
||||
/** Each game's picks as the launch takes them, by the appid its prefix is named after. */
|
||||
fun read(context: Context): Map<String, List<String>> =
|
||||
selections(context).mapValues { it.value.effective }.filterValues { it.isNotEmpty() }
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = selections(context)[appKey]?.effective.orEmpty()
|
||||
|
||||
fun selection(context: Context, appKey: String): Selection = selections(context)[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
|
||||
/** The user's own switch for [id] in [appKey]: on or off, over whatever was picked by itself. */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = read(context).toMutableMap()
|
||||
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
|
||||
fun setUser(context: Context, appKey: String, id: String, on: Boolean) {
|
||||
require(validId(id))
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, now.user + (id to on))
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, List<String>>) {
|
||||
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
|
||||
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
|
||||
}).toString()
|
||||
/** The components picked by themselves for [appKey], replacing the earlier ones; the user's switches stay. */
|
||||
@Synchronized
|
||||
fun setAuto(context: Context, appKey: String, auto: Map<String, AutoReason>) {
|
||||
require(auto.keys.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(auto, now.user)
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
/** The user's picks for [appKey] set outright (each listed on, every other user switch dropped). */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, ids.distinct().associateWith { true } + now.auto.keys.filter { it !in ids }.associateWith { false })
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, Selection>) {
|
||||
val kept = games.filterValues { it.auto.isNotEmpty() || it.user.isNotEmpty() }
|
||||
// Version 1 still: droiddeck-wincomponents reads "games" and ignores the rest.
|
||||
val text = JSONObject().put("version", 1)
|
||||
.put("games", JSONObject().apply { kept.forEach { (app, sel) -> sel.effective.takeIf { it.isNotEmpty() }?.let { put(app, JSONArray(it)) } } })
|
||||
.put("auto", JSONObject().apply {
|
||||
kept.forEach { (app, sel) ->
|
||||
if (sel.auto.isNotEmpty()) put(app, JSONObject().apply {
|
||||
sel.auto.forEach { (id, r) -> put(id, JSONObject().put("kind", r.kind).put("args", JSONArray(r.args))) }
|
||||
})
|
||||
}
|
||||
})
|
||||
.put("user", JSONObject().apply { kept.forEach { (app, sel) -> if (sel.user.isNotEmpty()) put(app, JSONObject(sel.user as Map<*, *>)) } })
|
||||
.toString()
|
||||
write(File(context.filesDir, SELECTION), text)
|
||||
publish(context, text)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ object StoreInstalls {
|
||||
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
|
||||
onStep?.invoke(2, STEPS)
|
||||
register(app)
|
||||
// Its Windows components from the store's list (GOG's dependencies, Epic's prerequisite,
|
||||
// Amazon's install steps), on a thread of their own.
|
||||
AddedGames.single(app, folder.path)?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, Library.addedGame(app, it)) }
|
||||
onStep?.invoke(STEPS, STEPS)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
|
||||
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
|
||||
// Both kept even when empty: the manifest was read, and names no prerequisite.
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId) + mapOf(
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_PATH to result.prereqPath,
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_NAME to result.prereqName,
|
||||
) + (if (result.prereqRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar)
|
||||
return folder.path
|
||||
|
||||
@@ -107,6 +107,11 @@ public final class EpicDownloadManager {
|
||||
public String launchExe = "";
|
||||
public String launchCommand = "";
|
||||
public String buildVersion = "";
|
||||
/** The prerequisite the launcher runs first (UE4PrereqSetup_x64.exe...): what it is called and its path in the install. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
/** The prerequisite fields were read (an empty name and path then mean the build names none). */
|
||||
public boolean prereqRead = false;
|
||||
public List<CdnUrl> cdnUrls = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -115,7 +120,12 @@ public final class EpicDownloadManager {
|
||||
public final String launchExe;
|
||||
public final String buildVersion;
|
||||
public final long bytes;
|
||||
/** The manifest's prerequisite, for Windows components; "" when it names none. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
|
||||
public boolean prereqRead = false;
|
||||
Result withPrereq(Manifest m) { prereqName = m.prereqName; prereqPath = m.prereqPath; prereqRead = m.prereqRead; return this; }
|
||||
}
|
||||
|
||||
public static final class InstallException extends Exception {
|
||||
@@ -178,7 +188,7 @@ public final class EpicDownloadManager {
|
||||
if (pending.isEmpty()) {
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
|
||||
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
|
||||
@@ -265,7 +275,7 @@ public final class EpicDownloadManager {
|
||||
if (r.cancelled) return null;
|
||||
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
|
||||
}
|
||||
@@ -293,7 +303,7 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
} catch (InstallException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -470,6 +480,13 @@ public final class EpicDownloadManager {
|
||||
m.buildVersion = readFString(b);
|
||||
m.launchExe = readFString(b).replace('\\', '/');
|
||||
m.launchCommand = readFString(b);
|
||||
// PrereqIds (an array), PrereqName, PrereqPath, PrereqArgs.
|
||||
int prereqIds = b.getInt();
|
||||
for (int i = 0; i < prereqIds && i < 64; i++) readFString(b);
|
||||
m.prereqName = readFString(b);
|
||||
m.prereqPath = readFString(b).replace('\\', '/');
|
||||
readFString(b); // PrereqArgs
|
||||
m.prereqRead = true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
|
||||
}
|
||||
@@ -537,6 +554,9 @@ public final class EpicDownloadManager {
|
||||
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
|
||||
m.launchCommand = root.optString("LaunchCommand", "");
|
||||
m.buildVersion = root.optString("BuildVersionString", "");
|
||||
m.prereqName = root.optString("PrereqName", "");
|
||||
m.prereqPath = root.optString("PrereqPath", "").replace('\\', '/');
|
||||
m.prereqRead = true;
|
||||
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
|
||||
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
|
||||
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
|
||||
@@ -753,6 +773,22 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest's prerequisite as {name, path}, from the manifest alone; null on failure. Blocking. */
|
||||
public static String[] fetchPrereq(String manifestApiJson) {
|
||||
try {
|
||||
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
|
||||
if (cdnUrls.isEmpty()) return null;
|
||||
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
|
||||
if (bytes == null) return null;
|
||||
Manifest m = parseManifest(bytes);
|
||||
if (m == null || !m.prereqRead) { Log.w(TAG, "manifest prerequisite not read"); return null; }
|
||||
Log.i(TAG, "manifest " + m.buildVersion + ": " + m.files.size() + " files, prerequisite " + (m.prereqName.isEmpty() && m.prereqPath.isEmpty() ? "none" : m.prereqName + " (" + m.prereqPath + ")"));
|
||||
return new String[] { m.prereqName, m.prereqPath };
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
|
||||
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
|
||||
try {
|
||||
|
||||
@@ -111,6 +111,9 @@ object GogBackend : StoreBackend {
|
||||
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
|
||||
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
// Kept even when empty: the build was read, and lists none.
|
||||
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
|
||||
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
|
||||
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
|
||||
|
||||
@@ -79,6 +79,10 @@ public final class GogDownloadManager {
|
||||
public final String exeRelative;
|
||||
public final String buildId;
|
||||
public final long bytes;
|
||||
/** The build manifest's "dependencies" (GOG's redistributable ids: MSVC2017_x64, DirectX...), for Windows components. */
|
||||
public List<String> dependencies = new ArrayList<>();
|
||||
/** The dependencies come from a gen2 build manifest read whole (gen1 builds and installers have none). */
|
||||
public boolean dependenciesRead = false;
|
||||
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
|
||||
}
|
||||
|
||||
@@ -150,10 +154,12 @@ public final class GogDownloadManager {
|
||||
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
|
||||
baseProductId = products.getJSONObject(0).optString("productId", null);
|
||||
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
|
||||
// The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
|
||||
// log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
|
||||
// The build's required redistributables (MSVC, DirectX, ...): logged, and kept in the
|
||||
// sidecar, where Windows components take them as this game's list (WinCompSources).
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
|
||||
List<String> dependencies = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
|
||||
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
|
||||
|
||||
cb.onProgress("Reading depot manifests…", 10);
|
||||
List<DepotFile> files = new ArrayList<>();
|
||||
@@ -262,6 +268,8 @@ public final class GogDownloadManager {
|
||||
String clientSecret = manifest.optString("clientSecret", null);
|
||||
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
|
||||
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
@@ -1031,6 +1039,48 @@ public final class GogDownloadManager {
|
||||
}
|
||||
|
||||
/** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
|
||||
/**
|
||||
* The "dependencies" of the build [buildId] of [gameId] (else the newest Windows build), from
|
||||
* its build manifest alone: no game file is fetched. Null when the builds or the manifest
|
||||
* could not be had; empty when the build lists none. Blocking.
|
||||
*/
|
||||
public static List<String> fetchDependencies(String gameId, String buildId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson == null) return null;
|
||||
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
|
||||
if (items == null || items.length() == 0) return null;
|
||||
String manifestUrl = null;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
if (!"windows".equals(item.optString("os"))) continue;
|
||||
String link = item.optString("link");
|
||||
if (link == null || link.isEmpty()) link = item.optString("meta_url");
|
||||
if (manifestUrl == null) manifestUrl = link;
|
||||
if (buildId != null && !buildId.isEmpty() && buildId.equals(item.optString("build_id"))) { manifestUrl = link; break; }
|
||||
}
|
||||
if (manifestUrl == null || manifestUrl.isEmpty()) return null;
|
||||
byte[] raw = fetchBytes(manifestUrl, token);
|
||||
if (raw == null) return null;
|
||||
String manifestStr = decompressBytes(raw);
|
||||
if (manifestStr == null) return null;
|
||||
JSONObject manifest = new JSONObject(manifestStr);
|
||||
// A manifest without depots is not one this reads right: no list rather than an empty one.
|
||||
JSONArray depots = manifest.optJSONArray("depots");
|
||||
if (depots == null) { Log.w(TAG, "gog: build manifest for " + gameId + " has no depots; keys " + manifest.names()); return null; }
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
List<String> out = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) out.add(d); }
|
||||
Log.i(TAG, "gog: build " + manifest.optString("buildId", buildId) + " of " + gameId + ": " + depots.length() + " depots, dependencies "
|
||||
+ (manifest.has("dependencies") ? String.valueOf(deps) : "(none listed)"));
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static long fetchInstallSizeBytes(String gameId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
|
||||
@@ -28,6 +28,7 @@ class ControllerActions(
|
||||
val onSteamButton: (Boolean) -> Unit,
|
||||
val onQamButton: (Boolean) -> Unit,
|
||||
val onKeyboardButton: (Boolean) -> Unit,
|
||||
val onSelectSteam: (Boolean) -> Unit,
|
||||
val onEditLayout: () -> Unit,
|
||||
val onResetLayout: () -> Unit,
|
||||
val onMapping: () -> Unit,
|
||||
@@ -70,6 +71,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ToggleRow(host, "controller-steam", stringResource(R.string.ctrl_steam_button), null, c.steamButton, onChange = a.onSteamButton)
|
||||
ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton)
|
||||
ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton)
|
||||
ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam)
|
||||
SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() }
|
||||
|
||||
@@ -15,10 +15,16 @@ import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.droiddeck.launcher.input.DeckControls
|
||||
import com.droiddeck.launcher.input.PadBridge
|
||||
import com.droiddeck.launcher.input.PadState
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
@@ -26,7 +32,8 @@ import kotlin.math.min
|
||||
*
|
||||
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
|
||||
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
|
||||
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
|
||||
* row beneath. Given the [pad] (the unfolded screen's panel), a fifth: a whole gamepad, each side
|
||||
* an analog stick or a trackpad as the player picks. A trackpad clicks from its bar or from a second finger while the first is down.
|
||||
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
|
||||
* where something is being touched.
|
||||
*/
|
||||
@@ -36,18 +43,29 @@ class DeckControlsPanel(
|
||||
private val onClose: () -> Unit,
|
||||
private val onSteamMenu: () -> Unit,
|
||||
private val onQam: () -> Unit,
|
||||
private val pad: PadBridge? = null,
|
||||
) : LinearLayout(context) {
|
||||
private enum class Tab(val label: Int) { GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
private enum class Tab(val label: Int) { GAMEPAD(R.string.deck_tab_gamepad), GRIPS(R.string.deck_tab_grips), LEFT(R.string.deck_tab_left), RIGHT(R.string.deck_tab_right), BOTH(R.string.deck_tab_both) }
|
||||
|
||||
private val content = FrameLayout(context)
|
||||
private val tabViews = HashMap<Tab, TextView>()
|
||||
private val topBar: View by lazy { topBar() }
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
|
||||
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
|
||||
select(selected)
|
||||
select(if (pad != null) selectedWithPad else selected)
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
// Held flat the gamepad has no Panels button, so another tab would have no way back:
|
||||
// turning the phone from upright onto its side returns to the gamepad.
|
||||
if (pad != null && selectedWithPad != Tab.GAMEPAD && !GamepadGeometry.tall(w.toFloat(), h.toFloat())) {
|
||||
post { if (selectedWithPad != Tab.GAMEPAD) select(Tab.GAMEPAD) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
@@ -65,6 +83,7 @@ class DeckControlsPanel(
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setPadding(dp(4), dp(4), dp(4), dp(4))
|
||||
for (tab in Tab.values()) {
|
||||
if (tab == Tab.GAMEPAD && pad == null) continue
|
||||
val view = TextView(context).apply {
|
||||
text = context.getString(tab.label)
|
||||
textSize = 14f
|
||||
@@ -88,7 +107,7 @@ class DeckControlsPanel(
|
||||
}
|
||||
|
||||
private fun select(tab: Tab) {
|
||||
selected = tab
|
||||
if (pad != null) selectedWithPad = tab else selected = tab
|
||||
DeckControls.releaseAll()
|
||||
for ((each, view) in tabViews) {
|
||||
val on = each == tab
|
||||
@@ -96,12 +115,15 @@ class DeckControlsPanel(
|
||||
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
|
||||
}
|
||||
content.removeAllViews()
|
||||
// The gamepad needs every row of the panel; held upright its Panels button brings the tabs back.
|
||||
topBar.visibility = if (tab == Tab.GAMEPAD) View.GONE else View.VISIBLE
|
||||
content.addView(
|
||||
when (tab) {
|
||||
Tab.GRIPS -> gripsGrid()
|
||||
Tab.LEFT -> singlePad(right = false)
|
||||
Tab.RIGHT -> singlePad(right = true)
|
||||
Tab.BOTH -> bothPads()
|
||||
Tab.GAMEPAD -> gamepad(pad ?: return)
|
||||
},
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
|
||||
)
|
||||
@@ -151,6 +173,79 @@ class DeckControlsPanel(
|
||||
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole pad, placed for two thumbs rather than in columns (see [GamepadGeometry]): on each side
|
||||
* a surface where the thumb rests - most of play is on the sticks or trackpads - and a short
|
||||
* slide inward along its arc, the d-pad on the left and the face buttons on the right; the bumpers and triggers above, at the top of the arc; View, Steam, QAM and Menu in the
|
||||
* middle, which a thumb only reaches on purpose. Each surface is an analog stick or a Steam
|
||||
* trackpad, switched from its corner and remembered (SessionPrefs.gamepadSurface). Its tab bar
|
||||
* is hidden; held upright, a Panels button brings the other tabs back, and held flat there is no
|
||||
* room for one.
|
||||
*/
|
||||
private fun gamepad(pad: PadBridge): View = GamepadLayout(context).apply {
|
||||
fun pill(label: String, write: (PadState, Boolean) -> Unit) = PadButton(context, pad, label, write)
|
||||
place(surfaceSlot(pad, right = false), GamepadGeometry.Slot.LEFT_MAIN)
|
||||
place(DirectionPad(context, pad), GamepadGeometry.Slot.LEFT_SECOND)
|
||||
place(surfaceSlot(pad, right = true), GamepadGeometry.Slot.RIGHT_MAIN)
|
||||
place(FaceButtons(context, pad), GamepadGeometry.Slot.RIGHT_SECOND)
|
||||
place(pill("LT") { st, d -> st.leftTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER)
|
||||
place(pill("LB") { st, d -> st.press(PadState.LB, d) }, GamepadGeometry.Slot.LEFT_INNER_SHOULDER)
|
||||
place(pill("RB") { st, d -> st.press(PadState.RB, d) }, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER)
|
||||
place(pill("RT") { st, d -> st.rightTrigger = if (d) 1f else 0f }, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER)
|
||||
place(pill("VIEW") { st, d -> st.press(PadState.SELECT, d) }, GamepadGeometry.Slot.VIEW)
|
||||
place(centreTap("STEAM", context.getString(R.string.deck_open_steam_menu), onSteamMenu), GamepadGeometry.Slot.STEAM)
|
||||
place(centreTap("\u2022\u2022\u2022", context.getString(R.string.drawer_open_qam), onQam), GamepadGeometry.Slot.QAM)
|
||||
place(pill("MENU") { st, d -> st.press(PadState.START, d) }, GamepadGeometry.Slot.MENU)
|
||||
place(centreTap("\u25a6 " + context.getString(R.string.deck_panels), context.getString(R.string.deck_more_controls)) { select(Tab.BOTH) },
|
||||
GamepadGeometry.Slot.PANELS)
|
||||
}
|
||||
|
||||
private fun centreTap(label: String, description: String, onClick: () -> Unit) = TextView(context).apply {
|
||||
text = label
|
||||
contentDescription = description
|
||||
textSize = 13f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
letterSpacing = 0.08f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
/** One side's stick or trackpad, with a chip in its outer corner that swaps the two. */
|
||||
private fun surfaceSlot(pad: PadBridge, right: Boolean): View = FrameLayout(context).apply {
|
||||
val slot = this
|
||||
fun show() {
|
||||
slot.removeAllViews()
|
||||
val stick = SessionPrefs.gamepadSurface(context, right) == SessionPrefs.SURFACE_STICK
|
||||
// No caption: the switch in its corner says which it is.
|
||||
slot.addView(if (stick) StickView(context, pad, right) else TrackpadView(context, right, captioned = false),
|
||||
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
val chip = TextView(context).apply {
|
||||
text = "\u21c4"
|
||||
contentDescription = context.getString(R.string.deck_surface_switch)
|
||||
textSize = 14f
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(TEXT_DIM)
|
||||
background = outline(CORNER_PILL, SURFACE)
|
||||
setOnClickListener {
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
SessionPrefs.setGamepadSurface(context, right,
|
||||
if (stick) SessionPrefs.SURFACE_TRACKPAD else SessionPrefs.SURFACE_STICK)
|
||||
show()
|
||||
}
|
||||
}
|
||||
// In the corner away from the thumb, so play does not swap it by accident.
|
||||
slot.addView(chip, FrameLayout.LayoutParams(dp(30), dp(30),
|
||||
(if (right) Gravity.START else Gravity.END) or Gravity.TOP).apply { setMargins(dp(6), dp(6), dp(6), dp(6)) })
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
|
||||
TextView(context).apply {
|
||||
text = label
|
||||
@@ -262,7 +357,7 @@ class DeckControlsPanel(
|
||||
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
|
||||
* while a second finger is down.
|
||||
*/
|
||||
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
|
||||
private class TrackpadView(context: Context, private val right: Boolean, private val captioned: Boolean = true) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicked = false
|
||||
private var fingerX = 0f
|
||||
@@ -283,7 +378,7 @@ class DeckControlsPanel(
|
||||
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
|
||||
captionPaint.textSize = 12 * density
|
||||
captionPaint.color = if (touching) ACCENT else TEXT_DIM
|
||||
canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (captioned) canvas.drawText((if (right) context.getString(R.string.deck_right_trackpad) else context.getString(R.string.deck_left_trackpad)).uppercase(), cx, 28 * density, captionPaint)
|
||||
if (touching) {
|
||||
val radius = 56 * density
|
||||
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
|
||||
@@ -333,9 +428,271 @@ class DeckControlsPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/** A pad button held while a finger is on it, written into the shared pad as the on-screen pad does. */
|
||||
private class PadButton(
|
||||
context: Context,
|
||||
private val pad: PadBridge,
|
||||
label: String,
|
||||
private val write: (PadState, Boolean) -> Unit,
|
||||
) : HoldButton(context, label, null, { down -> pad.applyTouch { write(it, down) } }) {
|
||||
override fun onDetachedFromWindow() {
|
||||
pad.applyTouch { write(it, false) }
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An analog stick that centres where the thumb lands, so it is never missed: the knob follows
|
||||
* the finger from there out to the ring. Tapping it again and holding (within [CLICK_MS]) is
|
||||
* the stick's click - L3 or R3 - for as long as the thumb stays down.
|
||||
*/
|
||||
private class StickView(context: Context, private val pad: PadBridge, private val right: Boolean) : Surface(context) {
|
||||
private var touching = false
|
||||
private var clicking = false
|
||||
private var lastUp = 0L
|
||||
private var originX = 0f
|
||||
private var originY = 0f
|
||||
private var knobX = 0f
|
||||
private var knobY = 0f
|
||||
private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val knob = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private fun reach() = min(width, height) * 0.30f
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
drawSurface(canvas, clicking)
|
||||
val cx = if (touching) originX else width / 2f
|
||||
val cy = if (touching) originY else height / 2f
|
||||
ring.strokeWidth = 2 * density
|
||||
ring.color = if (touching) ACCENT else LINE
|
||||
canvas.drawCircle(cx, cy, reach(), ring)
|
||||
knob.color = if (touching) ACCENT else KNOB
|
||||
canvas.drawCircle(cx + knobX, cy + knobY, reach() * 0.45f, knob)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val r = reach().coerceAtLeast(1f)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
// Centred where the thumb lands, but kept far enough in for the ring to fit.
|
||||
originX = event.x.coerceIn(r, (width - r).coerceAtLeast(r))
|
||||
originY = event.y.coerceIn(r, (height - r).coerceAtLeast(r))
|
||||
touching = true
|
||||
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
if (event.eventTime - lastUp < CLICK_MS) setClick(true)
|
||||
follow(event.x, event.y, r)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> follow(event.getX(0), event.getY(0), r)
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
touching = false
|
||||
lastUp = event.eventTime
|
||||
setClick(false)
|
||||
move(0f, 0f, 1f)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun follow(x: Float, y: Float, r: Float) {
|
||||
var dx = x - originX
|
||||
var dy = y - originY
|
||||
val length = hypot(dx, dy)
|
||||
if (length > r) { dx *= r / length; dy *= r / length }
|
||||
move(dx, dy, r)
|
||||
}
|
||||
|
||||
private fun move(dx: Float, dy: Float, r: Float) {
|
||||
knobX = dx
|
||||
knobY = dy
|
||||
pad.applyTouch { s ->
|
||||
if (right) { s.rightX = dx / r; s.rightY = dy / r } else { s.leftX = dx / r; s.leftY = dy / r }
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun setClick(down: Boolean) {
|
||||
if (clicking == down) return
|
||||
clicking = down
|
||||
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
pad.applyTouch { s -> s.press(if (right) PadState.R3 else PadState.L3, down) }
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
setClick(false)
|
||||
if (knobX != 0f || knobY != 0f) move(0f, 0f, 1f)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** The d-pad: the direction is the finger's angle from the centre, diagonals included. */
|
||||
private class DirectionPad(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private var held = 0 // bits: 1 up, 2 right, 4 down, 8 left
|
||||
private val arm = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rect = RectF()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val size = min(width, height) * 0.86f
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val half = size / 2f
|
||||
val thick = size / 3f / 2f
|
||||
fun armRect(bit: Int, l: Float, t: Float, r: Float, b: Float) {
|
||||
arm.color = if (held and bit != 0) ACCENT else KNOB
|
||||
rect.set(l, t, r, b)
|
||||
canvas.drawRoundRect(rect, 8 * density, 8 * density, arm)
|
||||
}
|
||||
armRect(1, cx - thick, cy - half, cx + thick, cy - thick)
|
||||
armRect(4, cx - thick, cy + thick, cx + thick, cy + half)
|
||||
armRect(8, cx - half, cy - thick, cx - thick, cy + thick)
|
||||
armRect(2, cx + thick, cy - thick, cx + half, cy + thick)
|
||||
arm.color = KNOB
|
||||
canvas.drawRect(cx - thick, cy - thick, cx + thick, cy + thick, arm)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val bits = when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.getX(0) - width / 2f
|
||||
val dy = event.getY(0) - height / 2f
|
||||
if (hypot(dx, dy) < min(width, height) * 0.08f) 0 else {
|
||||
// Eight sectors of 45 degrees, starting with right and going clockwise.
|
||||
val sector = ((Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())) + 360 + 22.5) % 360 / 45).toInt()
|
||||
intArrayOf(2, 2 or 4, 4, 4 or 8, 8, 8 or 1, 1, 1 or 2)[sector]
|
||||
}
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
set(bits)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(bits: Int) {
|
||||
if (bits == held) return
|
||||
if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
held = bits
|
||||
pad.applyTouch { s ->
|
||||
s.up = bits and 1 != 0; s.right = bits and 2 != 0; s.down = bits and 4 != 0; s.left = bits and 8 != 0
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(0)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** A, B, X and Y in a diamond, as on a Deck; several fingers can hold several at once. */
|
||||
private class FaceButtons(context: Context, private val pad: PadBridge) : View(context) {
|
||||
private val buttons = listOf(PadState.Y to "Y", PadState.B to "B", PadState.A to "A", PadState.X to "X")
|
||||
private var held = emptySet<Int>()
|
||||
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
/** Top, right, bottom, left - the centre of each, and the radius of all. */
|
||||
private fun layout(): Pair<List<Pair<Float, Float>>, Float> {
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val spread = min(width, height) * 0.30f
|
||||
val radius = min(width, height) * 0.16f
|
||||
return listOf(cx to cy - spread, cx + spread to cy, cx to cy + spread, cx - spread to cy) to radius
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val (centres, radius) = layout()
|
||||
text.textSize = radius * 0.9f
|
||||
buttons.forEachIndexed { i, (button, label) ->
|
||||
val (x, y) = centres[i]
|
||||
val down = button in held
|
||||
fill.color = if (down) ACCENT else KNOB
|
||||
canvas.drawCircle(x, y, radius, fill)
|
||||
text.color = if (down) ON_ACCENT else TEXT
|
||||
canvas.drawText(label, x, y + text.textSize * 0.35f, text)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val (centres, radius) = layout()
|
||||
val now = HashSet<Int>()
|
||||
val ending = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
|
||||
if (!ending) for (p in 0 until event.pointerCount) {
|
||||
if (event.actionMasked == MotionEvent.ACTION_POINTER_UP && p == event.actionIndex) continue
|
||||
val x = event.getX(p)
|
||||
val y = event.getY(p)
|
||||
// The nearest button within reach, so a thumb between two still lands on one.
|
||||
val nearest = centres.indices.minByOrNull { hypot(centres[it].first - x, centres[it].second - y) } ?: continue
|
||||
if (hypot(centres[nearest].first - x, centres[nearest].second - y) <= radius * 1.6f) now.add(buttons[nearest].first)
|
||||
}
|
||||
set(now)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun set(now: Set<Int>) {
|
||||
if (now == held) return
|
||||
if ((now - held).isNotEmpty()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
val before = held
|
||||
held = now
|
||||
pad.applyTouch { s -> for (b in before + now) s.press(b, b in now) }
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
set(emptySet())
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lays its controls out where [GamepadGeometry] puts them for the panel's size. */
|
||||
private class GamepadLayout(context: Context) : ViewGroup(context) {
|
||||
private val slots = LinkedHashMap<View, GamepadGeometry.Slot>()
|
||||
|
||||
fun place(view: View, slot: GamepadGeometry.Slot) {
|
||||
slots[view] = slot
|
||||
addView(view)
|
||||
}
|
||||
|
||||
private fun boxes(w: Int, h: Int) = GamepadGeometry.layout(w.toFloat(), h.toFloat(), resources.displayMetrics.density)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val w = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val h = MeasureSpec.getSize(heightMeasureSpec)
|
||||
setMeasuredDimension(w, h)
|
||||
val boxes = boxes(w, h)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot]
|
||||
view.visibility = if (box == null) View.GONE else View.VISIBLE
|
||||
if (box == null) continue
|
||||
view.measure(MeasureSpec.makeMeasureSpec(box.width.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(box.height.toInt().coerceAtLeast(1), MeasureSpec.EXACTLY))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val boxes = boxes(r - l, b - t)
|
||||
for ((view, slot) in slots) {
|
||||
val box = boxes[slot] ?: continue
|
||||
val left = box.left.toInt()
|
||||
val top = box.top.toInt()
|
||||
view.layout(left, top, left + box.width.toInt().coerceAtLeast(1), top + box.height.toInt().coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A second press on a stick this soon after the last is its click (L3, R3). */
|
||||
private const val CLICK_MS = 300L
|
||||
|
||||
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
|
||||
private var selected = Tab.BOTH
|
||||
/** The same for the unfolded screen's panel, which opens on the gamepad. */
|
||||
private var selectedWithPad = Tab.GAMEPAD
|
||||
|
||||
private const val CORNER = 20f
|
||||
private const val CORNER_PILL = 22f
|
||||
@@ -348,5 +705,6 @@ class DeckControlsPanel(
|
||||
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
|
||||
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
|
||||
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
|
||||
private val KNOB = Color.rgb(0x2A, 0x2D, 0x33)
|
||||
}
|
||||
}
|
||||
@@ -322,8 +322,18 @@ private fun GameList(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
|
||||
// A game that arrives while the list is up (Steam installed it in the session that just
|
||||
// ended, or it was added) eases in; the ones there when the page opened are simply there.
|
||||
val seen = remember { HashSet<Int>() }
|
||||
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
for (g in games) key(g.appId) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
Box(Modifier.arriveIn(settled && g.appId !in seen)) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.SideEffect {
|
||||
seen.addAll(games.map { it.appId })
|
||||
if (games.isNotEmpty()) settled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,6 +452,14 @@ private fun AddGameButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fades and slides a row in once when [arriving]; nothing for one already there (or with animations off). */
|
||||
@Composable
|
||||
internal fun Modifier.arriveIn(arriving: Boolean): Modifier {
|
||||
val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) }
|
||||
LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) }
|
||||
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() }
|
||||
}
|
||||
|
||||
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
|
||||
@Composable
|
||||
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
|
||||
|
||||
@@ -33,13 +33,16 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -126,6 +129,7 @@ class FrontEndState(
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
|
||||
val showPhantomGate: Boolean = false,
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val animationsEnabled: Boolean = true,
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
@@ -201,6 +205,7 @@ class FrontEndActions(
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onLanguage: (String) -> Unit = {},
|
||||
val onAppScale: (Int) -> Unit = {},
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
@@ -468,7 +473,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
var anyFocused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(colors.background)
|
||||
.then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding())
|
||||
.windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars)
|
||||
.onFocusChanged { anyFocused = it.hasFocus },
|
||||
) {
|
||||
// Start controllers on the current page's main action, else on the rail.
|
||||
|
||||
@@ -351,6 +351,11 @@ internal fun SetupPanel(
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
@@ -505,6 +510,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
|
||||
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
game = game,
|
||||
) { wincompOpen = null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Where the unfolded screen's gamepad puts each control, for a panel of a given size: plain
|
||||
* numbers, so the layout is tested without a device (GamepadGeometryTest).
|
||||
*
|
||||
* Each thumb pivots at its bottom corner; its main control (a stick or trackpad) sits where it
|
||||
* rests, diagonally in from the corner, and the right side mirrors the left.
|
||||
*
|
||||
* A wide strip (a foldable held flat as a tablet): the second control - the d-pad, the face
|
||||
* buttons - a slide inward along the arc, the shoulders above, and View, Steam, QAM and Menu
|
||||
* between the arcs. The reach R is the strip's height, or a third of its width.
|
||||
*
|
||||
* A tall panel (held upright): the second control a slide up the arc instead, the shoulders above
|
||||
* that, View, Steam, QAM and Menu between the two sticks, and Panels - the Deck controls' other
|
||||
* tabs - between the shoulders. Each side's column S is half the width, or as much as the height
|
||||
* holds. A wide strip has no room for Panels and leaves it out.
|
||||
*/
|
||||
object GamepadGeometry {
|
||||
enum class Slot {
|
||||
LEFT_MAIN, LEFT_SECOND, RIGHT_MAIN, RIGHT_SECOND,
|
||||
LEFT_OUTER_SHOULDER, LEFT_INNER_SHOULDER, RIGHT_INNER_SHOULDER, RIGHT_OUTER_SHOULDER,
|
||||
VIEW, STEAM, QAM, MENU,
|
||||
/** The Deck controls' other tabs: on a tall panel only. */
|
||||
PANELS,
|
||||
}
|
||||
|
||||
/** A control's box: its centre and size, in pixels. */
|
||||
data class Box(val cx: Float, val cy: Float, val width: Float, val height: Float) {
|
||||
val left get() = cx - width / 2f
|
||||
val top get() = cy - height / 2f
|
||||
val right get() = cx + width / 2f
|
||||
val bottom get() = cy + height / 2f
|
||||
|
||||
fun overlaps(other: Box): Boolean =
|
||||
left < other.right && other.left < right && top < other.bottom && other.top < bottom
|
||||
}
|
||||
|
||||
/** The middle buttons, in a row in this order; two by two as on a Deck, View and Menu above Steam and QAM. */
|
||||
private val middle = listOf(Slot.VIEW, Slot.STEAM, Slot.QAM, Slot.MENU)
|
||||
private val twoByTwo = mapOf(Slot.VIEW to 0, Slot.MENU to 1, Slot.STEAM to 2, Slot.QAM to 3)
|
||||
|
||||
/** A panel this shape is laid out as a tall panel (held upright), which has room for Panels;
|
||||
* anything flatter is a wide strip. */
|
||||
fun tall(w: Float, h: Float): Boolean = h >= w * 0.45f
|
||||
|
||||
fun layout(w: Float, h: Float, density: Float): Map<Slot, Box> {
|
||||
if (w <= 0f || h <= 0f) return emptyMap()
|
||||
val pad = 8 * density
|
||||
val buttonH = 40 * density
|
||||
// A row needs its buttons comfortable; two by two will take them a little narrower.
|
||||
val rowButton = 64 * density
|
||||
val gridButton = 48 * density
|
||||
val gutter = 8 * density
|
||||
val boxes = LinkedHashMap<Slot, Box>()
|
||||
fun mirror(x: Float, right: Boolean) = if (right) w - x else x
|
||||
fun pair(left: Slot, right: Slot, x: Float, cy: Float, bw: Float, bh: Float) {
|
||||
boxes[left] = Box(mirror(x, false), cy, bw, bh)
|
||||
boxes[right] = Box(mirror(x, true), cy, bw, bh)
|
||||
}
|
||||
fun middleGrid(columns: Int, span: Float, top: Float) {
|
||||
val each = (span / columns).coerceAtMost(110 * density)
|
||||
for ((order, slot) in middle.withIndex()) {
|
||||
val index = if (columns == 2) twoByTwo.getValue(slot) else order
|
||||
val x = w / 2f + (index % columns - (columns - 1) / 2f) * each
|
||||
boxes[slot] = Box(x, top + buttonH / 2f + (index / columns) * (buttonH + 10 * density), each - gutter, buttonH)
|
||||
}
|
||||
}
|
||||
if (!tall(w, h)) {
|
||||
val reach = min(h - pad, w * 0.33f)
|
||||
val main = reach * 0.66f
|
||||
val second = reach * 0.54f
|
||||
val mainTop = h - reach * 0.40f - main / 2f
|
||||
val shoulderW = reach * 0.36f
|
||||
val shoulderH = (reach * 0.16f).coerceAtLeast(buttonH)
|
||||
// Above the main controls, never over them, and inside the panel.
|
||||
val shoulderY = min(h - reach * 0.94f + shoulderH / 2f, mainTop - gutter - shoulderH / 2f)
|
||||
.coerceAtLeast(pad + shoulderH / 2f)
|
||||
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, reach * 0.40f, h - reach * 0.40f, main, main)
|
||||
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, reach * 1.03f, h - reach * 0.30f, second, second)
|
||||
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, reach * 0.22f, shoulderY, shoulderW, shoulderH)
|
||||
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, reach * 0.62f, shoulderY, shoulderW, shoulderH)
|
||||
// Between the arcs: in a row if they fit, else two by two, else one above another.
|
||||
val gap = w - 2 * reach * 1.32f
|
||||
val columns = when {
|
||||
gap >= middle.size * rowButton -> middle.size
|
||||
gap >= 2 * gridButton -> 2
|
||||
else -> 1
|
||||
}
|
||||
middleGrid(columns, gap - 2 * gutter, shoulderY - buttonH / 2f)
|
||||
} else {
|
||||
val side = min(w * 0.5f, h * 0.70f)
|
||||
val main = side * 0.62f
|
||||
val second = side * 0.46f
|
||||
val secondCy = h - side * 0.97f
|
||||
val shoulderW = side * 0.34f
|
||||
val shoulderH = (side * 0.13f).coerceAtLeast(buttonH)
|
||||
val shoulderY = (secondCy - second / 2f - gutter - shoulderH / 2f).coerceAtLeast(pad + shoulderH / 2f)
|
||||
pair(Slot.LEFT_MAIN, Slot.RIGHT_MAIN, side * 0.40f, h - side * 0.40f, main, main)
|
||||
pair(Slot.LEFT_SECOND, Slot.RIGHT_SECOND, side * 0.74f, secondCy, second, second)
|
||||
pair(Slot.LEFT_OUTER_SHOULDER, Slot.RIGHT_OUTER_SHOULDER, side * 0.20f, shoulderY, shoulderW, shoulderH)
|
||||
pair(Slot.LEFT_INNER_SHOULDER, Slot.RIGHT_INNER_SHOULDER, side * 0.57f, shoulderY, shoulderW, shoulderH)
|
||||
// Between the two sticks at the bottom: two by two, or one above another.
|
||||
val gap = w - 2 * (side * 0.40f + main / 2f)
|
||||
val columns = if (gap >= 2 * gridButton) 2 else 1
|
||||
val rows = (middle.size + columns - 1) / columns
|
||||
middleGrid(columns, gap - 2 * gutter, h - pad - rows * buttonH - (rows - 1) * 10 * density)
|
||||
// Between the inner shoulders, where it fits.
|
||||
val panelsW = min(112 * density, w - 2 * (side * 0.57f + shoulderW / 2f) - 2 * gutter)
|
||||
if (panelsW >= buttonH) boxes[Slot.PANELS] = Box(w / 2f, shoulderY, panelsW, buttonH)
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
}
|
||||
@@ -86,15 +86,13 @@ class ModeSettings(
|
||||
/** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */
|
||||
val syncBackend: String? = null,
|
||||
val stretch16x9: Boolean? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: enable the SteamOS client interface and its performance controls. */
|
||||
val steamDeckMode: Boolean = false,
|
||||
val steamRepairQueued: Boolean = false,
|
||||
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
|
||||
/** Steam: the QAM's performance overlay (mangoapp) is started. */
|
||||
val mangoapp: Boolean = true,
|
||||
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
|
||||
val steamController: String? = null,
|
||||
/** Unfolded controls (Steam only; null hides the row). */
|
||||
val unfoldedControls: Boolean? = null,
|
||||
/** Steam only: start a Steam session when DroidDeck opens. */
|
||||
val runSteamAtStartup: Boolean = false,
|
||||
/** Null outside Steam; the saved choice is separate from Android's access and device switch. */
|
||||
@@ -139,11 +137,10 @@ class ModeSettingsActions(
|
||||
val onForceSsbs: (Boolean) -> Unit = {},
|
||||
val onSyncBackend: (String) -> Unit = {},
|
||||
val onStretch16x9: (Boolean) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onSteamRepair: () -> Unit = {},
|
||||
val onSteamDeckMode: (Boolean) -> Unit = {},
|
||||
val onMangoapp: (Boolean) -> Unit = {},
|
||||
val onSteamController: (String) -> Unit = {},
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onRunSteamAtStartup: (Boolean) -> Unit = {},
|
||||
val onWifiDiscovery: (Boolean) -> Unit = {},
|
||||
val onWifiDiscoverySettings: () -> Unit = {},
|
||||
@@ -266,11 +263,19 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
@@ -373,25 +378,12 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "steamdeck", stringResource(R.string.mode_deck_mode),
|
||||
stringResource(R.string.mode_deck_mode_hint),
|
||||
s.steamDeckMode, onChange = a.onSteamDeckMode,
|
||||
)
|
||||
if (s.steamDeckMode) ToggleRow(
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
// Deck mode fixes the branch (SessionPrefs.steamChannel); the choice is for Deck mode off.
|
||||
if (s.steamDeckMode) SettingsRow(stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_deck)) {}
|
||||
else ChoiceRow(
|
||||
host, "channel", stringResource(R.string.mode_branch), stringResource(R.string.mode_branch_hint),
|
||||
listOf("publicbeta" to stringResource(R.string.mode_branch_public), "steamdeck_publicbeta" to stringResource(R.string.mode_branch_deck_beta)), s.steamChannel,
|
||||
note = stringResource(R.string.mode_branch_note),
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
|
||||
@@ -194,6 +194,9 @@ class DrawerActions(
|
||||
val suspendPolicy: String,
|
||||
val steamDownloadsInBackground: Boolean = false,
|
||||
val backActionsInverted: Boolean,
|
||||
/** What the pad is to Steam (SessionPrefs.CONTROLLER_*), switched live; null hides the row. */
|
||||
val padType: String? = null,
|
||||
val onPadType: (String) -> Unit = {},
|
||||
val touchMode: String,
|
||||
val touchAuto: String,
|
||||
val fexPreset: String,
|
||||
@@ -242,6 +245,9 @@ class DrawerActions(
|
||||
val pipAutoEnter: Boolean = false,
|
||||
val onPip: () -> Unit = {},
|
||||
val onPipAutoEnter: (Boolean) -> Unit = {},
|
||||
/** Unfolded controls (on a foldable; null hides the row). */
|
||||
val unfoldedControls: Boolean? = null,
|
||||
val onUnfoldedControls: (Boolean) -> Unit = {},
|
||||
val onShareLogs: () -> Unit,
|
||||
val onStop: () -> Unit,
|
||||
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
|
||||
@@ -416,6 +422,15 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
SessionDrawerPage.CONTROLLER -> {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_controller)) {
|
||||
a.padType?.let { type ->
|
||||
ChoiceRow(host, "pad-type", stringResource(R.string.mode_controller), null,
|
||||
listOf(SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360)),
|
||||
type, chipModifier = focus.track(page, "pad-type"), onPick = a.onPadType)
|
||||
}
|
||||
ChoiceRow(host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
@@ -445,6 +460,9 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
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)
|
||||
if (a.steam && a.unfoldedControls != null) ToggleRow(host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint), a.unfoldedControls,
|
||||
chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_keyboard)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -12,7 +11,6 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.BoxWithConstraints
|
||||
@@ -41,21 +39,38 @@ import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.inset
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** From this many games the Steam tab is a wall; fewer would only repeat the same covers. */
|
||||
private const val WALL_MIN_GAMES = 4
|
||||
|
||||
private const val DRIFT_STEP_NS = 15_000_000L
|
||||
|
||||
/**
|
||||
* The Steam tab: the library as a slowly drifting, tilted wall of capsules (a few games instead
|
||||
* lean on their own; none, the DroidDeck mark wandering a blank wall), and over it the wordmark and the one thing to do here -
|
||||
@@ -126,32 +141,49 @@ internal fun CapsuleWall(
|
||||
games: List<Library.SteamGame>,
|
||||
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
|
||||
modifier: Modifier = Modifier,
|
||||
) = TiltedWall(games.size, driftMs, modifier) { i, m ->
|
||||
Capsule(if (games.isEmpty()) null else games[i], RoundedCornerShape(10.dp), m)
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
|
||||
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
|
||||
}
|
||||
|
||||
/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null)
|
||||
|
||||
/**
|
||||
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] draws number i
|
||||
* (0 until [count], or 0 for each blank one when [count] is 0) at the modifier it is given.
|
||||
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i
|
||||
* (0 until [count], or 0 for each blank one when [count] is 0). Each column is one drawing with the
|
||||
* covers' corners cut into their bitmaps: a rounded clip under the tilt is rasterised on the CPU,
|
||||
* for every capsule on every frame.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TiltedWall(
|
||||
count: Int,
|
||||
driftMs: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
capsule: @Composable (index: Int, modifier: Modifier) -> Unit,
|
||||
capsule: (index: Int) -> WallCapsule,
|
||||
) {
|
||||
val capW = 112.dp
|
||||
val capH = 168.dp
|
||||
val gap = 14.dp
|
||||
val transition = rememberInfiniteTransition(label = "wall")
|
||||
val drift = transition.animateFloat(
|
||||
0f, 1f,
|
||||
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
|
||||
label = "drift",
|
||||
)
|
||||
// Animations off in the system settings: a still wall.
|
||||
val still = Motion.scale == 0f
|
||||
// The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as
|
||||
// stepped on every frame, which on a 120 Hz screen drew it twice as often.
|
||||
val drift = remember { mutableFloatStateOf(0f) }
|
||||
if (!still) LaunchedEffect(driftMs) {
|
||||
val period = driftMs * 1_000_000.0 * Motion.scale
|
||||
var start = 0L
|
||||
var shown = 0L
|
||||
while (true) withFrameNanos { now ->
|
||||
if (start == 0L) start = now
|
||||
if (shown == 0L || now - shown >= DRIFT_STEP_NS) {
|
||||
shown = now
|
||||
drift.floatValue = ((now - start) % period / period).toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
// Centred on the page and big enough that, once tilted, it still covers every corner.
|
||||
val tilt = Math.toRadians(13.0)
|
||||
@@ -162,6 +194,17 @@ internal fun TiltedWall(
|
||||
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
|
||||
val perRun = ((wallH + gap) / (capH + gap)).toInt() + 1
|
||||
val run = (capH + gap) * perRun
|
||||
val cap = with(density) { Size(capW.toPx(), capH.toPx()) }
|
||||
val step = with(density) { (capH + gap).toPx() }
|
||||
val radius = with(density) { 10.dp.toPx() }
|
||||
val line = with(density) { 1.dp.toPx() }
|
||||
val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count }
|
||||
val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() }
|
||||
val looks = HashMap<Int, Pair<WallCapsule, Painter?>>(shown.size * 2)
|
||||
for (index in shown) key(index) {
|
||||
val look = capsule(index)
|
||||
looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) }
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(gap),
|
||||
modifier = Modifier
|
||||
@@ -171,26 +214,52 @@ internal fun TiltedWall(
|
||||
.graphicsLayer { rotationZ = -13f },
|
||||
) {
|
||||
repeat(columns) { c ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(gap),
|
||||
modifier = Modifier.wrapContentHeight(Alignment.Top, unbounded = true).graphicsLayer {
|
||||
val shift = if (still) 0f else run.toPx() * drift.value
|
||||
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
|
||||
},
|
||||
) {
|
||||
repeat(perRun * 2) { i ->
|
||||
capsule(if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count, Modifier.size(capW, capH))
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
Modifier.width(capW).wrapContentHeight(Alignment.Top, unbounded = true).height(run * 2 - gap)
|
||||
.graphicsLayer {
|
||||
val shift = if (still) 0f else run.toPx() * drift.floatValue
|
||||
translationY = if (c % 2 == 0) -shift else shift - run.toPx()
|
||||
}
|
||||
.drawBehind {
|
||||
for (i in 0 until perRun * 2) {
|
||||
val (look, art) = looks[indexOf(c, i)] ?: continue
|
||||
val top = i * step
|
||||
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A cover sized for a capsule, its corners already cut, so it draws without a clip. */
|
||||
@Composable
|
||||
private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter {
|
||||
val context = LocalContext.current
|
||||
val w = size.width.roundToInt()
|
||||
val h = size.height.roundToInt()
|
||||
return rememberAsyncImagePainter(
|
||||
remember(model, w, h, radius) {
|
||||
ImageRequest.Builder(context).data(model).size(w, h).scale(Scale.FILL)
|
||||
.transformations(RoundedCornersTransformation(radius)).build()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) {
|
||||
drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius))
|
||||
if (art != null) with(art) { draw(size) }
|
||||
look.edge?.let {
|
||||
drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line))
|
||||
}
|
||||
}
|
||||
|
||||
/** One to three games, each a large capsule leaning on the others and floating a little. */
|
||||
@Composable
|
||||
private fun CapsuleFan(games: List<Library.SteamGame>) {
|
||||
val pal = LocalPalette.current
|
||||
val density = LocalDensity.current
|
||||
val transition = rememberInfiniteTransition(label = "fan")
|
||||
val floats = listOf(9_000, 11_000, 13_000).map { ms ->
|
||||
transition.animateFloat(0f, 1f, infiniteRepeatable(tween(ms, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "float")
|
||||
@@ -212,12 +281,14 @@ private fun CapsuleFan(games: List<Library.SteamGame>) {
|
||||
)
|
||||
},
|
||||
) {
|
||||
val radius = with(density) { 14.dp.toPx() }
|
||||
for (i in games.indices.reversed()) {
|
||||
val slot = slots[i]
|
||||
val h = maxHeight * slot.height
|
||||
val v: State<Float> = floats[i]
|
||||
Capsule(
|
||||
games[i], RoundedCornerShape(14.dp),
|
||||
val look = WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
|
||||
val art = look.art?.let { rememberCapsuleArt(it, with(density) { Size((h * 2f / 3f).toPx(), h.toPx()) }, radius) }
|
||||
Spacer(
|
||||
Modifier
|
||||
.offset(x = maxWidth * slot.x, y = maxHeight * slot.y)
|
||||
.size(h * 2f / 3f, h)
|
||||
@@ -228,23 +299,11 @@ private fun CapsuleFan(games: List<Library.SteamGame>) {
|
||||
shadowElevation = 24.dp.toPx()
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
clip = false
|
||||
},
|
||||
}
|
||||
.drawBehind { drawCapsule(look, art, radius, 0f) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FanSlot(val x: Float, val y: Float, val height: Float, val lean: Float)
|
||||
|
||||
/** A game's portrait capsule, or a blank one standing in for a game yet to be installed. */
|
||||
@Composable
|
||||
private fun Capsule(g: Library.SteamGame?, shape: RoundedCornerShape, modifier: Modifier) {
|
||||
val pal = LocalPalette.current
|
||||
if (g == null) {
|
||||
Box(modifier.clip(shape).background(Brush.linearGradient(listOf(pal.surface, pal.background))).border(1.dp, pal.line, shape))
|
||||
return
|
||||
}
|
||||
Box(modifier.clip(shape).background(artBrush(hueOf(g.name)))) {
|
||||
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
@@ -774,14 +775,12 @@ private fun SignInCard(store: Store, height: Dp) {
|
||||
.takeIf { it.size >= 8 }.orEmpty()
|
||||
}
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val capsule = RoundedCornerShape(10.dp)
|
||||
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
|
||||
val ground = SolidColor(c.fill)
|
||||
Box(Modifier.fillMaxWidth().height(height.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) {
|
||||
// The storefront arrives a moment after the page: its wall fades in over the blank one.
|
||||
androidx.compose.animation.Crossfade(art, animationSpec = Motion.tw(600), label = "signInWall") { tiles ->
|
||||
TiltedWall(tiles.size, if (tiles.isEmpty()) 75_000 else 50_000) { i, m ->
|
||||
if (tiles.isEmpty()) Box(m.clip(capsule).background(Brush.linearGradient(listOf(c.fill, colors.background))).border(1.dp, c.dot.copy(alpha = 0.22f), capsule))
|
||||
else Box(m.clip(capsule).background(c.fill)) { AsyncImage(model = tiles[i], contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) }
|
||||
}
|
||||
TiltedWall(tiles.size, if (tiles.isEmpty()) 75_000 else 50_000) { i -> if (tiles.isEmpty()) blank else WallCapsule(ground, tiles[i]) }
|
||||
}
|
||||
// The wall gives way to the words: dark at the left and the foot, and the store's glow under them.
|
||||
Box(
|
||||
|
||||
@@ -15,6 +15,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -38,6 +42,7 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.DependencyDetector
|
||||
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
|
||||
import com.droiddeck.launcher.frontend.SteamRedists
|
||||
import com.droiddeck.launcher.frontend.WinCompSources
|
||||
import com.droiddeck.launcher.session.WinComponentNames
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.session.WinComponents.Support
|
||||
@@ -53,13 +58,38 @@ import java.io.File
|
||||
// picked ones into the game's prefix (droiddeck-wincomponents).
|
||||
|
||||
/** Bannerlator's detector names installers; here the DLL-copy twin is the one that installs. */
|
||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String {
|
||||
val dll = when (name) {
|
||||
"oalinst" -> "oalinst_dll"
|
||||
else -> "${name}_dll"
|
||||
}
|
||||
val twin = all[dll]
|
||||
return if (twin != null && WinComponents.support(twin, all) == Support.READY) dll else name
|
||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
|
||||
|
||||
/** A store as a list's owner names it in a reason line. */
|
||||
@Composable
|
||||
private fun originName(origin: String): String = stringResource(
|
||||
when (origin) {
|
||||
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
|
||||
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
|
||||
WinCompSources.Origin.AMAZON.name -> R.string.wincomp_src_amazon
|
||||
else -> R.string.wincomp_src_steam
|
||||
},
|
||||
)
|
||||
|
||||
/** Why a component was turned on by itself, from [WinComponents.AutoReason] (args: what the list called it, then the origin's own). */
|
||||
@Composable
|
||||
private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
|
||||
WinCompSources.Origin.STEAM.name -> stringResource(R.string.wincomp_auto_steam, r.args.getOrElse(1) { "" }, r.args.getOrElse(2) { "" }, r.args.getOrElse(3) { "" })
|
||||
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
|
||||
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
|
||||
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
/** The reason line of a finding that is only recommended. */
|
||||
@Composable
|
||||
private fun findingReason(f: WinCompSources.Finding): String = when (f.origin) {
|
||||
WinCompSources.Origin.FOLDER -> stringResource(
|
||||
if (f.args.getOrNull(1) == DependencyDetector.Kind.SHIPPED.name) R.string.wincomp_found_shipped else R.string.wincomp_found_bundled,
|
||||
f.args.getOrElse(0) { f.original },
|
||||
)
|
||||
WinCompSources.Origin.STEAM_NAME -> stringResource(R.string.wincomp_steam_by_name, f.args.getOrElse(0) { f.original }, f.args.getOrElse(2) { "" })
|
||||
else -> autoReason(WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args))
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@@ -68,6 +98,8 @@ internal fun WinComponentsDialog(
|
||||
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
|
||||
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
|
||||
compat: File? = null, steamAppId: Int? = null,
|
||||
/** The game as the Games tab has it: its source decides whose list is trusted ([WinCompSources]). */
|
||||
game: com.droiddeck.launcher.frontend.Library.SteamGame? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -77,11 +109,17 @@ internal fun WinComponentsDialog(
|
||||
// null while the catalog loads; empty when it could not be had.
|
||||
var catalog by remember { mutableStateOf<Map<String, WinComponents.Component>?>(null) }
|
||||
var offline by remember { mutableStateOf(false) }
|
||||
var recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
|
||||
var findings by remember { mutableStateOf(emptyList<WinCompSources.Finding>()) }
|
||||
// Whether the game's list has been worked out at least once (kept from an earlier time counts).
|
||||
var findingsKnown by remember { mutableStateOf(game == null) }
|
||||
// "Loading the components" only when the catalog takes a while.
|
||||
var slowCatalog by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { kotlinx.coroutines.delay(300); slowCatalog = true }
|
||||
var installed by remember { mutableStateOf(emptySet<String>()) }
|
||||
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
|
||||
var present by remember { mutableStateOf(emptySet<String>()) }
|
||||
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
|
||||
var selection by remember { mutableStateOf(WinComponents.selection(context, appKey)) }
|
||||
val picks = selection.effective
|
||||
// The row being worked on: its id, the latest progress (a dependency's while that installs
|
||||
// first), and whether it is turning off rather than on. The row greys out and shows the bar
|
||||
// the moment the switch is pressed, and only that row; the rest of the page stays as it is.
|
||||
@@ -96,6 +134,7 @@ internal fun WinComponentsDialog(
|
||||
// pad, so the d-pad always has somewhere to start; LB and RB jump between the sections, since
|
||||
// the full list runs past sixty switches.
|
||||
val recFocus = remember { FocusRequester() }
|
||||
val recFocusAfterAuto = remember { FocusRequester() }
|
||||
val activeFocus = remember { FocusRequester() }
|
||||
val allFocus = remember { FocusRequester() }
|
||||
val waitFocus = remember { FocusRequester() }
|
||||
@@ -105,20 +144,46 @@ internal fun WinComponentsDialog(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val (entries, found) = withContext(Dispatchers.IO) {
|
||||
// The folder's own installers first, then what Steam installs with the game.
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
|
||||
WinComponents.fetch() to (own + steam)
|
||||
val list = WinComponents.fetch(context)
|
||||
// The source's list first (and what it turns on by itself, already installed), then the folder scan.
|
||||
if (game != null) {
|
||||
// The list kept from last time, at once; it is worked out again (and its components
|
||||
// queued) on the background worker, which this page then follows.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.queueGame(context, game, asked = true)
|
||||
val kept = com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey)
|
||||
if (kept != null) findingsKnown = true
|
||||
list to kept.orEmpty()
|
||||
} else {
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
|
||||
list to (own + steam).map { WinCompSources.Finding(it.componentName, it.reason, WinCompSources.Origin.FOLDER, false, listOf(it.reason, it.kind.name)) }
|
||||
}
|
||||
}
|
||||
offline = entries == null
|
||||
catalog = entries.orEmpty().associateBy { it.name }
|
||||
recommended = found
|
||||
findings = found
|
||||
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
|
||||
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
}
|
||||
fun setPicks(next: List<String>) {
|
||||
picks = next
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
/** The user's own switch for [id]: kept apart from what was turned on by itself, and over it. */
|
||||
// Automatic picks still downloading show on their rows; when one lands the page reads the picks again.
|
||||
val autoDownloading by com.droiddeck.launcher.frontend.AutoComponents.downloading.collectAsState()
|
||||
// Without a network a component not downloaded yet cannot be turned on; the rows say so and
|
||||
// come back the moment the network does.
|
||||
val online = rememberOnline()
|
||||
val autoChanges by com.droiddeck.launcher.frontend.AutoComponents.changes.collectAsState()
|
||||
val pendingGames by com.droiddeck.launcher.frontend.AutoComponents.pendingGames.collectAsState()
|
||||
val pendingComponents by com.droiddeck.launcher.frontend.AutoComponents.pendingComponents.collectAsState()
|
||||
LaunchedEffect(autoChanges) {
|
||||
if (game != null) withContext(Dispatchers.IO) { com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey) }
|
||||
?.let { findings = it; if (appKey !in pendingGames) findingsKnown = true }
|
||||
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
}
|
||||
fun setUser(id: String, on: Boolean) {
|
||||
selection = WinComponents.Selection(selection.auto, selection.user + (id to on))
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, on) }
|
||||
}
|
||||
fun toggle(id: String, on: Boolean) {
|
||||
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
|
||||
@@ -130,17 +195,16 @@ internal fun WinComponentsDialog(
|
||||
// Turning off is a saved pick, quick, but the row greys out until it is written so the
|
||||
// press is seen to land; the files leave the prefix at the game's next launch.
|
||||
busyId = id; reverting = true; progress = null
|
||||
val next = picks - id
|
||||
picks = next
|
||||
selection = WinComponents.Selection(selection.auto, selection.user + (id to false))
|
||||
coroutine.launch {
|
||||
withContext(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
withContext(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, false) }
|
||||
busyId = null; reverting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
val all = catalog.orEmpty()
|
||||
val c = all[id]
|
||||
if (id in installed || c == null) { setPicks(picks + id); return }
|
||||
if (id in installed || c == null) { setUser(id, true); return }
|
||||
busyId = id; reverting = false
|
||||
progress = WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1)
|
||||
coroutine.launch {
|
||||
@@ -149,28 +213,48 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
progress = null
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
|
||||
if (problem != null) { error = problem; failed = id } else setUser(id, true)
|
||||
busyId = null
|
||||
}
|
||||
}
|
||||
|
||||
val all = catalog
|
||||
// The first data is in: rows that show up from now on ease in.
|
||||
var arrived by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(all != null && findingsKnown) { if (all != null && findingsKnown) { withFrameNanos { }; arrived = true } }
|
||||
val here = if (all == null) emptySet() else present.map { installable(it, all) }.toSet()
|
||||
fun supportOf(id: String): Support =
|
||||
all?.get(id)?.let { WinComponents.support(it, all) } ?: if (id in installed) Support.READY else Support.UNSUPPORTED
|
||||
val recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
|
||||
// Turned on by itself for this game, each with its reason; then what is only recommended (the
|
||||
// folder scan, a name match, an auto pick that cannot install here); then what the lists name
|
||||
// that is no component here, by its own name.
|
||||
// Still being worked out on the background worker: the page says so instead of "nothing found".
|
||||
// Only before the game's list has ever been worked out: a list kept from before shows at once and
|
||||
// is checked again quietly, its changes easing in.
|
||||
val checking = game != null && !findingsKnown
|
||||
// Automatic picks on their way (queued or downloading) sit in their section already, with
|
||||
// their progress, and settle there when they land.
|
||||
val turningOn = if (all == null) emptyMap() else findings.filter { it.auto && it.component != null }
|
||||
.associateBy { installable(it.component!!, all) }
|
||||
.filterKeys { it !in selection.auto && selection.user[it] != false && (it in pendingComponents || it in autoDownloading) }
|
||||
val autoIds = selection.auto.keys.toList() + turningOn.keys
|
||||
val recIds = if (all == null) emptyList() else findings.filter { it.component != null }
|
||||
.filter { installable(it.component!!, all).let { id -> id !in selection.auto && id !in turningOn } }
|
||||
.distinctBy { installable(it.component!!, all) }
|
||||
val suggestions = findings.filter { it.component == null }.distinctBy { it.origin to it.original }
|
||||
val ready = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.READY }.map { it.name }
|
||||
val extra = (installed + picks).filter { all?.containsKey(it) != true }
|
||||
// What is turned on for this game gets its own section under the recommendations, which stay
|
||||
// where they are; the rest of the list is everything else, so a component turned off goes back
|
||||
// to its place in the alphabet.
|
||||
val recSet = recIds.map { installable(it.componentName, all.orEmpty()) }.toSet()
|
||||
// to its place in the alphabet. A component is listed once: the recommendations are left out
|
||||
// of the full list too.
|
||||
val recSet = recIds.map { installable(it.component!!, all.orEmpty()) }.toSet() + autoIds
|
||||
val active = picks.filter { it !in recSet && (all?.containsKey(it) == true || it in installed) }.distinct()
|
||||
.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val list = (ready + extra).distinct().filter { it !in active }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val list = (ready + extra).distinct().filter { it !in active && it !in recSet }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val waiting = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.NEEDS_INSTALLER }
|
||||
.map { it.name }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, all)) == Support.READY }
|
||||
val hasRec = all != null && (autoIds.isNotEmpty() || recIds.any { supportOf(installable(it.component!!, all)) == Support.READY })
|
||||
val sections = listOfNotNull(recFocus.takeIf { hasRec }, activeFocus.takeIf { active.isNotEmpty() },
|
||||
allFocus.takeIf { list.isNotEmpty() }, waitFocus.takeIf { waiting.isNotEmpty() }, doneFocus)
|
||||
var section by remember { mutableStateOf(0) }
|
||||
@@ -197,13 +281,14 @@ internal fun WinComponentsDialog(
|
||||
DialogHeader(gameName, stringResource(R.string.wincomp_title))
|
||||
Small(stringResource(R.string.wincomp_applies))
|
||||
if (byPad) Small(stringResource(R.string.wincomp_pad_hint))
|
||||
if (all == null) Small(stringResource(R.string.wincomp_loading))
|
||||
if (all == null && slowCatalog) Small(stringResource(R.string.wincomp_loading))
|
||||
if (offline) Small(stringResource(R.string.wincomp_offline), error = true)
|
||||
@Composable
|
||||
fun Item(id: String, reason: String?, focus: FocusRequester?) {
|
||||
val c = all?.get(id)
|
||||
val support = supportOf(id)
|
||||
val status = when {
|
||||
!online && id !in installed && support == Support.READY -> stringResource(R.string.wincomp_when_online)
|
||||
id == failed && error != null -> stringResource(R.string.wincomp_failed, error.orEmpty())
|
||||
id in here && id !in picks -> stringResource(R.string.wincomp_already_here)
|
||||
id in installed -> stringResource(R.string.wincomp_downloaded)
|
||||
@@ -213,12 +298,15 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
// The catalog key stays in the detail: it is what a log or a bug report names.
|
||||
val detail = listOfNotNull(reason, c?.description?.takeIf { it.isNotEmpty() }, status, id).joinToString(" · ")
|
||||
val usable = support == Support.READY
|
||||
val busy = id == busyId
|
||||
val offlineHere = !online && id !in installed
|
||||
val usable = support == Support.READY && !offlineHere
|
||||
val autoProgress = autoDownloading[id]?.takeIf { !offlineHere }
|
||||
val busy = id == busyId || autoProgress != null
|
||||
val shownProgress = if (id == busyId) progress else autoProgress
|
||||
val caption = when {
|
||||
!busy -> null
|
||||
reverting -> stringResource(R.string.wincomp_reverting)
|
||||
else -> progress?.let { p ->
|
||||
id == busyId && reverting -> stringResource(R.string.wincomp_reverting)
|
||||
else -> shownProgress?.let { p ->
|
||||
val stage = if (p.component != id && p.stage.isNotEmpty()) "${WinComponentNames.of(p.component)}: ${p.stage}" else p.stage
|
||||
val text = when (p.phase) {
|
||||
WinComponents.Phase.DOWNLOAD -> stringResource(R.string.wincomp_downloading, stage)
|
||||
@@ -228,9 +316,9 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
}
|
||||
ComponentRow(
|
||||
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
|
||||
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || (id in picks && id in installed),
|
||||
dim = !usable || busy, busy = busy, caption = caption,
|
||||
percent = if (busy && !reverting) progress?.percent ?: -1 else -1,
|
||||
percent = if (busy && !reverting) shownProgress?.percent ?: -1 else -1,
|
||||
modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
|
||||
.onFocusChanged { if (it.isFocused) switchFocused = true },
|
||||
) { on -> toggle(id, on) }
|
||||
@@ -241,48 +329,97 @@ internal fun WinComponentsDialog(
|
||||
return { id -> focus.takeIf { id == first } }
|
||||
}
|
||||
|
||||
if (all != null && recIds.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_recommended))
|
||||
val focusOf = firstUsable(recIds.map { installable(it.componentName, all) }, recFocus)
|
||||
Panel {
|
||||
recIds.forEachIndexed { i, rec ->
|
||||
if (i > 0) Divider()
|
||||
val reason = stringResource(
|
||||
when (rec.kind) {
|
||||
DependencyDetector.Kind.BUNDLED -> R.string.wincomp_found_bundled
|
||||
DependencyDetector.Kind.SHIPPED -> R.string.wincomp_found_shipped
|
||||
DependencyDetector.Kind.STEAM -> R.string.wincomp_found_steam
|
||||
}, rec.reason,
|
||||
)
|
||||
val id = installable(rec.componentName, all)
|
||||
Item(id, reason, focusOf(id))
|
||||
// The lists only once the catalog is in, faded in once: no half list counted before it.
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = all != null,
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(220)),
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(0)),
|
||||
label = "lists",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (checking) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.CircularProgressIndicator(Modifier.padding(start = 2.dp).size(12.dp), strokeWidth = 1.5.dp)
|
||||
Small(stringResource(R.string.wincomp_checking))
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = all != null && autoIds.isNotEmpty(),
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(260)) + androidx.compose.animation.expandVertically(Motion.tw(260)),
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(180)) + androidx.compose.animation.shrinkVertically(Motion.tw(200)),
|
||||
label = "autoSection",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Section(stringResource(R.string.wincomp_auto_section))
|
||||
if (turningOn.isNotEmpty()) Small(pluralStringResource(R.plurals.wincomp_turning_on, turningOn.size, turningOn.size))
|
||||
val focusOf = firstUsable(autoIds, recFocus)
|
||||
Panel {
|
||||
autoIds.forEachIndexed { i, id ->
|
||||
key(id) {
|
||||
Arrive(arrived) {
|
||||
Column {
|
||||
if (i > 0) Divider()
|
||||
val reason = selection.auto[id] ?: turningOn[id]?.let { f -> WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args) }
|
||||
Item(id, reason?.let { autoReason(it) }, focusOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (all != null && recIds.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_recommended))
|
||||
val focusOf = firstUsable(recIds.map { installable(it.component!!, all) }, if (autoIds.isEmpty()) recFocus else recFocusAfterAuto)
|
||||
Panel {
|
||||
recIds.forEachIndexed { i, rec ->
|
||||
val id = installable(rec.component!!, all)
|
||||
key(id) {
|
||||
Arrive(arrived) {
|
||||
Column {
|
||||
if (i > 0) Divider()
|
||||
Item(id, findingReason(rec), focusOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (all != null && gameDir != null && autoIds.isEmpty() && !checking) Small(stringResource(R.string.wincomp_no_recommendation))
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_suggestions))
|
||||
Panel {
|
||||
suggestions.forEachIndexed { i, f ->
|
||||
if (i > 0) Divider()
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(f.original, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(stringResource(R.string.wincomp_suggestion_reason, originName(f.origin.name)), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (active.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_active, active.size))
|
||||
val focusOf = firstUsable(active, activeFocus)
|
||||
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (list.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_all, list.size))
|
||||
val focusOf = firstUsable(list, allFocus)
|
||||
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (waiting.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Section(stringResource(R.string.wincomp_installers, waiting.size))
|
||||
Spacer(Modifier.weight(1f))
|
||||
SecondaryButton(
|
||||
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
|
||||
modifier = Modifier.focusRequester(waitFocus),
|
||||
) { showWaiting = !showWaiting }
|
||||
}
|
||||
Small(stringResource(R.string.wincomp_installers_note))
|
||||
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
|
||||
}
|
||||
}
|
||||
} else if (all != null && gameDir != null) Small(stringResource(R.string.wincomp_no_recommendation))
|
||||
|
||||
if (active.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_active, active.size))
|
||||
val focusOf = firstUsable(active, activeFocus)
|
||||
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (list.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_all, list.size))
|
||||
val focusOf = firstUsable(list, allFocus)
|
||||
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (waiting.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Section(stringResource(R.string.wincomp_installers, waiting.size))
|
||||
Spacer(Modifier.weight(1f))
|
||||
SecondaryButton(
|
||||
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
|
||||
modifier = Modifier.focusRequester(waitFocus),
|
||||
) { showWaiting = !showWaiting }
|
||||
}
|
||||
Small(stringResource(R.string.wincomp_installers_note))
|
||||
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
|
||||
}
|
||||
|
||||
error?.let { Small(it, error = true) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Small(stringResource(R.string.wincomp_next_launch))
|
||||
@@ -292,6 +429,39 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/** Once the page has its first data ([arrived]), a row that shows up eases in; the first ones are simply there. */
|
||||
@Composable
|
||||
private fun Arrive(arrived: Boolean, content: @Composable () -> Unit) {
|
||||
val state = remember { androidx.compose.animation.core.MutableTransitionState(!arrived) }.apply { targetState = true }
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visibleState = state,
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(240)) + androidx.compose.animation.expandVertically(Motion.tw(240)) +
|
||||
androidx.compose.animation.slideInVertically(Motion.tw(240)) { -it / 4 },
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(160)),
|
||||
label = "arrive",
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/** Whether the default network is there and validated, followed live. */
|
||||
@Composable
|
||||
private fun rememberOnline(): Boolean {
|
||||
val context = LocalContext.current
|
||||
var online by remember { mutableStateOf(com.droiddeck.launcher.frontend.AutoComponents.isOnline(context)) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
val cm = context.getSystemService(android.net.ConnectivityManager::class.java)
|
||||
val main = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val callback = object : android.net.ConnectivityManager.NetworkCallback() {
|
||||
private fun update() = main.post { online = com.droiddeck.launcher.frontend.AutoComponents.isOnline(context) }
|
||||
override fun onAvailable(network: android.net.Network) { update() }
|
||||
override fun onLost(network: android.net.Network) { update() }
|
||||
override fun onCapabilitiesChanged(network: android.net.Network, caps: android.net.NetworkCapabilities) { update() }
|
||||
}
|
||||
runCatching { cm?.registerDefaultNetworkCallback(callback) }
|
||||
onDispose { runCatching { cm?.unregisterNetworkCallback(callback) } }
|
||||
}
|
||||
return online
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String) =
|
||||
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">Mando</string>
|
||||
<string name="mode_controller_hint">Cómo ve Steam tu mando. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_controller_deck">Mando de Steam Deck</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Mando Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck: botón de Quick Access y giroscopio. Xbox 360: abre Quick Access con Guide+A.</string>
|
||||
<string name="mode_back">Atrás</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">¿Desinstalar Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Elimina el loader. Tus plugins y ajustes se conservan.</string>
|
||||
<string name="mode_client">Cliente</string>
|
||||
<string name="mode_deck_mode">Modo Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">La interfaz Deck de Steam y los controles de rendimiento de Quick Access. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_mangoapp">Superposición de rendimiento</string>
|
||||
<string name="mode_mangoapp_hint">Desactívalo si los juegos fallan o se quedan en negro en modo Deck. Se aplica en la próxima sesión.</string>
|
||||
<string name="mode_branch">Rama del cliente</string>
|
||||
<string name="mode_branch_deck">Beta pública de Steam Deck (requerida por el modo Deck).</string>
|
||||
<string name="mode_branch_hint">Se aplica en la próxima sesión. Steam puede actualizarse una vez.</string>
|
||||
<string name="mode_branch_public">Beta pública</string>
|
||||
<string name="mode_branch_deck_beta">Beta pública de Steam Deck</string>
|
||||
<string name="mode_branch_note">El modo Deck siempre usa la beta pública de Steam Deck.</string>
|
||||
<string name="mode_steam_repair">Reparar el cliente de Steam</string>
|
||||
<string name="mode_steam_repair_hint">Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes.</string>
|
||||
<string name="mode_steam_repair_queued">El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam.</string>
|
||||
@@ -312,12 +307,30 @@
|
||||
<string name="wincomp_title">Componentes de Windows</string>
|
||||
<string name="wincomp_pad_hint">LB / RB saltan entre las secciones.</string>
|
||||
<string name="wincomp_card_none">Ninguno</string>
|
||||
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes de Bannerlator. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
|
||||
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
|
||||
<string name="wincomp_offline">No se pudo acceder a la lista de componentes. Los componentes ya descargados se pueden seguir activando o desactivando.</string>
|
||||
<string name="wincomp_recommended">Recomendados para este juego</string>
|
||||
<string name="wincomp_checking">Comprobando lo que necesita este juego…</string>
|
||||
<string name="wincomp_when_online">Disponible con conexión</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="one">Activando %1$d componente…</item>
|
||||
<item quantity="many">Activando %1$d componentes…</item>
|
||||
<item quantity="other">Activando %1$d componentes…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">Activados para este juego</string>
|
||||
<string name="wincomp_auto_steam">Steam instala %1$s con %2$s (app %3$s)</string>
|
||||
<string name="wincomp_auto_gog">De la lista de GOG para este juego</string>
|
||||
<string name="wincomp_auto_epic">De los requisitos previos de Epic para este juego</string>
|
||||
<string name="wincomp_auto_amazon">De los pasos de instalación de Amazon para este juego</string>
|
||||
<string name="wincomp_steam_by_name">Steam instala %1$s con la app %2$s, encontrada por nombre</string>
|
||||
<string name="wincomp_suggestions">También en la lista</string>
|
||||
<string name="wincomp_suggestion_reason">En la lista de %1$s; no es un componente aquí</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">El juego incluye %1$s</string>
|
||||
<string name="wincomp_found_shipped">El juego tiene %1$s (suele funcionar tal cual)</string>
|
||||
<string name="wincomp_found_steam">Steam instala %1$s con este juego</string>
|
||||
<string name="wincomp_already_here">Ya está en este juego</string>
|
||||
<string name="wincomp_no_recommendation">Nada en la carpeta del juego apunta a un componente.</string>
|
||||
<string name="wincomp_all">Todos los componentes (%1$d)</string>
|
||||
@@ -839,6 +852,8 @@
|
||||
<string name="ctrl_steam_button">Botón Steam</string>
|
||||
<string name="ctrl_qam_button">Botón QAM</string>
|
||||
<string name="ctrl_keyboard_button">Botón de teclado</string>
|
||||
<string name="ctrl_select_steam">Pulsa Select para abrir el menú de Steam</string>
|
||||
<string name="ctrl_select_steam_hint">En lugar de View/Select.</string>
|
||||
<string name="setup_animations">Animaciones</string>
|
||||
<string name="ctrl_adaptive_hint">Los sticks aparecen donde tocas y se ocultan al soltar</string>
|
||||
<string name="ctrl_layout">Disposición</string>
|
||||
|
||||
@@ -232,6 +232,9 @@
|
||||
<string name="mode_controller">Manette</string>
|
||||
<string name="mode_controller_hint">Comment Steam reconnaît votre manette. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_controller_deck">Manette Steam Deck</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Manette Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck : fournit un bouton d\'accès rapide et un gyroscope. Xbox 360 : utilisez Guide+A pour ouvrir l\'accès rapide.</string>
|
||||
<string name="mode_back">Retour</string>
|
||||
@@ -257,16 +260,8 @@
|
||||
<string name="mode_decky_remove_title">Désinstaller Decky Loader ?</string>
|
||||
<string name="mode_decky_remove_text">Supprime le chargeur. Vos plugins et paramètres seront conservés.</string>
|
||||
<string name="mode_client">Client</string>
|
||||
<string name="mode_deck_mode">Mode Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">Interface Steam Deck et contrôles de performance à accès rapide. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_mangoapp">Superposition de performance</string>
|
||||
<string name="mode_mangoapp_hint">Désactivez cette option si les jeux plantent ou affichent un écran noir en mode Deck. Prendra effet à la prochaine session.</string>
|
||||
<string name="mode_branch">Branche du client</string>
|
||||
<string name="mode_branch_deck">Bêta Steam Deck (requis pour le mode Deck).</string>
|
||||
<string name="mode_branch_hint">Prendra effet à la prochaine session. Steam peut se mettre à jour une fois de lui-même.</string>
|
||||
<string name="mode_branch_public">Bêta</string>
|
||||
<string name="mode_branch_deck_beta">Bêta Steam Deck</string>
|
||||
<string name="mode_branch_note">Le mode Deck utilise toujours la version bêta de Steam Deck.</string>
|
||||
<string name="mode_added_art">Illustrations</string>
|
||||
<string name="mode_sgdb_key">Clé API SteamGridDB</string>
|
||||
<string name="mode_sgdb_key_yours">Votre clé</string>
|
||||
@@ -730,6 +725,8 @@
|
||||
<string name="ctrl_steam_button">Bouton Steam</string>
|
||||
<string name="ctrl_qam_button">Bouton QAM</string>
|
||||
<string name="ctrl_keyboard_button">Bouton clavier</string>
|
||||
<string name="ctrl_select_steam">Appuyez sur Select pour ouvrir le menu Steam</string>
|
||||
<string name="ctrl_select_steam_hint">À la place de View/Select.</string>
|
||||
<string name="setup_animations">Animations</string>
|
||||
<string name="ctrl_adaptive_hint">Le joystick apparaîtra là où vous touchez et se masquera une fois relâché</string>
|
||||
<string name="ctrl_layout">Disposition</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">コントローラー</string>
|
||||
<string name="mode_controller_hint">Steamがコントローラーを認識する方法。次回のセッションで有効になります。</string>
|
||||
<string name="mode_controller_deck">Steam Deckコントローラー</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360コントローラー</string>
|
||||
<string name="mode_controller_note">Deck:クイックアクセスボタンとジャイロを提供します。Xbox 360:Guide+Aでクイックアクセスを開きます。</string>
|
||||
<string name="mode_back">戻る</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">Decky Loaderをアンインストールしますか?</string>
|
||||
<string name="mode_decky_remove_text">Loaderを削除します。プラグインと設定は保持されます。</string>
|
||||
<string name="mode_client">クライアント</string>
|
||||
<string name="mode_deck_mode">Steam Deckモード</string>
|
||||
<string name="mode_deck_mode_hint">Steam DeckのUIとクイックアクセスパフォーマンスコントロール。次回のセッションで有効になります。</string>
|
||||
<string name="mode_mangoapp">パフォーマンスオーバーレイ</string>
|
||||
<string name="mode_mangoapp_hint">Deckモードでゲームがクラッシュしたり画面が真っ暗になる場合は、これをオフにしてください。次回のセッションで有効になります。</string>
|
||||
<string name="mode_branch">クライアントブランチ</string>
|
||||
<string name="mode_branch_deck">Steam Deckベータ版(Deckモードに必要)。</string>
|
||||
<string name="mode_branch_hint">次回のセッションで有効になります。Steamが自動的に一度アップデートされる場合があります。</string>
|
||||
<string name="mode_branch_public">ベータ版</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deckベータ版</string>
|
||||
<string name="mode_branch_note">Deckモードは常にSteam Deckベータ版を使用します。</string>
|
||||
<string name="mode_steam_repair">Steamクライアントを修復</string>
|
||||
<string name="mode_steam_repair_hint">次回Steamの起動時にSteamクライアントを再ダウンロードします。Steamが読み込み画面のまま進まない場合に使用してください。ゲーム、ログイン情報、設定は保持されます。</string>
|
||||
<string name="mode_steam_repair_queued">次回Steamの起動時にSteamクライアントを再ダウンロードします。</string>
|
||||
@@ -312,12 +307,28 @@
|
||||
<string name="wincomp_title">Windowsコンポーネント</string>
|
||||
<string name="wincomp_pad_hint">LB / RBでセクション間を移動します。</string>
|
||||
<string name="wincomp_card_none">なし</string>
|
||||
<string name="wincomp_applies">Protonに含まれないWindows DLLです(Bannerlatorのコンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
|
||||
<string name="wincomp_applies">Protonに含まれないWindows DLLです(コンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
|
||||
<string name="wincomp_offline">コンポーネント一覧に接続できませんでした。ダウンロード済みのコンポーネントは引き続き切り替えられます。</string>
|
||||
<string name="wincomp_recommended">このゲームにおすすめ</string>
|
||||
<string name="wincomp_checking">このゲームに必要なものを確認しています…</string>
|
||||
<string name="wincomp_when_online">オンライン時に利用可能</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">%1$d 個のコンポーネントを有効にしています…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">このゲームで有効</string>
|
||||
<string name="wincomp_auto_steam">Steamが%2$s(アプリ %3$s)と一緒に%1$sをインストールします</string>
|
||||
<string name="wincomp_auto_gog">このゲームのGOGのリストから</string>
|
||||
<string name="wincomp_auto_epic">このゲームのEpicの前提条件から</string>
|
||||
<string name="wincomp_auto_amazon">このゲームのAmazonのインストール手順から</string>
|
||||
<string name="wincomp_steam_by_name">Steamがアプリ %2$sと一緒に%1$sをインストールします(名前で一致)</string>
|
||||
<string name="wincomp_suggestions">その他のリスト</string>
|
||||
<string name="wincomp_suggestion_reason">%1$sのリストにあります。ここではコンポーネントではありません</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">ゲームに%1$sが同梱されています</string>
|
||||
<string name="wincomp_found_shipped">ゲームに%1$sがあります(そのままで動くことが多いです)</string>
|
||||
<string name="wincomp_found_steam">Steamがこのゲームと一緒に%1$sをインストールします</string>
|
||||
<string name="wincomp_already_here">このゲームに導入済み</string>
|
||||
<string name="wincomp_no_recommendation">ゲームのフォルダーには、必要なコンポーネントを示すものがありません。</string>
|
||||
<string name="wincomp_all">すべてのコンポーネント(%1$d)</string>
|
||||
@@ -831,6 +842,8 @@
|
||||
<string name="ctrl_steam_button">Steamボタン</string>
|
||||
<string name="ctrl_qam_button">QAMボタン</string>
|
||||
<string name="ctrl_keyboard_button">キーボードボタン</string>
|
||||
<string name="ctrl_select_steam">Selectを押してSteamメニューを開く</string>
|
||||
<string name="ctrl_select_steam_hint">View/Selectの代わり。</string>
|
||||
<string name="setup_animations">アニメーション</string>
|
||||
<string name="ctrl_adaptive_hint">スティックは触れた位置に表示され、離すと非表示になります</string>
|
||||
<string name="ctrl_layout">レイアウト</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">컨트롤러</string>
|
||||
<string name="mode_controller_hint">Steam이 컨트롤러를 인식하는 방식입니다. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_controller_deck">Steam Deck 컨트롤러</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360 컨트롤러</string>
|
||||
<string name="mode_controller_note">Deck: 빠른 액세스 버튼과 자이로스코프를 제공합니다. Xbox 360: Guide+A로 빠른 액세스를 엽니다.</string>
|
||||
<string name="mode_back">뒤로</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">Decky Loader를 제거하시겠습니까?</string>
|
||||
<string name="mode_decky_remove_text">로더를 제거합니다. 플러그인과 설정은 유지됩니다.</string>
|
||||
<string name="mode_client">클라이언트</string>
|
||||
<string name="mode_deck_mode">Steam Deck 모드</string>
|
||||
<string name="mode_deck_mode_hint">Steam Deck UI 및 빠른 액세스 성능 제어. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_mangoapp">성능 오버레이</string>
|
||||
<string name="mode_mangoapp_hint">Deck 모드에서 게임이 충돌하거나 화면이 검게 변하면 이 옵션을 끄세요. 다음 세션에 적용됩니다.</string>
|
||||
<string name="mode_branch">클라이언트 브랜치</string>
|
||||
<string name="mode_branch_deck">Steam Deck 베타(Deck 모드에 필요).</string>
|
||||
<string name="mode_branch_hint">다음 세션에 적용됩니다. Steam이 자체적으로 한 번 업데이트될 수 있습니다.</string>
|
||||
<string name="mode_branch_public">베타</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck 베타</string>
|
||||
<string name="mode_branch_note">Deck 모드는 항상 Steam Deck 베타를 사용합니다.</string>
|
||||
<string name="mode_steam_repair">Steam 클라이언트 복구</string>
|
||||
<string name="mode_steam_repair_hint">다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다. Steam이 로딩 화면에서 넘어가지 않을 때 사용하세요. 게임, 로그인 정보 및 설정은 유지됩니다.</string>
|
||||
<string name="mode_steam_repair_queued">다음에 Steam을 시작할 때 Steam 클라이언트를 다시 다운로드합니다.</string>
|
||||
@@ -312,12 +307,28 @@
|
||||
<string name="wincomp_title">Windows 구성 요소</string>
|
||||
<string name="wincomp_pad_hint">LB / RB로 섹션 사이를 이동합니다.</string>
|
||||
<string name="wincomp_card_none">없음</string>
|
||||
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, Bannerlator의 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
|
||||
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
|
||||
<string name="wincomp_offline">구성 요소 목록에 연결할 수 없습니다. 이미 다운로드한 구성 요소는 계속 켜고 끌 수 있습니다.</string>
|
||||
<string name="wincomp_recommended">이 게임에 권장</string>
|
||||
<string name="wincomp_checking">이 게임에 필요한 것을 확인하는 중…</string>
|
||||
<string name="wincomp_when_online">온라인일 때 사용 가능</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">구성 요소 %1$d개를 켜는 중…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">이 게임에 켜짐</string>
|
||||
<string name="wincomp_auto_steam">Steam이 %2$s(앱 %3$s)과(와) 함께 %1$s을(를) 설치합니다</string>
|
||||
<string name="wincomp_auto_gog">이 게임의 GOG 목록에서</string>
|
||||
<string name="wincomp_auto_epic">이 게임의 Epic 사전 요구 사항에서</string>
|
||||
<string name="wincomp_auto_amazon">이 게임의 Amazon 설치 단계에서</string>
|
||||
<string name="wincomp_steam_by_name">Steam이 앱 %2$s과(와) 함께 %1$s을(를) 설치합니다(이름으로 일치)</string>
|
||||
<string name="wincomp_suggestions">함께 나열됨</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 목록에 있음; 여기서는 구성 요소가 아님</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">게임에 %1$s이(가) 포함되어 있습니다</string>
|
||||
<string name="wincomp_found_shipped">게임에 %1$s이(가) 있습니다 (대개 그대로 작동)</string>
|
||||
<string name="wincomp_found_steam">Steam이 이 게임과 함께 %1$s을(를) 설치합니다</string>
|
||||
<string name="wincomp_already_here">이 게임에 이미 있음</string>
|
||||
<string name="wincomp_no_recommendation">게임 폴더에서 필요한 구성 요소를 가리키는 것이 없습니다.</string>
|
||||
<string name="wincomp_all">모든 구성 요소 (%1$d)</string>
|
||||
@@ -831,6 +842,8 @@
|
||||
<string name="ctrl_steam_button">Steam 버튼</string>
|
||||
<string name="ctrl_qam_button">QAM 버튼</string>
|
||||
<string name="ctrl_keyboard_button">키보드 버튼</string>
|
||||
<string name="ctrl_select_steam">Select를 눌러 Steam 메뉴 열기</string>
|
||||
<string name="ctrl_select_steam_hint">View/Select 대신.</string>
|
||||
<string name="setup_animations">애니메이션</string>
|
||||
<string name="ctrl_adaptive_hint">스틱이 터치한 위치에 나타나며 손을 떼면 숨겨집니다</string>
|
||||
<string name="ctrl_layout">레이아웃</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">Контроллер</string>
|
||||
<string name="mode_controller_hint">Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_controller_deck">Контроллер Steam Deck</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Контроллер Xbox 360</string>
|
||||
<string name="mode_controller_note">Deck: кнопка быстрого доступа и гироскоп. Xbox 360: используйте Guide+A для быстрого доступа.</string>
|
||||
<string name="mode_back">Назад</string>
|
||||
@@ -281,16 +284,8 @@
|
||||
<string name="mode_decky_remove_title">Удалить Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Удаляет загрузчик. Ваши плагины и настройки сохранятся.</string>
|
||||
<string name="mode_client">Клиент</string>
|
||||
<string name="mode_deck_mode">Режим Steam Deck</string>
|
||||
<string name="mode_deck_mode_hint">Интерфейс Steam Deck и управление производительностью через быстрый доступ. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_mangoapp">Оверлей производительности</string>
|
||||
<string name="mode_mangoapp_hint">Отключите, если игры вылетают или показывают черный экран в режиме Deck. Вступит в силу в следующей сессии.</string>
|
||||
<string name="mode_branch">Ветка клиента</string>
|
||||
<string name="mode_branch_deck">Публичная бета Steam Deck (требуется для режима Deck).</string>
|
||||
<string name="mode_branch_hint">Вступит в силу в следующей сессии. Steam может обновиться самостоятельно один раз.</string>
|
||||
<string name="mode_branch_public">Публичная бета</string>
|
||||
<string name="mode_branch_deck_beta">Публичная бета Steam Deck</string>
|
||||
<string name="mode_branch_note">Режим Deck всегда использует публичную бету Steam Deck.</string>
|
||||
<string name="mode_steam_repair">Восстановить клиент Steam</string>
|
||||
<string name="mode_steam_repair_hint">При следующем запуске Steam клиент будет скачан заново. Используйте, если Steam зависает на экране загрузки. Игры, вход и настройки сохраняются.</string>
|
||||
<string name="mode_steam_repair_queued">Клиент Steam будет скачан заново при следующем запуске Steam.</string>
|
||||
@@ -814,6 +809,8 @@
|
||||
<string name="ctrl_steam_button">Кнопка Steam</string>
|
||||
<string name="ctrl_qam_button">Кнопка QAM</string>
|
||||
<string name="ctrl_keyboard_button">Кнопка клавиатуры</string>
|
||||
<string name="ctrl_select_steam">Нажмите Select, чтобы открыть меню Steam</string>
|
||||
<string name="ctrl_select_steam_hint">Вместо View/Select.</string>
|
||||
<string name="setup_animations">Анимации</string>
|
||||
<string name="ctrl_adaptive_hint">Стики появляются в месте касания и исчезают после отпускания</string>
|
||||
<string name="ctrl_layout">Раскладка</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">手柄</string>
|
||||
<string name="mode_controller_hint">决定 Steam 把你的手柄识别为哪种类型,将在下次会话生效。</string>
|
||||
<string name="mode_controller_deck">Steam Deck 手柄</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360 手柄</string>
|
||||
<string name="mode_controller_note">Deck:快捷访问按钮与陀螺仪。Xbox 360:用 Guide+A 打开快捷访问。</string>
|
||||
<string name="mode_back">返回</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">卸载 Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">移除加载器。你的插件和设置会保留。</string>
|
||||
<string name="mode_client">客户端</string>
|
||||
<string name="mode_deck_mode">Steam Deck 模式</string>
|
||||
<string name="mode_deck_mode_hint">Steam 的 Deck 界面和快捷访问性能控制。将在下次会话生效。</string>
|
||||
<string name="mode_mangoapp">性能覆盖层</string>
|
||||
<string name="mode_mangoapp_hint">若游戏在 Deck 模式下崩溃或黑屏,请关闭性能覆盖层。将在下次会话生效。</string>
|
||||
<string name="mode_branch">客户端分支</string>
|
||||
<string name="mode_branch_deck">Steam Deck 公开测试版(Deck 模式所必需)。</string>
|
||||
<string name="mode_branch_hint">将在下次会话生效。Steam 可能会自行更新一次。</string>
|
||||
<string name="mode_branch_public">公开测试版</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck 公开测试版</string>
|
||||
<string name="mode_branch_note">Deck 模式始终使用 Steam Deck 公开测试版。</string>
|
||||
<string name="mode_steam_repair">修复 Steam 客户端</string>
|
||||
<string name="mode_steam_repair_hint">下次启动 Steam 时重新下载 Steam 客户端。如果 Steam 一直停在加载画面,请使用此项。游戏、登录信息和设置都会保留。</string>
|
||||
<string name="mode_steam_repair_queued">下次启动 Steam 时将重新下载 Steam 客户端。</string>
|
||||
@@ -312,12 +307,28 @@
|
||||
<string name="wincomp_title">Windows 组件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之间跳转。</string>
|
||||
<string name="wincomp_card_none">无</string>
|
||||
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自 Bannerlator 的组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
|
||||
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
|
||||
<string name="wincomp_offline">无法访问组件列表。已下载的组件仍可开启或关闭。</string>
|
||||
<string name="wincomp_recommended">此游戏推荐</string>
|
||||
<string name="wincomp_checking">正在检查此游戏需要什么…</string>
|
||||
<string name="wincomp_when_online">联网后可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在开启 %1$d 个组件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">此游戏已开启</string>
|
||||
<string name="wincomp_auto_steam">Steam 会随 %2$s(应用 %3$s)安装 %1$s</string>
|
||||
<string name="wincomp_auto_gog">来自此游戏的 GOG 列表</string>
|
||||
<string name="wincomp_auto_epic">来自此游戏的 Epic 先决条件</string>
|
||||
<string name="wincomp_auto_amazon">来自此游戏的 Amazon 安装步骤</string>
|
||||
<string name="wincomp_steam_by_name">Steam 会随应用 %2$s 安装 %1$s(按名称匹配)</string>
|
||||
<string name="wincomp_suggestions">列表中的其他项</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;此处不是组件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">游戏自带 %1$s</string>
|
||||
<string name="wincomp_found_shipped">游戏含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 会随此游戏安装 %1$s</string>
|
||||
<string name="wincomp_already_here">此游戏中已有</string>
|
||||
<string name="wincomp_no_recommendation">游戏文件夹中没有指向任何组件的内容。</string>
|
||||
<string name="wincomp_all">全部组件(%1$d)</string>
|
||||
@@ -831,6 +842,8 @@
|
||||
<string name="ctrl_steam_button">Steam 按钮</string>
|
||||
<string name="ctrl_qam_button">QAM 按钮</string>
|
||||
<string name="ctrl_keyboard_button">键盘按钮</string>
|
||||
<string name="ctrl_select_steam">按 Select 打开 Steam 菜单</string>
|
||||
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
|
||||
<string name="setup_animations">动画</string>
|
||||
<string name="ctrl_adaptive_hint">摇杆出现在触摸处,松手后隐藏</string>
|
||||
<string name="ctrl_layout">布局</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">控制器</string>
|
||||
<string name="mode_controller_hint">Steam 如何識別你的控制器。下次會話生效。</string>
|
||||
<string name="mode_controller_deck">Steam Deck 控制器</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360 控制器</string>
|
||||
<string name="mode_controller_note">Deck:提供快速存取按鈕和陀螺儀。Xbox 360:按 Guide+A 開啟快速存取。</string>
|
||||
<string name="mode_back">返回</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">解除安裝 Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">移除載入器,你的插件和設定會保留。</string>
|
||||
<string name="mode_client">用戶端</string>
|
||||
<string name="mode_deck_mode">Steam Deck 模式</string>
|
||||
<string name="mode_deck_mode_hint">Steam 的 Deck 介面及快速存取效能控制。下次會話生效。</string>
|
||||
<string name="mode_mangoapp">效能疊加層</string>
|
||||
<string name="mode_mangoapp_hint">如果在 Deck 模式下遊戲當機或黑畫面,請關閉此項。下次會話生效。</string>
|
||||
<string name="mode_branch">用戶端分支</string>
|
||||
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式需要)。</string>
|
||||
<string name="mode_branch_hint">下次會話生效。Steam 可能會自行更新一次。</string>
|
||||
<string name="mode_branch_public">公開測試版</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck 公開測試版</string>
|
||||
<string name="mode_branch_note">Deck 模式一律使用 Steam Deck 公開測試版。</string>
|
||||
<string name="mode_steam_repair">修復 Steam 用戶端</string>
|
||||
<string name="mode_steam_repair_hint">下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停留在載入畫面,請使用此功能。遊戲、登入資料和設定都會保留。</string>
|
||||
<string name="mode_steam_repair_queued">下次啟動 Steam 時會重新下載 Steam 用戶端。</string>
|
||||
@@ -312,12 +307,28 @@
|
||||
<string name="wincomp_title">Windows 組件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自 Bannerlator 的組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連接組件清單。已下載的組件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
|
||||
<string name="wincomp_when_online">連線後可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在開啟 %1$d 個元件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">這款遊戲已開啟</string>
|
||||
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
|
||||
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
|
||||
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
|
||||
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
|
||||
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
|
||||
<string name="wincomp_suggestions">清單中的其他項目</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">遊戲附帶 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾內沒有指向任何組件的內容。</string>
|
||||
<string name="wincomp_all">所有組件(%1$d)</string>
|
||||
@@ -831,6 +842,8 @@
|
||||
<string name="ctrl_steam_button">Steam 按鈕</string>
|
||||
<string name="ctrl_qam_button">QAM 按鈕</string>
|
||||
<string name="ctrl_keyboard_button">鍵盤按鈕</string>
|
||||
<string name="ctrl_select_steam">按 Select 開啟 Steam 選單</string>
|
||||
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
|
||||
<string name="setup_animations">動畫</string>
|
||||
<string name="ctrl_adaptive_hint">搖桿會在你觸控的位置出現,放開後隱藏</string>
|
||||
<string name="ctrl_layout">佈局</string>
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
<string name="mode_controller">控制器</string>
|
||||
<string name="mode_controller_hint">Steam 辨識你的控制器的方式。下次工作階段生效。</string>
|
||||
<string name="mode_controller_deck">Steam Deck 控制器</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360 控制器</string>
|
||||
<string name="mode_controller_note">Deck:提供快速存取按鈕和陀螺儀。Xbox 360:使用 Guide+A 開啟快速存取。</string>
|
||||
<string name="mode_back">返回</string>
|
||||
@@ -283,16 +286,8 @@
|
||||
<string name="mode_decky_remove_title">解除安裝 Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">移除載入器。你的外掛程式和設定會保留。</string>
|
||||
<string name="mode_client">用戶端</string>
|
||||
<string name="mode_deck_mode">Steam Deck 模式</string>
|
||||
<string name="mode_deck_mode_hint">Steam Deck 介面和快速存取效能控制。下次工作階段生效。</string>
|
||||
<string name="mode_mangoapp">效能重疊層</string>
|
||||
<string name="mode_mangoapp_hint">如果 Deck 模式下遊戲崩潰或黑畫面,請關閉此項。下次工作階段生效。</string>
|
||||
<string name="mode_branch">用戶端分支</string>
|
||||
<string name="mode_branch_deck">Steam Deck 公開測試版(Deck 模式所需)。</string>
|
||||
<string name="mode_branch_hint">下次工作階段生效。Steam 可能會自行更新一次。</string>
|
||||
<string name="mode_branch_public">公開測試版</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck 公開測試版</string>
|
||||
<string name="mode_branch_note">Deck 模式一律使用 Steam Deck 公開測試版。</string>
|
||||
<string name="mode_steam_repair">修復 Steam 用戶端</string>
|
||||
<string name="mode_steam_repair_hint">下次啟動 Steam 時重新下載 Steam 用戶端。如果 Steam 一直停在載入畫面,請使用此功能。遊戲、登入狀態和設定都會保留。</string>
|
||||
<string name="mode_steam_repair_queued">下次啟動 Steam 時會重新下載 Steam 用戶端。</string>
|
||||
@@ -312,12 +307,28 @@
|
||||
<string name="wincomp_title">Windows 元件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各區段之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自 Bannerlator 的元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連上元件清單。已下載的元件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
|
||||
<string name="wincomp_when_online">連線後可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在開啟 %1$d 個元件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">這款遊戲已開啟</string>
|
||||
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
|
||||
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
|
||||
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
|
||||
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
|
||||
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
|
||||
<string name="wincomp_suggestions">清單中的其他項目</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">遊戲隨附 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾中沒有指向任何元件的內容。</string>
|
||||
<string name="wincomp_all">所有元件(%1$d)</string>
|
||||
@@ -831,6 +842,8 @@
|
||||
<string name="ctrl_steam_button">Steam 按鈕</string>
|
||||
<string name="ctrl_qam_button">QAM 按鈕</string>
|
||||
<string name="ctrl_keyboard_button">鍵盤按鈕</string>
|
||||
<string name="ctrl_select_steam">按 Select 開啟 Steam 功能表</string>
|
||||
<string name="ctrl_select_steam_hint">代替 View/Select。</string>
|
||||
<string name="setup_animations">動畫</string>
|
||||
<string name="ctrl_adaptive_hint">搖桿會出現在你觸控的位置,並在放開後隱藏</string>
|
||||
<string name="ctrl_layout">版面配置</string>
|
||||
|
||||
@@ -262,8 +262,11 @@
|
||||
<string name="mode_controller">Controller</string>
|
||||
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
|
||||
<string name="mode_controller_deck">Steam Deck controller</string>
|
||||
<string name="mode_controller_steam">Steam Controller</string>
|
||||
<string name="mode_controller_ds4">DualShock 4</string>
|
||||
<string name="mode_controller_dualsense">DualSense</string>
|
||||
<string name="mode_controller_x360">Xbox 360 controller</string>
|
||||
<string name="mode_controller_note">Deck: Quick Access button and gyro. Xbox 360: open Quick Access with Guide+A.</string>
|
||||
<string name="mode_controller_note">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.</string>
|
||||
<string name="mode_back">Back</string>
|
||||
<string name="back_menu_then_qam">Menu → QAM</string>
|
||||
<string name="back_qam_then_menu">QAM → Menu</string>
|
||||
@@ -293,16 +296,8 @@
|
||||
<string name="mode_decky_remove_title">Uninstall Decky Loader?</string>
|
||||
<string name="mode_decky_remove_text">Removes the loader. Your plugins and settings are kept.</string>
|
||||
<string name="mode_client">Client</string>
|
||||
<string name="mode_deck_mode">Steam Deck mode</string>
|
||||
<string name="mode_deck_mode_hint">Steam\'s Deck interface and Quick Access performance controls. Applies next session.</string>
|
||||
<string name="mode_mangoapp">Performance overlay</string>
|
||||
<string name="mode_mangoapp_hint">Turn off if games crash or go black in Deck mode. Applies next session.</string>
|
||||
<string name="mode_branch">Client branch</string>
|
||||
<string name="mode_branch_deck">Steam Deck public beta (required by Deck mode).</string>
|
||||
<string name="mode_branch_hint">Applies next session. Steam may update itself once.</string>
|
||||
<string name="mode_branch_public">Public beta</string>
|
||||
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
|
||||
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
|
||||
<string name="mode_steam_repair">Repair Steam client</string>
|
||||
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
|
||||
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
|
||||
@@ -322,12 +317,29 @@
|
||||
<string name="wincomp_title">Windows components</string>
|
||||
<string name="wincomp_pad_hint">LB / RB jump between the sections.</string>
|
||||
<string name="wincomp_card_none">None</string>
|
||||
<string name="wincomp_applies">Windows DLLs Proton does not include, from Bannerlator\'s component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
|
||||
<string name="wincomp_applies">Windows DLLs Proton does not include, from the component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
|
||||
<string name="wincomp_offline">Could not reach the component list. Components already downloaded can still be switched.</string>
|
||||
<string name="wincomp_recommended">Recommended for this game</string>
|
||||
<string name="wincomp_checking">Checking what this game needs…</string>
|
||||
<string name="wincomp_when_online">Available when online</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="one">Turning on %1$d component…</item>
|
||||
<item quantity="other">Turning on %1$d components…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">On for this game</string>
|
||||
<string name="wincomp_auto_steam">Steam installs %1$s with %2$s (app %3$s)</string>
|
||||
<string name="wincomp_auto_gog">From GOG\'s list for this game</string>
|
||||
<string name="wincomp_auto_epic">From Epic\'s prerequisites for this game</string>
|
||||
<string name="wincomp_auto_amazon">From Amazon\'s install steps for this game</string>
|
||||
<string name="wincomp_steam_by_name">Steam installs %1$s with app %2$s, matched by name</string>
|
||||
<string name="wincomp_suggestions">Also listed</string>
|
||||
<string name="wincomp_suggestion_reason">Listed by %1$s; not a component here</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">The game ships %1$s</string>
|
||||
<string name="wincomp_found_shipped">The game has %1$s (often works as is)</string>
|
||||
<string name="wincomp_found_steam">Steam installs %1$s with this game</string>
|
||||
<string name="wincomp_already_here">Already in this game</string>
|
||||
<string name="wincomp_no_recommendation">Nothing in the game\'s folder points at a component.</string>
|
||||
<string name="wincomp_active">Installed for this game (%1$d)</string>
|
||||
@@ -635,6 +647,10 @@
|
||||
<string name="setup_home">Use as Home screen</string>
|
||||
<string name="setup_home_on">DroidDeck can be the Home app</string>
|
||||
<string name="setup_home_off">DroidDeck is never offered as a Home app</string>
|
||||
<string name="setup_orientation">Orientation</string>
|
||||
<string name="setup_orientation_auto">Automatic landscape</string>
|
||||
<string name="setup_orientation_landscape">Landscape</string>
|
||||
<string name="setup_orientation_reverse">Reverse landscape</string>
|
||||
<string name="setup_fullscreen">Fullscreen</string>
|
||||
<string name="setup_fullscreen_on">Hides the Android status and navigation bars</string>
|
||||
<string name="setup_fullscreen_off">Shows the Android status and navigation bars</string>
|
||||
@@ -849,6 +865,8 @@
|
||||
<string name="ctrl_steam_button">Steam button</string>
|
||||
<string name="ctrl_qam_button">QAM button</string>
|
||||
<string name="ctrl_keyboard_button">Keyboard button</string>
|
||||
<string name="ctrl_select_steam">Press Select to open the Steam menu</string>
|
||||
<string name="ctrl_select_steam_hint">Instead of View/Select.</string>
|
||||
<string name="setup_animations">Animations</string>
|
||||
<string name="ctrl_adaptive_hint">Sticks appear where you touch and hide on release</string>
|
||||
<string name="ctrl_layout">Layout</string>
|
||||
@@ -973,6 +991,12 @@
|
||||
<string name="deck_tab_left">Left pad</string>
|
||||
<string name="deck_tab_right">Right pad</string>
|
||||
<string name="deck_tab_both">Both</string>
|
||||
<string name="deck_tab_gamepad">Gamepad</string>
|
||||
<string name="deck_surface_stick">Stick</string>
|
||||
<string name="deck_surface_trackpad">Trackpad</string>
|
||||
<string name="deck_surface_switch">Switch between stick and trackpad</string>
|
||||
<string name="deck_more_controls">More Deck controls</string>
|
||||
<string name="deck_panels">PANELS</string>
|
||||
<string name="deck_close">Close second-screen controls</string>
|
||||
<string name="deck_open_steam_menu">Open Steam menu</string>
|
||||
<string name="deck_left_upper">Left · upper</string>
|
||||
@@ -1036,6 +1060,8 @@
|
||||
<string name="game_env_eyebrow">Proton games</string>
|
||||
<string name="pip_title">Picture in picture</string>
|
||||
<string name="pip_auto">Automatically enter PiP</string>
|
||||
<string name="unfolded_controls">Controls on the unfolded screen</string>
|
||||
<string name="unfolded_controls_hint">Unfolded, the game sits above the Steam Deck trackpads and back grips. Folded, the game is full screen.</string>
|
||||
<string name="pip_suspended">Suspended</string>
|
||||
<string name="pip_not_ready">Picture in picture is available once the session is ready.</string>
|
||||
<string name="pip_permission">Allow picture in picture for DroidDeck in Android settings.</string>
|
||||
|
||||
@@ -46,10 +46,26 @@ class GameEnvironmentTest {
|
||||
|
||||
@Test fun dotnetRuntimeFitsAndroidAddressSpaceForEveryGame() {
|
||||
val values = GameEnvironment.effective(GameEnvironment.Config(), "PERFORMANCE", "43")
|
||||
assertEquals("0x800000000", values["DOTNET_GCRegionRange"])
|
||||
assertEquals("800000000", values["DOTNET_GCRegionRange"])
|
||||
assertEquals("0", values["DOTNET_EnableWriteXorExecute"])
|
||||
}
|
||||
|
||||
@Test fun dotnetHeapRangeIsBareHexForNativeAot() {
|
||||
for (preset in FexPreset.all) {
|
||||
val values = GameEnvironment.effective(GameEnvironment.Config(), preset.id, "3478050")
|
||||
assertEquals(32L * 1024 * 1024 * 1024, values.getValue("DOTNET_GCRegionRange")!!.toLong(16))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun dotnetHeapRangeRespectsSharedAndGameOverrides() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("DOTNET_GCRegionRange" to "400000000"),
|
||||
games = mapOf("3478050" to mapOf("DOTNET_GCRegionRange" to "800000000")),
|
||||
)
|
||||
assertEquals("800000000", GameEnvironment.effective(config, "", "3478050")["DOTNET_GCRegionRange"])
|
||||
assertEquals("400000000", GameEnvironment.effective(config, "", "43")["DOTNET_GCRegionRange"])
|
||||
}
|
||||
|
||||
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
|
||||
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
|
||||
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
|
||||
|
||||
@@ -83,7 +83,8 @@ class AddedExesTest {
|
||||
val result = AddedGames.addExe(app, deep)
|
||||
assertTrue(result is AddedGames.AddResult.Switched)
|
||||
assertEquals(deep.path, AddedGames.scan(app).single().exe.path)
|
||||
assertEquals(0, AddedExes.list(app).size)
|
||||
// The Games folder's game is an entry of its own (GamesFolderMigration); no second one.
|
||||
assertEquals(listOf(File(games, "Example").path), AddedExes.list(app).map { it.folder })
|
||||
}
|
||||
|
||||
@Test fun anExeTheSessionCannotSeeIsNotAdded() {
|
||||
|
||||
@@ -33,27 +33,40 @@ class AddedGameArtLookupTest {
|
||||
}
|
||||
|
||||
@Test fun anAppidFromTheGamesFilesIsUsedWithoutASearch() {
|
||||
val lookup = File(tmp.newFolder(), "steam-appid")
|
||||
val id = AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null }
|
||||
assertEquals("1623730", id)
|
||||
assertEquals("1623730", lookup.readText())
|
||||
// An earlier "none" from a search does not stand in its way.
|
||||
lookup.writeText("none")
|
||||
assertEquals("1623730", AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null })
|
||||
val dir = tmp.newFolder("Palworld")
|
||||
File(dir, "steam_appid.txt").writeText("1623730")
|
||||
val exe = File(dir, "Palworld.exe").apply { writeText("x") }
|
||||
val match = SteamMatch.resolve(app, dir.path, exe, "Palworld") { fail("searched"); null }
|
||||
assertEquals(1623730, match.appId)
|
||||
assertEquals(SteamMatch.Certainty.FILES, match.certainty)
|
||||
// The same record for everyone who asks.
|
||||
assertEquals(1623730, SteamMatch.get(app, dir.path)!!.appId)
|
||||
}
|
||||
|
||||
@Test fun withoutAnAppidTheTitleIsSearchedOnceAWeek() {
|
||||
val lookup = File(tmp.newFolder(), "steam-appid")
|
||||
val dir = tmp.newFolder("Nothing Like It")
|
||||
val exe = File(dir, "game.exe").apply { writeText("x") }
|
||||
var searches = 0
|
||||
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; null })
|
||||
assertEquals("none", lookup.readText())
|
||||
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "1" })
|
||||
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; null }.appId)
|
||||
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 1 }.appId)
|
||||
assertEquals(1, searches)
|
||||
lookup.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)
|
||||
assertEquals("42", AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "42" })
|
||||
val old = SteamMatch.get(app, dir.path)!!
|
||||
SteamMatch.put(app, dir.path, SteamMatch.Match(null, SteamMatch.Certainty.NONE, "", old.checkedAt - 8L * 24 * 3600 * 1000))
|
||||
val found = SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 42 }
|
||||
assertEquals(42, found.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, found.certainty)
|
||||
assertEquals(2, searches)
|
||||
}
|
||||
|
||||
@Test fun anEarlierBuildsArtLookupIsKeptAsANameMatch() {
|
||||
val dir = tmp.newFolder("Hades")
|
||||
val exe = File(dir, "Hades.exe").apply { writeText("x") }
|
||||
val legacy = File(tmp.newFolder(), "steam-appid").apply { writeText("1145360") }
|
||||
val match = SteamMatch.resolve(app, dir.path, exe, "Hades", legacy = legacy) { fail("searched"); null }
|
||||
assertEquals(1145360, match.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, match.certainty)
|
||||
}
|
||||
|
||||
@Test fun aStoreResultIsTakenOnlyWhenItsNameMatches() {
|
||||
// The first result is no longer taken for being first.
|
||||
assertNull(AddedGameArt.pickStoreResult("My Obscure Game", listOf("10" to "Totally Different", "11" to "Another One")))
|
||||
|
||||
@@ -27,4 +27,45 @@ class AddedGamesScanTest {
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(games.path))
|
||||
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
|
||||
}
|
||||
|
||||
@Test fun aStorePackageLaunchesTheExecutableItDeclares() {
|
||||
val game = tmp.newFolder("Minecraft Dungeons")
|
||||
File(game, "Dungeons.exe").writeText("unreal bootstrap")
|
||||
File(game, "Dungeons/Binaries/Win64").mkdirs()
|
||||
File(game, "Dungeons/Binaries/Win64/Dungeons.exe").writeText("the game, renamed from Dungeons-Win64-Shipping.exe")
|
||||
File(game, "MicrosoftGame.config").writeText(
|
||||
"<Game><ExecutableList>\n <Executable Name=\"Dungeons\\Binaries\\Win64\\Dungeons.exe\"\n\n Id=\"Game\" />\n</ExecutableList></Game>",
|
||||
)
|
||||
assertEquals(File(game, "Dungeons/Binaries/Win64/Dungeons.exe").path, AddedGames.candidates(game).first().path)
|
||||
assertEquals(2, AddedGames.candidates(game).size)
|
||||
}
|
||||
|
||||
@Test fun anAppxManifestNamesTheGameWhenThereIsNoConfig() {
|
||||
val game = tmp.newFolder("Store Game")
|
||||
File(game, "Launcher.exe").writeText("bootstrap")
|
||||
File(game, "Game/Binaries/Win64").mkdirs()
|
||||
File(game, "Game/Binaries/Win64/Game.exe").writeText("game")
|
||||
File(game, "appxmanifest.xml").writeText("<Package><Applications><Application Id=\"Game\" Executable=\"Game\\Binaries\\Win64\\Game.exe\" /></Applications></Package>")
|
||||
assertEquals(File(game, "Game/Binaries/Win64/Game.exe").path, AddedGames.candidates(game).first().path)
|
||||
}
|
||||
|
||||
@Test fun aDeclaredTopLevelExecutableKeepsItsPlace() {
|
||||
val game = tmp.newFolder("Minecraft Dungeons II")
|
||||
File(game, "Dungeons.exe").writeText("bootstrap")
|
||||
File(game, "Other.exe").writeText("a much longer other executable")
|
||||
File(game, "MicrosoftGame.config").writeText("<Executable Name=\"Dungeons.exe\" Id=\"App\" />")
|
||||
assertEquals(listOf("Dungeons.exe", "Other.exe"), AddedGames.candidates(game).map { it.name })
|
||||
}
|
||||
|
||||
@Test fun declaredHelpersMissingFilesAndPathsOutsideTheFolderAreIgnored() {
|
||||
val game = tmp.newFolder("Pass Game")
|
||||
File(game, "PassGame.exe").writeText("game")
|
||||
File(game, "gamelaunchhelper.exe").writeText("needs gaming services")
|
||||
tmp.newFile("Outside.exe").writeText("not this game")
|
||||
File(game, "MicrosoftGame.config").writeText(
|
||||
"<Executable Name=\"gamelaunchhelper.exe\" /><Executable Name=\"Missing.exe\" /><Executable Name=\"..\\Outside.exe\" />",
|
||||
)
|
||||
assertEquals(emptyList<File>(), AddedGames.declaredExes(game))
|
||||
assertEquals(listOf("PassGame.exe"), AddedGames.candidates(game).map { it.name })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Games folders from earlier builds become entries, every game keeping what it had. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class GamesFolderMigrationTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun exe(root: File, rel: String) = File(root, rel).apply { parentFile!!.mkdirs(); writeBytes(ByteArray(16)) }
|
||||
|
||||
@Before fun setUp() {
|
||||
SessionPrefs.setGameStorage(app, "", "")
|
||||
AddedGamesPublisher.now = true
|
||||
}
|
||||
|
||||
@Test fun eachGameBecomesAnEntryKeepingItsExeAppidNameAndRemoval() {
|
||||
val pc = tmp.newFolder("PC")
|
||||
val alpha = File(pc, "Alpha"); exe(pc, "Alpha/Alpha.exe")
|
||||
val beta = File(pc, "Beta"); exe(pc, "Beta/Beta.exe"); val pick = exe(pc, "Beta/bin/Beta64.exe")
|
||||
val gone = File(pc, "Gone"); exe(pc, "Gone/Gone.exe")
|
||||
exe(pc, "steamapps/common/Installed/Installed.exe")
|
||||
val store = File(pc, "Store"); exe(pc, "Store/Store.exe")
|
||||
StoreGameSidecar.file(store).writeText("""{"store":"gog","id":"1","title":"Store Game","exe":"Store.exe"}""")
|
||||
File(pc, "Notes").mkdirs()
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
|
||||
// What earlier scans had kept, by folder.
|
||||
SessionPrefs.setAddedGameExe(app, beta.path, pick.path)
|
||||
SessionPrefs.addedGameAppId(app, alpha.path, 0x8123_4567L)
|
||||
SessionPrefs.addedGameAppId(app, beta.path, 0x8765_4321L)
|
||||
SessionPrefs.setAddedGameName(app, alpha.path, "Alpha GOTY")
|
||||
SessionPrefs.setAddedGameRemoved(app, gone.path, true)
|
||||
|
||||
GamesFolderMigration.run(app)
|
||||
|
||||
assertEquals(emptyList<String>(), SessionPrefs.addedGamesDirs(app))
|
||||
assertTrue(SessionPrefs.gamesFoldersMigrated(app))
|
||||
val entries = AddedExes.list(app).associateBy { it.folder }
|
||||
assertEquals(setOf(alpha.path, beta.path, gone.path), entries.keys)
|
||||
assertTrue(entries.getValue(beta.path).picked)
|
||||
assertEquals(pick.path, entries.getValue(beta.path).exe)
|
||||
assertFalse(entries.getValue(alpha.path).picked)
|
||||
|
||||
val games = AddedGames.scan(app).associateBy { it.folder.path }
|
||||
assertEquals(setOf(alpha.path, beta.path), games.keys)
|
||||
assertEquals(0x8123_4567L, games.getValue(alpha.path).appId)
|
||||
assertEquals(0x8765_4321L, games.getValue(beta.path).appId)
|
||||
assertEquals("Alpha GOTY", games.getValue(alpha.path).name)
|
||||
assertEquals(pick.path, games.getValue(beta.path).exe.path)
|
||||
// The same path inside the session as before: the shortcut keeps its target.
|
||||
assertEquals(AddedGames.GUEST_DIR + "/PC/Alpha/Alpha.exe", games.getValue(alpha.path).guestExe)
|
||||
assertEquals(AddedGames.GUEST_DIR + "/PC/Beta/bin/Beta64.exe", games.getValue(beta.path).guestExe)
|
||||
assertTrue(SessionPrefs.removedAddedGames(app).contains(gone.path))
|
||||
// Not a library any more.
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun runningAgainChangesNothing() {
|
||||
val pc = tmp.newFolder("PC")
|
||||
exe(pc, "Alpha/Alpha.exe")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
|
||||
GamesFolderMigration.run(app)
|
||||
val entries = AddedExes.list(app).map { it.folder to it.exe }
|
||||
val binds = SessionPrefs.gameFolderBinds(app)
|
||||
val before = AddedGames.scan(app).map { it.appId to it.guestExe }
|
||||
GamesFolderMigration.run(app)
|
||||
GamesFolderMigration.run(app)
|
||||
assertEquals(entries, AddedExes.list(app).map { it.folder to it.exe })
|
||||
assertEquals(binds, SessionPrefs.gameFolderBinds(app))
|
||||
assertEquals(before, AddedGames.scan(app).map { it.appId to it.guestExe })
|
||||
assertEquals(listOf(pc.path to AddedGames.GUEST_DIR + "/PC"), binds)
|
||||
}
|
||||
|
||||
@Test fun twoFoldersOfOneNameKeepTheirOwnGuestPaths() {
|
||||
val a = File(tmp.newFolder("a"), "Games").apply { mkdirs() }
|
||||
val b = File(tmp.newFolder("b"), "Games").apply { mkdirs() }
|
||||
exe(a, "One/One.exe")
|
||||
exe(b, "Two/Two.exe")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(a.path, b.path))
|
||||
val guests = GamesFolderMigration.legacyRoots(listOf(a.path, b.path)).map { it.guest }
|
||||
val games = AddedGames.scan(app).associateBy { it.name }
|
||||
assertEquals(guests[0] + "/One/One.exe", games.getValue("One").guestExe)
|
||||
assertEquals(guests[1] + "/Two/Two.exe", games.getValue("Two").guestExe)
|
||||
assertTrue(guests[0] != guests[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Steam's app info asked anonymously: the answer read, batched, kept, and asked only when needed. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class SteamPicsTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
/** Dragon's Dogma 2's app info as Steam's anonymous PICS answer carried it (recorded 2026-10-10). */
|
||||
private val recorded: String get() = javaClass.classLoader!!.getResource("steam-pics/app_2054970.vdf")!!.readText()
|
||||
|
||||
@After fun tearDown() { SteamAppInfo.pics = { SteamPics.lookup(it) } }
|
||||
|
||||
@Test fun theRecordedAnswerGivesTheSharedRedistAndTheName() {
|
||||
val info = SteamPics.infoFromKeyValues(recorded)!!
|
||||
assertEquals("Dragon's Dogma 2", info.name)
|
||||
assertEquals(listOf("228988"), info.sharedDepots)
|
||||
// VC++ 2019 for the Windows components.
|
||||
assertEquals(listOf("vcredist2019_dll" to "VC++ 2019"), WinCompSources.steamDepots(info.sharedDepots))
|
||||
}
|
||||
|
||||
private fun message(emsg: Int, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + body.size).order(ByteOrder.LITTLE_ENDIAN).putInt(emsg or 0x80000000.toInt()).putInt(0).put(body).array()
|
||||
|
||||
@Test fun aProductInfoResponseInsideAGzippedMultiIsRead() {
|
||||
val appEntry = SteamPics.Proto().varint(1, 2054970).varint(2, 123).bytes(5, recorded.toByteArray() + byteArrayOf(0)).bytes()
|
||||
val response = SteamPics.Proto().bytes(1, appEntry).varint(2, 999).bytes()
|
||||
val inner = message(8904, response)
|
||||
val packed = ByteBuffer.allocate(4 + inner.size).order(ByteOrder.LITTLE_ENDIAN).putInt(inner.size).put(inner).array()
|
||||
val gz = ByteArrayOutputStream().also { o -> GZIPOutputStream(o).use { it.write(packed) } }.toByteArray()
|
||||
val multi = message(1, SteamPics.Proto().varint(1, packed.size.toLong()).bytes(2, gz).bytes())
|
||||
|
||||
val msgs = SteamPics.messages(multi)
|
||||
assertEquals(listOf(8904), msgs.map { it.first })
|
||||
val r = SteamPics.parseInfoResponse(msgs.single().third)
|
||||
assertEquals(listOf("228988"), r.apps.getValue(2054970).sharedDepots)
|
||||
assertEquals(listOf(999), r.unknown)
|
||||
assertFalse(r.pending)
|
||||
}
|
||||
|
||||
@Test fun requestsAreBatchedByTwentyFive() {
|
||||
val ids = (1..60).toList() + listOf(5, 6)
|
||||
val batches = SteamPics.batches(ids)
|
||||
assertEquals(listOf(25, 25, 10), batches.map { it.size })
|
||||
assertEquals((1..60).toList(), batches.flatten())
|
||||
}
|
||||
|
||||
@Test fun answersAreKeptAndAMissIsAskedAgainOnlyAfterAWeek() {
|
||||
val calls = ArrayList<List<Int>>()
|
||||
SteamAppInfo.pics = { ids -> calls.add(ids); mapOf(2054970 to SteamPics.infoFromKeyValues(recorded), 7 to null) }
|
||||
assertTrue(SteamAppInfo.lookup(app, listOf(2054970, 7)))
|
||||
// One connection for both.
|
||||
assertEquals(listOf(listOf(2054970, 7)), calls)
|
||||
assertEquals(listOf("228988"), SteamAppInfo.info(app, 2054970)!!.sharedDepots)
|
||||
assertNull(SteamAppInfo.info(app, 7))
|
||||
// Kept: nothing asked again (the miss waits a week).
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(2054970, 7)))
|
||||
assertEquals(1, calls.size)
|
||||
assertEquals("hit", SteamAppInfo.state(app, 2054970))
|
||||
assertEquals("miss", SteamAppInfo.state(app, 7))
|
||||
}
|
||||
|
||||
@Test fun noNetworkWhenTheClientsCacheHasTheApp() {
|
||||
// An appinfo.vdf with the app in it (built as in WinCompSourcesTest).
|
||||
val file = SteamAppInfo.appinfoFile(app)
|
||||
WinCompSourcesTest.writeAppinfo(file, 1145360)
|
||||
SteamAppInfo.pics = { fail("asked Steam"); null }
|
||||
assertTrue(SteamAppInfo.needLookup(app, listOf(1145360)).isEmpty())
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(1145360)))
|
||||
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
|
||||
}
|
||||
|
||||
@Test fun anUnreachableSteamKeepsNothingAndTriesAgain() {
|
||||
SteamAppInfo.pics = { null }
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(42)))
|
||||
assertEquals(listOf(42), SteamAppInfo.needLookup(app, listOf(42)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Each source's list of Windows components, how it maps to the catalog, and what turns on by itself. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class WinCompSourcesTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun game(folder: File?, source: String, appId: Int = 0x8123_4567.toInt(), library: String = Library.ADDED, name: String = "Game") =
|
||||
Library.SteamGame(appId, name, null, library, gameFiles = folder, source = source)
|
||||
|
||||
private fun sidecar(folder: File, store: Store, extra: Map<String, String>) =
|
||||
StoreGameSidecar(store, "1", "Game", exe = "Game.exe", extra = extra).write(folder)
|
||||
|
||||
private fun auto(findings: List<WinCompSources.Finding>) = findings.filter { it.auto }.map { it.component to it.origin }
|
||||
|
||||
// ---- mapping tables ----
|
||||
|
||||
@Test fun gogDependencyIdsMapToComponents() {
|
||||
assertEquals(listOf("vcredist2015"), WinCompSources.gog("MSVC2017_x64"))
|
||||
assertEquals(listOf("vcredist2022"), WinCompSources.gog("MSVC2022"))
|
||||
assertEquals(listOf("vcredist2013"), WinCompSources.gog("msvc2013"))
|
||||
assertEquals(listOf("d3dx9"), WinCompSources.gog("DirectX"))
|
||||
assertEquals(listOf("dotnet48"), WinCompSources.gog("DOTNET48"))
|
||||
assertEquals(listOf("dotnet472"), WinCompSources.gog("DotNet471"))
|
||||
assertEquals(listOf("physx"), WinCompSources.gog("PhysX"))
|
||||
assertEquals(listOf("oalinst"), WinCompSources.gog("OpenAL"))
|
||||
assertEquals(listOf("xna40"), WinCompSources.gog("XNA4"))
|
||||
assertEquals(emptyList<String>(), WinCompSources.gog("ISI"))
|
||||
}
|
||||
|
||||
@Test fun epicPrerequisitesMapToComponents() {
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", ""))
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe", "UE4 Prerequisites (x64)"))
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("", "UE4 Prerequisites (x64)"))
|
||||
assertEquals(listOf("vcredist2013"), WinCompSources.epic("Redist/vcredist/2013/vcredist_x64.exe", "Visual C++"))
|
||||
assertEquals(emptyList<String>(), WinCompSources.epic("Installers/LauncherHelper.exe", "Launcher Helper"))
|
||||
}
|
||||
|
||||
@Test fun steamworksSharedDepotsMapToComponents() {
|
||||
assertEquals(listOf("vcredist2015_dll" to "VC++ 2015", "d3dx9" to "DirectX (June 2010)"), WinCompSources.steamDepots(listOf("228986", "228990", "999")))
|
||||
}
|
||||
|
||||
// ---- per source ----
|
||||
|
||||
@Test fun steamTakesItsSharedDepotsAndTurnsThemOn() {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
val folder = File(steamapps, "common/Game").apply { mkdirs() }
|
||||
File(steamapps, "appmanifest_570.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"appid\"\t\t\"570\"\n\t\"SharedDepots\"\n\t{\n\t\t\"228986\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
|
||||
)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.SOURCE_STEAM, 570, library = "steam", name = "Dota"))
|
||||
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
|
||||
assertEquals(listOf("VC++ 2015", "Dota", "570"), findings.first().args)
|
||||
}
|
||||
|
||||
@Test fun gogTakesTheBuildsDependencies() {
|
||||
val folder = tmp.newFolder("GogGame")
|
||||
sidecar(folder, Store.GOG, mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2017_x64,DirectX,ISI"))
|
||||
val findings = WinCompSources.forGame(app, game(folder, "gog"))
|
||||
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(findings))
|
||||
// ISI is none of ours: a suggestion under its own name.
|
||||
assertEquals(listOf("ISI"), findings.filter { it.component == null }.map { it.original })
|
||||
}
|
||||
|
||||
@Test fun epicTakesTheManifestsPrerequisite() {
|
||||
val folder = tmp.newFolder("EpicGame")
|
||||
sidecar(folder, Store.EPIC, mapOf(WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", WinCompSources.EPIC_PREREQ_NAME to "UE Prerequisites"))
|
||||
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
|
||||
}
|
||||
|
||||
@Test fun amazonTakesFuelJsonsPostInstall() {
|
||||
val folder = tmp.newFolder("AmazonGame")
|
||||
sidecar(folder, Store.AMAZON, emptyMap())
|
||||
File(folder, "fuel.json").writeText(JSONObject()
|
||||
.put("Main", JSONObject().put("Command", "Game.exe"))
|
||||
.put("PostInstall", org.json.JSONArray()
|
||||
.put(JSONObject().put("Command", "_CommonRedist\\vcredist\\2015\\vc_redist.x64.exe").put("Args", org.json.JSONArray().put("/q")))
|
||||
.put(JSONObject().put("Command", "_CommonRedist\\DirectX\\Jun2010\\DXSETUP.exe"))
|
||||
.put(JSONObject().put("Command", "Tools\\Activate.exe")))
|
||||
.toString())
|
||||
val findings = WinCompSources.forGame(app, game(folder, "amazon"))
|
||||
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.AMAZON, "d3dx9" to WinCompSources.Origin.AMAZON), auto(findings))
|
||||
assertEquals(listOf("Activate.exe"), findings.filter { it.component == null }.map { it.original })
|
||||
}
|
||||
|
||||
// ---- Custom: certainty decides ----
|
||||
|
||||
private fun appinfo(file: File, appId: Int) = writeAppinfo(file, appId)
|
||||
|
||||
companion object {
|
||||
/** A version 29 appinfo.vdf with one app, whose depots point at two Steamworks Shared ones. */
|
||||
fun writeAppinfo(file: File, appId: Int) {
|
||||
val strings = listOf("appinfo", "common", "name", "depots", "228986", "depotfromapp", "228990", "1001", "maxsize")
|
||||
fun key(s: String) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.indexOf(s)).array()
|
||||
val kv = ByteArrayOutputStream().apply {
|
||||
fun map(k: String) { write(0x00); write(key(k)) }
|
||||
fun str(k: String, v: String) { write(0x01); write(key(k)); write(v.toByteArray()); write(0) }
|
||||
fun end() = write(0x08)
|
||||
map("appinfo"); map("common"); str("name", "Some Game"); end()
|
||||
map("depots"); map("228986"); str("depotfromapp", "228980"); end(); map("228990"); str("depotfromapp", "228980"); end()
|
||||
map("1001"); str("maxsize", "1"); end(); end(); end(); end()
|
||||
}.toByteArray()
|
||||
val body = ByteArray(60) + kv
|
||||
val table = ByteArrayOutputStream().apply {
|
||||
write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.size).array())
|
||||
strings.forEach { write(it.toByteArray()); write(0) }
|
||||
}.toByteArray()
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN).putInt(0x07564429).putInt(1)
|
||||
val entries = ByteBuffer.allocate(8 + body.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(appId).putInt(body.size).put(body).putInt(0).array()
|
||||
head.putLong(16L + entries.size)
|
||||
file.parentFile!!.mkdirs()
|
||||
file.writeBytes(head.array() + entries + table)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun steamsAppInfoIsReadForAnAppWithoutAManifest() {
|
||||
val file = File(tmp.newFolder(), "appinfo.vdf")
|
||||
appinfo(file, 1145360)
|
||||
val info = SteamAppInfo.read(file, 1145360)!!
|
||||
assertEquals("Some Game", info.name)
|
||||
assertEquals(listOf("228986", "228990"), info.sharedDepots)
|
||||
assertEquals(null, SteamAppInfo.read(file, 1))
|
||||
}
|
||||
|
||||
@Test fun customWithAnAppidInItsFilesIsCertainAndOnByItself() {
|
||||
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
|
||||
val folder = tmp.newFolder("Hades")
|
||||
File(folder, "steam_appid.txt").writeText("1145360")
|
||||
val exe = File(folder, "Hades.exe").apply { writeText("x") }
|
||||
assertEquals(SteamMatch.Certainty.FILES, SteamMatch.resolve(app, folder.path, exe, "Hades").certainty)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
|
||||
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
|
||||
}
|
||||
|
||||
@Test fun customMatchedByNameIsOnlyRecommended() {
|
||||
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
|
||||
val folder = tmp.newFolder("Hades Named")
|
||||
val exe = File(folder, "Hades.exe").apply { writeText("x") }
|
||||
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.resolve(app, folder.path, exe, "Hades") { 1145360 }.certainty)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
|
||||
assertTrue(auto(findings).isEmpty())
|
||||
assertEquals(listOf("vcredist2015_dll", "d3dx9"), findings.filter { it.origin == WinCompSources.Origin.STEAM_NAME }.map { it.component })
|
||||
}
|
||||
|
||||
@Test fun customWithNoMatchHasTheFolderScanOnly() {
|
||||
val folder = tmp.newFolder("Unknown")
|
||||
File(folder, "_CommonRedist/vcredist/2013").mkdirs()
|
||||
File(folder, "_CommonRedist/vcredist/2013/vcredist_x64.exe").writeText("x")
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED))
|
||||
assertTrue(auto(findings).isEmpty())
|
||||
assertEquals(listOf("vcredist2013" to WinCompSources.Origin.FOLDER), findings.map { it.component to it.origin })
|
||||
}
|
||||
|
||||
// ---- what turns on, and the user's switches ----
|
||||
|
||||
private fun ready(name: String) = WinComponents.Component(name, "", "", "ready", emptyList(),
|
||||
listOf(WinComponents.Step("override_dll", JSONObject().put("dll", "x").put("type", "native"))))
|
||||
|
||||
@Test fun onlyAutoFindingsThatInstallHereTurnOn() {
|
||||
val catalog = listOf(ready("vcredist2015_dll"), ready("vcredist2015"), ready("d3dx9"),
|
||||
WinComponents.Component("physx", "", "", "broken", emptyList(), emptyList())).associateBy { it.name }
|
||||
val findings = listOf(
|
||||
WinCompSources.Finding("vcredist2015", "MSVC2017", WinCompSources.Origin.GOG, true),
|
||||
WinCompSources.Finding("physx", "PhysX", WinCompSources.Origin.GOG, true),
|
||||
WinCompSources.Finding("d3dx9", "d3dx9_43.dll", WinCompSources.Origin.FOLDER, false),
|
||||
)
|
||||
val picks = AutoComponents.autoPicks(findings, catalog)
|
||||
// The detector's name installs as its DLL twin; physx cannot install; the folder scan only recommends.
|
||||
assertEquals(listOf("vcredist2015_dll"), picks.keys.toList())
|
||||
assertEquals("GOG", picks.getValue("vcredist2015_dll").kind)
|
||||
}
|
||||
|
||||
@Test fun theUsersSwitchWinsOverAnAutoPickEitherWay() {
|
||||
val key = "12345"
|
||||
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX")), "xact" to WinComponents.AutoReason("GOG", listOf("XACT"))))
|
||||
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, key))
|
||||
WinComponents.setUser(app, key, "d3dx9", false)
|
||||
WinComponents.setUser(app, key, "oalinst_dll", true)
|
||||
assertEquals(listOf("xact", "oalinst_dll"), WinComponents.picks(app, key))
|
||||
// A new automatic list keeps the user's switches.
|
||||
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX"))))
|
||||
assertEquals(listOf("oalinst_dll"), WinComponents.picks(app, key))
|
||||
assertEquals(mapOf("d3dx9" to false, "oalinst_dll" to true), WinComponents.selection(app, key).user)
|
||||
// What the launch reads stays version 1 with the effective list.
|
||||
val text = File(app.filesDir, "wincomponents.json").readText()
|
||||
assertEquals(1, JSONObject(text).getInt("version"))
|
||||
assertEquals("oalinst_dll", JSONObject(text).getJSONObject("games").getJSONArray(key).getString(0))
|
||||
}
|
||||
|
||||
@Test fun picksFromBeforeOverridesAreTheUsersOwn() {
|
||||
File(app.filesDir, "wincomponents.json").writeText("""{"version":1,"games":{"777":["d3dx9","xact"]}}""")
|
||||
assertEquals(mapOf("d3dx9" to true, "xact" to true), WinComponents.selection(app, "777").user)
|
||||
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, "777"))
|
||||
}
|
||||
|
||||
@Test fun artAndComponentsShareOneSteamMatch() {
|
||||
val folder = tmp.newFolder("Shared")
|
||||
val exe = File(folder, "Shared.exe").apply { writeText("x") }
|
||||
val art = AddedGames.Game(folder, "Shared", exe, "/g/Shared.exe", "/g", 0x81234567L, 0L, emptyList())
|
||||
// The art fetch finds it by name ...
|
||||
val match = AddedGameArt.steamMatch(app, art) { 4242 }
|
||||
assertEquals(4242, match.appId)
|
||||
// ... and the components read the same record.
|
||||
assertEquals(4242, SteamMatch.get(app, folder.path)!!.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.get(app, folder.path)!!.certainty)
|
||||
assertFalse(WinCompSources.forGame(app, game(folder, Library.ADDED)).any { it.auto })
|
||||
}
|
||||
|
||||
private val catalog3 = listOf(ready("d3dx9"), ready("vcredist2013_dll"), ready("vcredist2019_dll"))
|
||||
private val installedNow = mutableSetOf<String>()
|
||||
private val downloaded = ArrayList<String>()
|
||||
private var session = false
|
||||
private var gameUp = false
|
||||
|
||||
private fun payback(): Library.SteamGame {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
val folder = File(steamapps, "common/Payback").apply { mkdirs() }
|
||||
File(steamapps, "appmanifest_1262580.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"SharedDepots\"\n\t{\n\t\t\"228985\"\t\t\"228980\"\n\t\t\"228988\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
|
||||
)
|
||||
return game(folder, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Need for Speed Payback")
|
||||
}
|
||||
|
||||
private fun <T> withFakeWorker(block: () -> T): T {
|
||||
AutoComponents.catalogOf = { _, _ -> catalog3 }
|
||||
AutoComponents.installedOf = { installedNow.toSet() }
|
||||
AutoComponents.installer = { _, c, _, _ -> downloaded.add(c.name); installedNow.add(c.name); null }
|
||||
AutoComponents.sessionRunning = { session }
|
||||
AutoComponents.unfavourable = { false }
|
||||
AutoComponents.online = { true }
|
||||
AutoComponents.gameRunning = { gameUp }
|
||||
AutoComponents.runNow = true
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
AutoComponents.runNow = false
|
||||
AutoComponents.catalogOf = { c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
AutoComponents.installedOf = { WinComponents.installedIds(it).toSet() }
|
||||
AutoComponents.installer = { c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
AutoComponents.sessionRunning = { com.droiddeck.launcher.session.SessionState.running }
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
AutoComponents.gameRunning = { false }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aSteamListedComponentNotYetDownloadedTurnsOnWhenItLands() = withFakeWorker {
|
||||
installedNow.add("d3dx9")
|
||||
val game = payback()
|
||||
// The user had switched one off: it is never downloaded for an automatic pick.
|
||||
WinComponents.setUser(app, "1262580", "vcredist2019_dll", false)
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertEquals(listOf("vcredist2013_dll"), downloaded)
|
||||
val sel = WinComponents.selection(app, "1262580")
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), sel.auto.keys)
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll"), WinComponents.picks(app, "1262580").toSet())
|
||||
assertEquals("STEAM", sel.auto.getValue("vcredist2013_dll").kind)
|
||||
// The list is kept for the page.
|
||||
assertEquals(3, AutoComponents.cachedFindings(app, "1262580")!!.count { it.auto })
|
||||
}
|
||||
|
||||
@Test fun theQueueWaitsWhileASessionRunsAndResumesAfter() = withFakeWorker {
|
||||
val game = payback()
|
||||
session = true
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
assertEquals(listOf("g:1262580"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(listOf("vcredist2013_dll", "vcredist2019_dll", "d3dx9"), downloaded)
|
||||
assertTrue(AutoComponents.queued().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aJobNobodyAskedForWaitsOnAnUnfavourableDevice() = withFakeWorker {
|
||||
val game = payback()
|
||||
AutoComponents.unfavourable = { true }
|
||||
AutoComponents.queueGame(app, game, asked = false)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
// The user opens the page: asked for, it goes.
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertEquals(3, downloaded.size)
|
||||
}
|
||||
|
||||
@Test fun aGameAndAComponentAreQueuedOnceEach() = withFakeWorker {
|
||||
val first = payback()
|
||||
val other = game(first.gameFiles, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Same app again")
|
||||
session = true
|
||||
AutoComponents.queueGame(app, first, asked = false)
|
||||
AutoComponents.queueGame(app, other, asked = false)
|
||||
assertEquals(listOf("g:1262580"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
// Each component once, though two games could want it.
|
||||
assertEquals(downloaded.distinct(), downloaded)
|
||||
}
|
||||
|
||||
@Test fun aDownloadWaitsForANetwork() = withFakeWorker {
|
||||
val game = payback()
|
||||
AutoComponents.online = { false }
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
// The list is worked out offline; the downloads wait.
|
||||
assertTrue(downloaded.isEmpty())
|
||||
assertTrue(AutoComponents.queued().all { it.startsWith("c:") })
|
||||
AutoComponents.online = { true }
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(3, downloaded.size)
|
||||
}
|
||||
|
||||
// ---- older store installs: the list read once from the store ----
|
||||
|
||||
@Test fun anOlderGogInstallGetsItsListFromTheStoreOnce() {
|
||||
val folder = tmp.newFolder("DOOM I Enhanced")
|
||||
StoreGameSidecar(Store.GOG, "2015545325", "DOOM", exe = "DOOM.exe", installVersion = "123").write(folder)
|
||||
var fetches = 0
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, sc -> fetches++; assertEquals("123", sc.installVersion); mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2019_x64,DirectX") }
|
||||
AutoComponents.online = { true }
|
||||
try {
|
||||
assertTrue(StoreListBackfill.fill(app, folder))
|
||||
assertEquals("MSVC2019_x64,DirectX", StoreGameSidecar.read(folder)!!.extra[WinCompSources.GOG_DEPENDENCIES])
|
||||
// Kept like a fresh install: not fetched again.
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertEquals(1, fetches)
|
||||
assertEquals(listOf("vcredist2019" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(WinCompSources.forGame(app, game(folder, "gog"))))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anOlderEpicInstallGetsItsPrerequisite() {
|
||||
val folder = tmp.newFolder("DOOMBLADE")
|
||||
StoreGameSidecar(Store.EPIC, "abc", "DOOMBLADE", exe = "DOOMBLADE.exe", extra = mapOf("namespace" to "ns", "catalogItemId" to "cat")).write(folder)
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to "UE4 Prerequisites (x64)", WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe")
|
||||
}
|
||||
AutoComponents.online = { true }
|
||||
try {
|
||||
assertTrue(StoreListBackfill.fill(app, folder))
|
||||
assertEquals("ns", StoreGameSidecar.read(folder)!!.extra["namespace"])
|
||||
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aFailedFetchWaitsAWeekAndOfflineIsNoTry() {
|
||||
val folder = tmp.newFolder("Metalstorm")
|
||||
StoreGameSidecar(Store.EPIC, "m", "Metalstorm", exe = "M.exe").write(folder)
|
||||
var fetches = 0
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ -> fetches++; null }
|
||||
try {
|
||||
AutoComponents.online = { false }
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertEquals(0, fetches)
|
||||
AutoComponents.online = { true }
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
// Signed out or the store refused: once, then a week's rest; the sidecar is untouched.
|
||||
assertEquals(1, fetches)
|
||||
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(folder)!!))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- apps the client never saw ----
|
||||
|
||||
@Test fun anAppTheClientLearnsLaterIsReadOnceItsCacheIsWritten() {
|
||||
val file = SteamAppInfo.appinfoFile(app)
|
||||
appinfo(file, 1)
|
||||
file.setLastModified(1_000_000_000_000L)
|
||||
assertEquals(null, SteamAppInfo.info(app, 1145360))
|
||||
// Unchanged file: the miss stands (no re-read).
|
||||
assertEquals(null, SteamAppInfo.info(app, 1145360))
|
||||
// The client wrote its cache with the app in it: read at once, not a week later.
|
||||
appinfo(file, 1145360)
|
||||
file.setLastModified(1_000_000_100_000L)
|
||||
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
|
||||
}
|
||||
|
||||
@Test fun unitysManagedFolderIsNotADotNetRequirement() {
|
||||
val folder = tmp.newFolder("DOOMBLADE")
|
||||
File(folder, "DOOMBLADE_Data/Managed").mkdirs()
|
||||
File(folder, "DOOMBLADE_Data/Managed/com.rlabrecque.steamworks.net.dll").writeText("x")
|
||||
File(folder, "DOOMBLADE_Data/Managed/System.Net.Http.dll").writeText("x")
|
||||
assertTrue(DependencyDetector.detect(folder).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aListReadWholeIsNotFetchedAgainButAnUnmarkedEmptyOneIs() {
|
||||
val read = tmp.newFolder("Read")
|
||||
StoreGameSidecar(Store.EPIC, "a", "A", exe = "A.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "", WinCompSources.LIST_READ to "1")).write(read)
|
||||
val older = tmp.newFolder("Older")
|
||||
StoreGameSidecar(Store.EPIC, "b", "B", exe = "B.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "")).write(older)
|
||||
assertFalse(StoreListBackfill.needs(StoreGameSidecar.read(read)!!))
|
||||
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(older)!!))
|
||||
}
|
||||
|
||||
// ---- a game installed during the session ----
|
||||
|
||||
private fun manifest(dir: File, appId: Int, state: Int, time: Long) =
|
||||
File(dir, "appmanifest_$appId.acf").apply {
|
||||
writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"$appId\"\n\t\"name\"\t\t\"Audiosurf\"\n\t\"installdir\"\t\t\"Audiosurf\"\n\t\"StateFlags\"\t\t\"$state\"\n}\n")
|
||||
setLastModified(time)
|
||||
}
|
||||
|
||||
@Test fun aManifestTurningInstalledIsNoticedOnce() {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
manifest(steamapps, 12900, 1026, 1_000_000L)
|
||||
manifest(steamapps, 570, 4, 1_000_000L)
|
||||
val watch = SessionInstalls.Watch { listOf(steamapps) }
|
||||
// What was there when the session started is not new.
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
manifest(steamapps, 12900, 4, 2_000_000L)
|
||||
manifest(steamapps, 440, 4, 2_000_000L)
|
||||
val done = watch.poll()
|
||||
assertEquals(setOf(12900, 440), done.map { it.appId }.toSet())
|
||||
assertEquals(File(steamapps, "common/Audiosurf"), SessionInstalls.gameOf(done.first { it.appId == 12900 }).gameFiles)
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun anInSessionJobWaitsWhileAGameRunsThenReadiesItsComponents() = withFakeWorker {
|
||||
val installed = payback()
|
||||
session = true
|
||||
gameUp = true
|
||||
AutoComponents.queueInSession(app, installed)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
// Other work stays paused in the session.
|
||||
val other = game(tmp.newFolder("Other"), Library.ADDED)
|
||||
AutoComponents.queueGame(app, other, asked = false)
|
||||
gameUp = false
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(3, downloaded.size)
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), WinComponents.picks(app, "1262580").toSet())
|
||||
// Nothing left pending for the launch to wait on; the other game still waits for the session's end.
|
||||
assertEquals(0, AutoComponents.Pending.read(app).length())
|
||||
assertEquals(listOf("g:${AutoComponents.appKey(other)}"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
assertTrue(AutoComponents.queued().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.view.InputDevice
|
||||
import android.view.InputEvent
|
||||
import android.view.KeyCharacterMap
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.Implementation
|
||||
import org.robolectric.annotation.Implements
|
||||
import org.robolectric.annotation.RealObject
|
||||
import org.robolectric.shadow.api.Shadow
|
||||
import org.robolectric.shadows.InputDeviceBuilder
|
||||
import org.robolectric.shadows.ShadowKeyEvent
|
||||
import org.robolectric.shadows.ShadowMotionEvent
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], manifest = Config.NONE, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class,
|
||||
PadBridgeRoutingTest.RegisteredMotionEvent::class])
|
||||
class PadBridgeRoutingTest {
|
||||
@Test fun aCompositeKeyboardDoesNotCountAsAConnectedPad() {
|
||||
val keyboard = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
|
||||
assertFalse(PadBridge.isFromController(keyboard))
|
||||
assertFalse(PadBridge.anyControllerConnected())
|
||||
assertFalse(PadBridge.isFromController(device(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)))
|
||||
}
|
||||
|
||||
@Test fun mouseAxesDoNotMakeACompositeKeyboardAJoystick() {
|
||||
val keyboard = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC, rangeSource = InputDevice.SOURCE_MOUSE)
|
||||
assertFalse(PadBridge.isFromController(keyboard))
|
||||
assertFalse(PadBridge.anyControllerConnected())
|
||||
}
|
||||
|
||||
@Test fun gamepadsKeepTheirKeyboardCapabilities() {
|
||||
for (type in listOf(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, InputDevice.KEYBOARD_TYPE_ALPHABETIC)) {
|
||||
assertTrue(PadBridge.isFromController(device(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_KEYBOARD, type)))
|
||||
}
|
||||
assertTrue(PadBridge.anyControllerConnected())
|
||||
}
|
||||
|
||||
@Test fun aJoystickWithoutKeyboardCapabilitiesRemainsAController() {
|
||||
assertTrue(PadBridge.isFromController(device(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE)))
|
||||
assertTrue(PadBridge.isFromController(device(COMPOSITE, InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC)))
|
||||
}
|
||||
|
||||
@Test fun joystickRangesDistinguishAHybridFromIncidentalSourceBits() {
|
||||
for (axis in listOf(MotionEvent.AXIS_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
|
||||
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC,
|
||||
rangeSource = InputDevice.SOURCE_JOYSTICK, axis = axis)
|
||||
assertTrue(PadBridge.isFromController(hybrid))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun physicalControllerButtonsDistinguishAHybridWithoutAxes() {
|
||||
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC,
|
||||
keys = intArrayOf(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_BUTTON_A))
|
||||
assertTrue(hybrid.hasKeys(KeyEvent.KEYCODE_BUTTON_A)[0])
|
||||
assertTrue(PadBridge.isFromController(hybrid))
|
||||
}
|
||||
|
||||
@Test fun absentAndVirtualDevicesDoNotCountAsControllers() {
|
||||
assertFalse(PadBridge.isFromController(null))
|
||||
val virtual = device(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK,
|
||||
InputDevice.KEYBOARD_TYPE_NONE, id = -1)
|
||||
assertTrue(virtual.isVirtual)
|
||||
assertFalse(PadBridge.isFromController(virtual))
|
||||
}
|
||||
|
||||
@Test fun keyAndMotionOwnershipDoesNotFollowEverySourceOnTheDevice() {
|
||||
val keyboard = device(InputDevice.SOURCE_KEYBOARD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
|
||||
val composite = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
|
||||
val hybrid = device(COMPOSITE, InputDevice.KEYBOARD_TYPE_ALPHABETIC, rangeSource = InputDevice.SOURCE_JOYSTICK)
|
||||
val gamepad = device(COMPOSITE or InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_ALPHABETIC)
|
||||
val joystick = device(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE)
|
||||
val virtual = device(InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NONE, id = -1)
|
||||
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
|
||||
for (device in listOf(keyboard, composite, hybrid, gamepad, joystick)) {
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_A, action)))
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_A, action, InputDevice.SOURCE_GAMEPAD)))
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(device, KeyEvent.KEYCODE_VOLUME_UP, action)))
|
||||
}
|
||||
for (code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_BACK,
|
||||
KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_HOME)) {
|
||||
for (device in listOf(keyboard, composite, hybrid)) {
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(device, code, action)))
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(device, code, action, InputDevice.SOURCE_DPAD)))
|
||||
}
|
||||
assertTrue(PadBridge.isControllerKeyEvent(key(gamepad, code, action)))
|
||||
assertTrue(PadBridge.isControllerKeyEvent(key(joystick, code, action)))
|
||||
assertTrue(PadBridge.isControllerKeyEvent(key(hybrid, code, action, InputDevice.SOURCE_JOYSTICK)))
|
||||
}
|
||||
for (code in listOf(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B,
|
||||
KeyEvent.KEYCODE_BUTTON_L2, KeyEvent.KEYCODE_BUTTON_R2, KeyEvent.KEYCODE_BUTTON_MODE)) {
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(keyboard, code, action)))
|
||||
assertFalse(PadBridge.isControllerKeyEvent(key(virtual, code, action)))
|
||||
for (device in listOf(composite, hybrid, gamepad, joystick)) {
|
||||
assertTrue(PadBridge.isControllerKeyEvent(key(device, code, action)))
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNull(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_A, 0, 0, 999, 0).device)
|
||||
assertFalse(PadBridge.isControllerKeyEvent(KeyEvent(0L, 0L, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BUTTON_A, 0, 0, 999, 0)))
|
||||
for (device in listOf(keyboard, composite, hybrid, gamepad, joystick, virtual)) {
|
||||
val motion = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1,
|
||||
arrayOf(MotionEvent.PointerProperties().apply { id = 0 }),
|
||||
arrayOf(MotionEvent.PointerCoords().apply { setAxisValue(MotionEvent.AXIS_X, 0.75f) }),
|
||||
0, 0, 1f, 1f, device.id, 0, InputDevice.SOURCE_JOYSTICK, 0)
|
||||
try {
|
||||
assertSame(device, motion.device)
|
||||
assertEquals(device in listOf(composite, hybrid, gamepad, joystick), PadBridge.isControllerMotionEvent(motion))
|
||||
motion.source = InputDevice.SOURCE_MOUSE
|
||||
assertFalse(PadBridge.isControllerMotionEvent(motion))
|
||||
} finally { motion.recycle() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(device: InputDevice, code: Int, action: Int, source: Int = InputDevice.SOURCE_KEYBOARD): KeyEvent {
|
||||
val event = KeyEvent(0L, 0L, action, code, 0, 0, device.id, 0, 0, source)
|
||||
assertSame(device, event.device)
|
||||
return event
|
||||
}
|
||||
|
||||
private var nextId = 10
|
||||
|
||||
private fun device(sources: Int, type: Int, keys: IntArray = intArrayOf(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_DPAD_UP),
|
||||
rangeSource: Int? = null, axis: Int = MotionEvent.AXIS_X, id: Int = nextId++): InputDevice {
|
||||
val builder = InputDeviceBuilder.newBuilder().setId(id).setName("routing fixture $id")
|
||||
.setDescriptor("routing-$id").setExternal(true).setSources(sources).setKeyboardType(type)
|
||||
.setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD))
|
||||
if (rangeSource != null) builder.addMotionRange(axis, rangeSource, -1f, 1f, 0f, 0f, 0f)
|
||||
val device = builder.build()
|
||||
val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java)
|
||||
shadowOf(manager).addInputDevice(device)
|
||||
shadowOf(manager).addInputDeviceKeys(id, keys)
|
||||
assertSame(device, InputDevice.getDevice(id))
|
||||
assertEquals(sources, device.sources)
|
||||
assertEquals(type, device.keyboardType)
|
||||
return device
|
||||
}
|
||||
|
||||
private companion object { const val COMPOSITE = 0x1002313 }
|
||||
|
||||
// Robolectric's default InputEvent shadow has its own nullable device field. Use Android's
|
||||
// original lookup so key and motion events resolve the registered device by id.
|
||||
@Implements(KeyEvent::class)
|
||||
class RegisteredKeyEvent : ShadowKeyEvent() {
|
||||
@RealObject private lateinit var event: KeyEvent
|
||||
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
}
|
||||
|
||||
@Implements(MotionEvent::class)
|
||||
class RegisteredMotionEvent : ShadowMotionEvent() {
|
||||
@RealObject private lateinit var event: MotionEvent
|
||||
@Implementation override fun getDevice(): InputDevice? = Shadow.directlyOn(event as InputEvent, InputEvent::class.java, "getDevice")
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,14 @@ class GameStorageTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
|
||||
@Test fun onlyAGamesFolderSteamInstalledIntoStaysALibrary() {
|
||||
val pc = tmp.newFolder("PC")
|
||||
File(pc, "steamapps").mkdirs()
|
||||
File(pc, "steamapps/appmanifest_220.acf").writeText("\"AppState\" { \"appid\" \"220\" }")
|
||||
val plain = tmp.newFolder("Games")
|
||||
File(plain, "steamapps/common").mkdirs()
|
||||
val missing = File(tmp.root, "Unplugged")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path))
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, plain.path, missing.path))
|
||||
val libraries = GameStorage.gamesFolderLibraries(app)
|
||||
assertEquals(listOf(pc), libraries.map { it.host })
|
||||
assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest })
|
||||
@@ -28,7 +32,10 @@ class GameStorageTest {
|
||||
}
|
||||
|
||||
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
|
||||
val pc = tmp.newFolder("PC")
|
||||
File(pc, "steamapps").mkdirs()
|
||||
File(pc, "steamapps/appmanifest_220.acf").writeText("x")
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path))
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
|
||||
@@ -36,7 +43,9 @@ class GameStorageTest {
|
||||
}
|
||||
|
||||
@Test fun namesTheClientCannotStoreAreLeftOut() {
|
||||
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
|
||||
val odd = listOf(tmp.newFolder("Quote\"d"), tmp.newFolder("Back\\slash"))
|
||||
odd.forEach { File(it, "steamapps").mkdirs(); File(it, "steamapps/appmanifest_220.acf").writeText("x") }
|
||||
SessionPrefs.setAddedGamesDirs(app, odd.map { it.path })
|
||||
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class OrientationTest {
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun clearOrientation() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() {
|
||||
assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
|
||||
for (value in listOf(
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
|
||||
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE,
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
)) {
|
||||
SessionPrefs.setOrientation(context, value)
|
||||
assertEquals(value, SessionPrefs.orientation(context.createConfigurationContext(context.resources.configuration)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() {
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit()
|
||||
assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
|
||||
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
|
||||
}
|
||||
assertEquals(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE, SessionPrefs.orientation(context))
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,24 @@ class RumbleComponentTest {
|
||||
assertFalse("the phone buzzed as well", shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
@Test fun repeatedAndRapidEffectsDoNotRestartTheMotors() {
|
||||
val pad = FakeMotors()
|
||||
rumble.controllerMotors = { pad }
|
||||
activeController(PAD_ID)
|
||||
repeat(5) { effect(strong = 40000, weak = 1000) }
|
||||
effect(strong = 20000, weak = 1000)
|
||||
assertEquals("a stream of packets restarted the motor each time", 1, pad.played.size)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(RumbleComponent.MIN_GAP_MS))
|
||||
effect(strong = 40000, weak = 1000)
|
||||
assertEquals("the same strength, still playing, was started again", 1, pad.played.size)
|
||||
effect(strong = 20000, weak = 1000)
|
||||
assertEquals(Triple(20000, 1000, 5000L), pad.played.last())
|
||||
effect(strong = 0, weak = 0)
|
||||
assertFalse(pad.vibrating)
|
||||
effect(strong = 20000, weak = 1000)
|
||||
assertTrue("an effect right after a stop was dropped", pad.vibrating)
|
||||
}
|
||||
|
||||
@Test fun aControllerWithoutMotorsFallsBackToThePhone() {
|
||||
rumble.controllerMotors = { null }
|
||||
activeController(PAD_ID)
|
||||
|
||||
@@ -38,6 +38,15 @@ class SessionDisplayTest {
|
||||
assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN))
|
||||
}
|
||||
|
||||
@Test fun unfoldedControlsKeepTheGameSixteenByNineAcrossTheWidth() {
|
||||
// Fold inner screen held as a tablet, and upright: 16:9 either way, the controls the rest.
|
||||
assertEquals(1376, SessionDisplay.unfoldedGameHeight(2448, 1848))
|
||||
assertEquals(1038, SessionDisplay.unfoldedGameHeight(1848, 2448))
|
||||
// A window near 16:9 itself still leaves the controls a fifth of it.
|
||||
assertEquals(864, SessionDisplay.unfoldedGameHeight(1920, 1080))
|
||||
}
|
||||
|
||||
|
||||
@Test fun followScreenKeepsTheWindowShapeAndSkipsTinyWindows() {
|
||||
assertEquals(2520 to 1080, SessionDisplay.resolveChoice(1080 to 2520, SessionDisplay.FOLLOW_SCREEN))
|
||||
assertEquals(1968 to 2184, SessionDisplay.followSize(1968, 2184))
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class SteamChannelTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun freshSettingsDefaultToDeckBetaWithOrWithoutDeckMode() {
|
||||
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
|
||||
SessionPrefs.setSteamDeckMode(context, false)
|
||||
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
|
||||
}
|
||||
|
||||
@Test fun explicitChannelIsPreservedWhenDeckModeIsOff() {
|
||||
SessionPrefs.setSteamDeckMode(context, false)
|
||||
SessionPrefs.setSteamChannel(context, "publicbeta")
|
||||
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
|
||||
SessionPrefs.setSteamChannel(context, "steamdeck_publicbeta")
|
||||
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
|
||||
}
|
||||
|
||||
@Test fun deckModeUsesDeckBetaAndRestoresSavedSelectionWhenDisabled() {
|
||||
SessionPrefs.setSteamChannel(context, "publicbeta")
|
||||
assertEquals("steamdeck_publicbeta", SessionPrefs.steamChannel(context))
|
||||
SessionPrefs.setSteamDeckMode(context, false)
|
||||
assertEquals("publicbeta", SessionPrefs.steamChannel(context))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.robolectric.annotation.Config
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class WinComponentsSupportTest {
|
||||
private val base = "https://github.com/The412Banner/winlator-contents/releases/download/system-libraries-v1"
|
||||
private val base = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/system-libraries-v1"
|
||||
|
||||
private fun step(action: String, vararg pairs: Pair<String, String>) =
|
||||
WinComponents.Step(action, JSONObject().apply { pairs.forEach { (k, v) -> put(k, v) } })
|
||||
@@ -66,7 +66,7 @@ class WinComponentsSupportTest {
|
||||
}
|
||||
|
||||
@Test fun recordedInstallersInstallHere() {
|
||||
val snapshot = "https://github.com/The412Banner/winlator-contents/releases/download/component-snapshots-v1/K-Lite.snapshot.json"
|
||||
val snapshot = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/component-snapshots-v1/K-Lite.snapshot.json"
|
||||
// A setup program with a recording of its result installs from the recording.
|
||||
val klite = WinComponents.Component("K-Lite", "", "", "ready", emptyList(),
|
||||
listOf(step("install_exe", "url" to "$base/K-Lite.exe", "file_name" to "K-Lite_1960.exe")), snapshot)
|
||||
@@ -96,5 +96,7 @@ class WinComponentsSupportTest {
|
||||
assertEquals(Support.UNSUPPORTED, support(component("powershell", step("copy_file", "file_name" to "profile.ps1", "dest" to "win64"))))
|
||||
assertEquals(Support.UNSUPPORTED, support(component("VulkanRT", step("install_exe", "url" to "$base/VulkanRT.exe"))))
|
||||
assertEquals(Support.UNSUPPORTED, support(component("ie8_kb2936068", step("install_exe", "url" to "$base/ie8_kb2936068.exe"))))
|
||||
assertEquals(Support.UNSUPPORTED, support(component("oalinst", step("install_exe", "url" to "$base/oalinst.exe"))))
|
||||
assertEquals(Support.UNSUPPORTED, support(component("webview2", step("install_exe", "url" to "$base/webview2.exe"))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.session.SessionDisplay
|
||||
import com.droiddeck.launcher.ui.GamepadGeometry.Slot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GamepadGeometryTest {
|
||||
/** A foldable's unfolded panel (px) and density, as the gamepad gets it under a 16:9 game. */
|
||||
private data class Panel(val name: String, val width: Int, val height: Int, val density: Float) {
|
||||
val controlsHeight get() = height - SessionDisplay.unfoldedGameHeight(width, height)
|
||||
}
|
||||
|
||||
private val panels = listOf(
|
||||
Panel("Fold 8 upright", 1848, 2448, 2.625f),
|
||||
Panel("Fold 8 as a tablet", 2448, 1848, 2.625f),
|
||||
Panel("Pixel Fold upright", 1840, 2208, 2.625f),
|
||||
Panel("Pixel Fold as a tablet", 2208, 1840, 2.625f),
|
||||
Panel("OnePlus Open upright", 2268, 2440, 2.75f),
|
||||
Panel("OnePlus Open as a tablet", 2440, 2268, 2.75f),
|
||||
Panel("Fold 8 upright, large display size", 1848, 2448, 3.5f),
|
||||
Panel("Fold 8 as a tablet, small display size", 2448, 1848, 2.0f),
|
||||
)
|
||||
|
||||
private fun boxes(panel: Panel) =
|
||||
GamepadGeometry.layout(panel.width.toFloat(), panel.controlsHeight.toFloat(), panel.density)
|
||||
|
||||
@Test fun everyControlIsPlacedAndPanelsOnlyWhenUpright() {
|
||||
for (panel in panels) {
|
||||
val upright = panel.height > panel.width
|
||||
val expected = Slot.values().toSet() - if (upright) emptySet() else setOf(Slot.PANELS)
|
||||
assertEquals(panel.name, expected, boxes(panel).keys)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun everyControlStaysOnThePanel() {
|
||||
for (panel in panels) for ((slot, box) in boxes(panel)) {
|
||||
val where = "${panel.name}: $slot $box"
|
||||
assertTrue(where, box.left >= 0f && box.top >= 0f)
|
||||
assertTrue(where, box.right <= panel.width && box.bottom <= panel.controlsHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun noControlsOverlap() {
|
||||
for (panel in panels) {
|
||||
val placed = boxes(panel).entries.toList()
|
||||
for (i in placed.indices) for (j in i + 1 until placed.size) {
|
||||
assertFalse("${panel.name}: ${placed[i].key} overlaps ${placed[j].key}",
|
||||
placed[i].value.overlaps(placed[j].value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun everyControlIsBigEnoughForAThumb() {
|
||||
for (panel in panels) for ((slot, box) in boxes(panel)) {
|
||||
val minPx = 40 * panel.density
|
||||
assertTrue("${panel.name}: $slot is ${box.width}x${box.height}, under 40dp",
|
||||
box.width >= minPx - 1 && box.height >= minPx - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mainControlsSitUnderTheThumbsAndMirror() {
|
||||
for (panel in panels) {
|
||||
val b = boxes(panel)
|
||||
val left = b.getValue(Slot.LEFT_MAIN)
|
||||
val right = b.getValue(Slot.RIGHT_MAIN)
|
||||
assertEquals(panel.name, left.cy, right.cy, 0.5f)
|
||||
assertEquals(panel.name, left.cx, panel.width - right.cx, 0.5f)
|
||||
// In the bottom half of their side, nearer the corner than the second controls.
|
||||
assertTrue(panel.name, left.cx < b.getValue(Slot.LEFT_SECOND).cx)
|
||||
assertTrue(panel.name, left.cy > panel.controlsHeight / 2f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun middleButtonsKeepTheDeckOrder() {
|
||||
for (panel in panels) {
|
||||
val b = boxes(panel)
|
||||
// View before Menu and Steam before QAM: left of it in a row or two by two, above it
|
||||
// in a single column.
|
||||
fun before(a: Slot, c: Slot) = b.getValue(a).let { x -> b.getValue(c).let { y ->
|
||||
x.cx < y.cx - 0.5f || (kotlin.math.abs(x.cx - y.cx) <= 0.5f && x.cy < y.cy) } }
|
||||
assertTrue(panel.name, before(Slot.VIEW, Slot.MENU))
|
||||
assertTrue(panel.name, before(Slot.STEAM, Slot.QAM))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
"appinfo"
|
||||
{
|
||||
"appid" "2054970"
|
||||
"common"
|
||||
{
|
||||
"name" "Dragon's Dogma 2"
|
||||
"type" "Game"
|
||||
"releasestate" "released"
|
||||
"oslist" "windows"
|
||||
"osarch" "64"
|
||||
"osextended" ""
|
||||
"icon" "f3d469e0d7a0e25ddd13db8ebf08f1c24d6478b0"
|
||||
"clienticon" "a8eaa474f143bfdab634907e034ea6fa47086ccb"
|
||||
"clienttga" "0c02d7c5b9790c444597578019b02937234b90d2"
|
||||
"languages"
|
||||
{
|
||||
"english" "1"
|
||||
"german" "1"
|
||||
"french" "1"
|
||||
"italian" "1"
|
||||
"koreana" "1"
|
||||
"spanish" "1"
|
||||
"schinese" "1"
|
||||
"tchinese" "1"
|
||||
"russian" "1"
|
||||
"japanese" "1"
|
||||
"portuguese" "1"
|
||||
"polish" "1"
|
||||
"arabic" "1"
|
||||
"brazilian" "1"
|
||||
"latam" "1"
|
||||
}
|
||||
"eulas"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"id" "2054970_eula_0"
|
||||
"name" "Dragon's Dogma 2 EULA"
|
||||
"url" "https://store.steampowered.com//eula/2054970_eula_0"
|
||||
"version" "3"
|
||||
}
|
||||
}
|
||||
"content_descriptors"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "5"
|
||||
}
|
||||
"steam_deck_compatibility"
|
||||
{
|
||||
"category" "1"
|
||||
"steamos_compatibility" "2"
|
||||
"steam_machine_compatibility" "3"
|
||||
"test_timestamp" "1784764800"
|
||||
"tested_build_id" "2054970"
|
||||
"tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "2"
|
||||
"token" "#SteamDeckVerified_TestResult_UnsupportedGraphicsPerformance"
|
||||
}
|
||||
}
|
||||
"steam_machine_tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_DefaultControllerConfigFullyFunctional"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_ControllerGlyphsMatchDevice"
|
||||
}
|
||||
"2"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_DefaultConfigurationIsPerformant"
|
||||
}
|
||||
}
|
||||
"steamos_tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "3"
|
||||
"token" "#SteamOS_TestResult_GameStartupFunctional"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"display" "1"
|
||||
"token" "#SteamOS_TestResult_ExternalControllersNotSupportedPrimaryPlayer"
|
||||
}
|
||||
}
|
||||
"configuration"
|
||||
{
|
||||
"supported_input" "other"
|
||||
"requires_manual_keyboard_invoke" "0"
|
||||
"requires_non_controller_launcher_nav" "0"
|
||||
"primary_player_is_controller_slot_0" "1"
|
||||
"non_deck_display_glyphs" "0"
|
||||
"small_text" "1"
|
||||
"requires_internet_for_setup" "0"
|
||||
"requires_internet_for_singleplayer" "0"
|
||||
"recommended_runtime" "proton-11.0-beta"
|
||||
"requires_h264" "0"
|
||||
"requires_voice_files" "0"
|
||||
"gamescope_frame_limiter_not_supported" "0"
|
||||
"hdr_support" "0"
|
||||
"requires_proton_soundfonts" "0"
|
||||
}
|
||||
}
|
||||
"metacritic_name" "Dragon's Dogma 2"
|
||||
"controllertagwizard" "1"
|
||||
"controller_support" "partial"
|
||||
"small_capsule"
|
||||
{
|
||||
"english" "capsule_231x87.jpg"
|
||||
}
|
||||
"header_image"
|
||||
{
|
||||
"english" "header.jpg"
|
||||
}
|
||||
"library_assets"
|
||||
{
|
||||
"library_capsule" "en"
|
||||
"library_hero" "en"
|
||||
"library_logo" "en"
|
||||
"library_header" "en"
|
||||
"logo_position"
|
||||
{
|
||||
"pinned_position" "BottomLeft"
|
||||
"width_pct" "23.186781828785556"
|
||||
"height_pct" "41.66700658287976"
|
||||
}
|
||||
}
|
||||
"library_assets_full"
|
||||
{
|
||||
"library_capsule"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_600x900.jpg"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "library_600x900_2x.jpg"
|
||||
}
|
||||
}
|
||||
"library_hero"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_hero.jpg"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "library_hero_2x.jpg"
|
||||
}
|
||||
}
|
||||
"library_logo"
|
||||
{
|
||||
"logo_position"
|
||||
{
|
||||
"pinned_position" "BottomLeft"
|
||||
"width_pct" "23.186781828785556"
|
||||
"height_pct" "41.66700658287976"
|
||||
}
|
||||
"image"
|
||||
{
|
||||
"english" "logo.png"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "logo_2x.png"
|
||||
}
|
||||
}
|
||||
"library_header"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_header.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
"store_asset_mtime" "1753923539"
|
||||
"associations"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"type" "developer"
|
||||
"name" "CAPCOM Co., Ltd."
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"type" "publisher"
|
||||
"name" "CAPCOM Co., Ltd."
|
||||
}
|
||||
"2"
|
||||
{
|
||||
"type" "franchise"
|
||||
"name" "Dragon's Dogma"
|
||||
}
|
||||
}
|
||||
"primary_genre" "3"
|
||||
"genres"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "3"
|
||||
}
|
||||
"category"
|
||||
{
|
||||
"category_2" "1"
|
||||
"category_18" "1"
|
||||
"category_55" "1"
|
||||
"category_57" "1"
|
||||
"category_60" "1"
|
||||
"category_22" "1"
|
||||
"category_29" "1"
|
||||
"category_23" "1"
|
||||
"category_61" "1"
|
||||
"category_45" "1"
|
||||
"category_46" "1"
|
||||
"category_62" "1"
|
||||
}
|
||||
"supported_languages"
|
||||
{
|
||||
"english"
|
||||
{
|
||||
"supported" "true"
|
||||
"full_audio" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"french"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"italian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"german"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"spanish"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"arabic"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"latam"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"brazilian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"russian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"schinese"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"tchinese"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"japanese"
|
||||
{
|
||||
"supported" "true"
|
||||
"full_audio" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"koreana"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"polish"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
}
|
||||
"steam_release_date" "1711065600"
|
||||
"metacritic_score" "88"
|
||||
"metacritic_fullurl" "https://www.metacritic.com/game/pc/dragons-dogma-2?ftag=MCD-06-10aaa1f"
|
||||
"community_visible_stats" "1"
|
||||
"community_hub_visible" "1"
|
||||
"gameid" "2054970"
|
||||
"content_descriptors_including_dlc"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "5"
|
||||
}
|
||||
"store_tags"
|
||||
{
|
||||
"0" "1695"
|
||||
"1" "122"
|
||||
"2" "4231"
|
||||
"3" "1684"
|
||||
"4" "4182"
|
||||
"5" "19"
|
||||
"6" "21"
|
||||
"7" "4747"
|
||||
"8" "4046"
|
||||
"9" "4057"
|
||||
"10" "4106"
|
||||
"11" "4172"
|
||||
"12" "4608"
|
||||
"13" "1697"
|
||||
"14" "3968"
|
||||
"15" "3854"
|
||||
"16" "3859"
|
||||
"17" "7926"
|
||||
"18" "4191"
|
||||
"19" "6730"
|
||||
}
|
||||
"review_score" "5"
|
||||
"review_percentage" "63"
|
||||
}
|
||||
"extended"
|
||||
{
|
||||
"additional_dependencies"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"src_os" "windows"
|
||||
"dest_os" "linux"
|
||||
"h264" "1"
|
||||
}
|
||||
}
|
||||
"developer" "CAPCOM Co., Ltd."
|
||||
"publisher" "CAPCOM Co., Ltd."
|
||||
"homepage" "https://www.dragonsdogma.com/2da/"
|
||||
"aliases" "ドグマ,ドグマ2,DD2,龙信2,ドラゴンズドグマ,龙之信条"
|
||||
"listofdlc" "2581000,2593170,2593180,2593190,2593200,2593210,2593220,2593230,2593240,2593250,2593260,2593270,2593280,2593290,2694820,2694830,2694840,2694850,2757060,2757080,2757090,2757120,2757130,2757140,2757170"
|
||||
"dlcavailableonstore" "1"
|
||||
}
|
||||
"config"
|
||||
{
|
||||
"installdir" "Dragons Dogma 2"
|
||||
"launch"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"executable" "DD2.exe"
|
||||
}
|
||||
}
|
||||
"steamcontrollertemplateindex" "2"
|
||||
"steamdecktouchscreen" "1"
|
||||
"steamconfigurator3rdpartynative" "65534"
|
||||
}
|
||||
"depots"
|
||||
{
|
||||
"baselanguages" "english,german,french,italian,koreana,spanish,schinese,tchinese,russian,japanese,polish,arabic,brazilian,latam"
|
||||
"228988"
|
||||
{
|
||||
"config"
|
||||
{
|
||||
"oslist" "windows"
|
||||
}
|
||||
"depotfromapp" "228980"
|
||||
"sharedinstall" "1"
|
||||
}
|
||||
"2054971"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3438547366935427234"
|
||||
"size" "154689650"
|
||||
"download" "49702016"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054972"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2497482018950166086"
|
||||
"size" "154689650"
|
||||
"download" "49702016"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054973"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "5096395230065303989"
|
||||
"size" "69392135623"
|
||||
"download" "68155907184"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054974"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4433477873043462508"
|
||||
"size" "69392135912"
|
||||
"download" "68163333808"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2581000"
|
||||
{
|
||||
"dlcappid" "2581000"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6150746279967323915"
|
||||
"size" "732"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593170"
|
||||
{
|
||||
"dlcappid" "2593170"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "810106481153607467"
|
||||
"size" "731"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593180"
|
||||
{
|
||||
"dlcappid" "2593180"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2908546599585986601"
|
||||
"size" "14463"
|
||||
"download" "14528"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593190"
|
||||
{
|
||||
"dlcappid" "2593190"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1427647465435055606"
|
||||
"size" "197908084"
|
||||
"download" "180975120"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593200"
|
||||
{
|
||||
"dlcappid" "2593200"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6641524981969165665"
|
||||
"size" "5583"
|
||||
"download" "5472"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593210"
|
||||
{
|
||||
"dlcappid" "2593210"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "971487897150453015"
|
||||
"size" "8506"
|
||||
"download" "8416"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593220"
|
||||
{
|
||||
"dlcappid" "2593220"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7751533332300224932"
|
||||
"size" "6072"
|
||||
"download" "5984"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593230"
|
||||
{
|
||||
"dlcappid" "2593230"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3205009877271857381"
|
||||
"size" "4433"
|
||||
"download" "4320"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593240"
|
||||
{
|
||||
"dlcappid" "2593240"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2644417301132228564"
|
||||
"size" "5471"
|
||||
"download" "5360"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593250"
|
||||
{
|
||||
"dlcappid" "2593250"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4059800356804376977"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593260"
|
||||
{
|
||||
"dlcappid" "2593260"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1542297718911676562"
|
||||
"size" "5004"
|
||||
"download" "4944"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593270"
|
||||
{
|
||||
"dlcappid" "2593270"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2999068646104164918"
|
||||
"size" "5098"
|
||||
"download" "5024"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593280"
|
||||
{
|
||||
"dlcappid" "2593280"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3305444330840079072"
|
||||
"size" "693"
|
||||
"download" "752"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593290"
|
||||
{
|
||||
"dlcappid" "2593290"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3353726914325255074"
|
||||
"size" "26906257761"
|
||||
"download" "26506078656"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694820"
|
||||
{
|
||||
"dlcappid" "2694820"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8323545382908063536"
|
||||
"size" "4994"
|
||||
"download" "4928"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694830"
|
||||
{
|
||||
"dlcappid" "2694830"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8243393096252728659"
|
||||
"size" "4980"
|
||||
"download" "4912"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694840"
|
||||
{
|
||||
"dlcappid" "2694840"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7407203756785514772"
|
||||
"size" "4980"
|
||||
"download" "4912"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694850"
|
||||
{
|
||||
"dlcappid" "2694850"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8989014742962253789"
|
||||
"size" "4992"
|
||||
"download" "4928"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757060"
|
||||
{
|
||||
"dlcappid" "2757060"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4478133242487678390"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757080"
|
||||
{
|
||||
"dlcappid" "2757080"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4246061750770449019"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757090"
|
||||
{
|
||||
"dlcappid" "2757090"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6279084752133001593"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757100"
|
||||
{
|
||||
"dlcappid" "2757100"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2163371650537928331"
|
||||
"size" "728"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757110"
|
||||
{
|
||||
"dlcappid" "2757110"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6048538284791812257"
|
||||
"size" "728"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757120"
|
||||
{
|
||||
"dlcappid" "2757120"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4411956497046953058"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757130"
|
||||
{
|
||||
"dlcappid" "2757130"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1901727257188878695"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757140"
|
||||
{
|
||||
"dlcappid" "2757140"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3192862444394827561"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757150"
|
||||
{
|
||||
"dlcappid" "2757150"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6674463851485663659"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757160"
|
||||
{
|
||||
"dlcappid" "2757160"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "9188163296361277604"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757170"
|
||||
{
|
||||
"dlcappid" "2757170"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3408012598466658697"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757180"
|
||||
{
|
||||
"dlcappid" "2757180"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7470516453980211807"
|
||||
"size" "731"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757190"
|
||||
{
|
||||
"dlcappid" "2757190"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "488116235793758299"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757200"
|
||||
{
|
||||
"dlcappid" "2757200"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3018062872582304551"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757210"
|
||||
{
|
||||
"dlcappid" "2757210"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6248888390153964942"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"5184170"
|
||||
{
|
||||
"dlcappid" "5184170"
|
||||
"manifests"
|
||||
{
|
||||
}
|
||||
}
|
||||
"branches"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"buildid" "25290080"
|
||||
"timeupdated" "1791503987"
|
||||
"timebuildupdated" "1789345188"
|
||||
}
|
||||
}
|
||||
"privatebranches" "1"
|
||||
}
|
||||
"ufs"
|
||||
{
|
||||
"quota" "2000000000"
|
||||
"maxnumfiles" "1000"
|
||||
}
|
||||
}
|
||||
| ||||