Compare commits

..
Author SHA1 Message Date
xXJsonDeruloXx 3f4405931a Merge main into add-compose-previews 2026-10-10 13:09:59 -04:00
xXJsonDeruloXx a1078cb12f Add Compose previews across DroidDeck UI 2026-10-07 10:28:15 -04:00
155 changed files with 1967 additions and 10375 deletions

No files matched your search

+24 -13
View File
@@ -44,19 +44,6 @@ 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"
@@ -501,6 +488,30 @@ jobs:
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
- name: Render Compose previews
run: ./gradlew testDebugUnitTest --tests '*PreviewRenderTest' --console=plain -PndkVersion="$NDK_VERSION"
- name: Check the native dependency closure
run: |
set -euo pipefail
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
WORK=$(mktemp -d)
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
cd "$WORK/lib/arm64-v8a"
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
fail=0
for so in *.so; do
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
[ -f "$need" ] && continue
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
fail=1
done
done
exit $fail
- name: Sign with the testkey (v1 + v2 + v3)
run: |
set -euo pipefail
+4
View File
@@ -286,6 +286,10 @@ dependencies {
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
debugImplementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
testImplementation 'androidx.compose.ui:ui-test-junit4'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.activity:activity-compose:1.8.2'
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
@@ -0,0 +1,87 @@
package com.droiddeck.launcher.files
import androidx.compose.runtime.Composable
import com.droiddeck.launcher.ui.DroidDeckPreviews
import com.droiddeck.launcher.ui.PreviewCase
import com.droiddeck.launcher.ui.PreviewHost
import java.io.File
/** File metadata only: no files are created and no real directory is enumerated. */
private class SampleFile(path: String, private val directory: Boolean = false, private val bytes: Long = 0) : File(path) {
override fun isDirectory() = directory
override fun isFile() = !directory
override fun exists() = true
override fun canRead() = true
override fun canWrite() = false
override fun length() = bytes
override fun getUsableSpace() = 32_000_000_000L
override fun getFreeSpace() = 32_000_000_000L
override fun lastModified() = 1_791_331_200_000L
override fun getCanonicalPath() = absolutePath
override fun getCanonicalFile() = this
override fun listFiles(): Array<File> = emptyArray()
}
private val directory = SampleFile("/storage/emulated/0", directory = true)
private val entries = listOf(
SampleFile("/storage/emulated/0/Games", directory = true),
SampleFile("/storage/emulated/0/ROMs", directory = true),
SampleFile("/storage/emulated/0/Downloads", directory = true),
SampleFile("/storage/emulated/0/Portal 2.exe", bytes = 4_200_000),
SampleFile("/storage/emulated/0/session.log", bytes = 32_000),
SampleFile("/storage/emulated/0/save.zip", bytes = 12_000_000),
)
@DroidDeckPreviews
@Composable
internal fun FileGridPreview() = PreviewHost {
FileManagerScreen(preview = FileManagerPreviewState(directory, entries))
}
@DroidDeckPreviews
@Composable
internal fun FileListPreview() = PreviewHost {
FileManagerScreen(preview = FileManagerPreviewState(directory, entries, grid = false))
}
@DroidDeckPreviews
@Composable
internal fun FileEmptyPreview() = PreviewHost {
FileManagerScreen(preview = FileManagerPreviewState(directory, emptyList()))
}
@DroidDeckPreviews
@Composable
internal fun FilePickerPreview() = PreviewHost {
FileManagerScreen(pickMode = true, pickExtensions = listOf("exe"), pickerTitle = "Choose executable", onPick = {},
preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory || it.extension == "exe" }))
}
@DroidDeckPreviews
@Composable
internal fun FolderPickerPreview() = PreviewHost {
FileManagerScreen(pickMode = true, pickDirMode = true, onPick = {},
preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory }))
}
@DroidDeckPreviews
@Composable
internal fun FilePropertiesPreview() = PreviewHost {
FilePropertiesDialog(entries.last(), {}, {})
}
@DroidDeckPreviews
@Composable
internal fun FavoritesPreview() = PreviewHost {
FileManagerScreen(preview = FileManagerPreviewState(directory, entries, favorites = true))
}
internal val filePreviewCases = listOf(
PreviewCase("Favorites") { FavoritesPreview() },
PreviewCase("FileGrid") { FileGridPreview() },
PreviewCase("FileList") { FileListPreview() },
PreviewCase("FileEmpty") { FileEmptyPreview() },
PreviewCase("FilePicker") { FilePickerPreview() },
PreviewCase("FolderPicker") { FolderPickerPreview() },
PreviewCase("FileProperties") { FilePropertiesPreview() },
)
@@ -0,0 +1,93 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.runtime.UserApps
@DroidDeckPreviews
@Composable
internal fun WirelessAdbPreview() {
PreviewHost {
WirelessAdbFixPage({}, true, {}, { _, _, _, done -> done("Preview only") }, { _, done -> done(null) }, { _, _, _, done -> done("Preview only") })
}
}
@DroidDeckPreviews
@Composable
internal fun ProcessLimitPreview() {
PreviewHost {
PhantomProcessGatePage(PhantomProcessStatus.ENABLED, {}, {}, {}, {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun AddAppPreview() {
PreviewHost {
AddAppDialog(true, {}) { _, _ -> }
}
}
@DroidDeckPreviews
@Composable
internal fun EditAppPreview() {
PreviewHost {
EditAppDialog(UserApps.App("sample", UserApps.Kind.APPIMAGE, "Example app", null, "AppImage", "/preview/example.AppImage", emptyList()), {}) { _, _, _ -> }
}
}
@DroidDeckPreviews
@Composable
internal fun ConfirmationPreview() {
PreviewHost {
ConfirmDialog("Remove runtime?", "Remove the installed Linux runtime.", "Remove", {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun CustomResolutionPreview() {
PreviewHost {
CustomResolutionDialog(1280 to 720, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun RomsFolderPreview() {
PreviewHost {
RomsDialog("/preview/ROMs", {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun GameEnvironmentPreview() {
PreviewHost {
GameEnvironmentEditor(false, com.droiddeck.launcher.core.GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps", "MANGOHUD" to "1"))) {}
}
}
@DroidDeckPreviews
@Composable
internal fun WindowsComponentsPreview() {
PreviewHost {
WinComponentsDialog("100", "Portal 2", null, false, onClose = {}, previewComponents = listOf(
com.droiddeck.launcher.session.WinComponents.Component("vcrun2022", "Visual C++ runtime", "Microsoft", "ready", emptyList(), emptyList()),
com.droiddeck.launcher.session.WinComponents.Component("d3dcompiler_47", "DirectX shader compiler", "Microsoft", "ready", emptyList(), emptyList()),
))
}
}
@DroidDeckPreviews
@Composable
internal fun DeveloperDisplayPreview() = PreviewHost {
DeveloperDisplayChoiceDialog(listOf(2 to "Secondary screen"), {}, {}, {})
}
@DroidDeckPreviews
@Composable
internal fun AppDisplayPreview() = PreviewHost {
ChooseAppDisplayDialog(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null),
com.droiddeck.launcher.input.SecondScreenDisplay(2, "Secondary screen"), {}, {}, {})
}
@@ -0,0 +1,129 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@DroidDeckPreviews
@Composable
internal fun LauncherPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions)
}
}
@DroidDeckPreviews
@Composable
internal fun RuntimeMissingPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(ready = false), PreviewData.frontActions)
}
}
@DroidDeckPreviews
@Composable
internal fun RuntimeDownloadingPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(ready = false, busy = true), PreviewData.frontActions)
}
}
@DroidDeckPreviews
@Composable
internal fun LibraryPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "games")
}
}
@DroidDeckPreviews
@Composable
internal fun EmptyLibraryPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(empty = true), PreviewData.frontActions, initialSelection = "games")
}
}
@DroidDeckPreviews
@Composable
internal fun GameDetailsPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "app:100")
}
}
@DroidDeckPreviews
@Composable
internal fun DesktopPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "desktop")
}
}
@DroidDeckPreviews
@Composable
internal fun EmulatorPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "emu:dolphin")
}
}
@DroidDeckPreviews
@Composable
internal fun ArtPreview() {
PreviewHost {
Column(Modifier.fillMaxSize().padding(24.dp)) {
ArtGrid(PreviewData.games.map { Tile(it.name, "Installed", null, it.gameIdString, onClick = {}) })
}
}
}
@DroidDeckPreviews
@Composable
internal fun UpdatesPreview() {
PreviewHost {
UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
}
}
@DroidDeckPreviews
@Composable
internal fun UpdatesDownloadingPreview() {
PreviewHost {
UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog, stage = "Downloading", percent = 42)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
}
}
@DroidDeckPreviews
@Composable
internal fun UpdatesErrorPreview() {
PreviewHost {
UpdatesPage(PreviewData.front(updates = UpdatesState(error = "Could not check for updates")), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
}
}
@Preview(name = "Paper theme", widthDp = 960, heightDp = 540, apiLevel = 34)
@Composable
internal fun AlternateThemePreview() {
PreviewHost(theme = Themes.PAPER) { FrontEndScreen(PreviewData.front(), PreviewData.frontActions) }
}
@DroidDeckPreviews
@Composable
internal fun StorePreview() = PreviewHost {
val apps = listOf(
com.droiddeck.launcher.store.FlathubApi.AppSummary("org.videolan.VLC", "VLC", "Media player", null, "VideoLAN", true, 100_000),
com.droiddeck.launcher.store.FlathubApi.AppSummary("org.mozilla.firefox", "Firefox", "Web browser", null, "Mozilla", true, 100_000),
)
StorePage(PreviewData.front(), PreviewData.frontActions, Modifier.fillMaxSize(), previewSections = mapOf("popular" to apps, "trending" to apps))
}
@DroidDeckPreviews
@Composable
internal fun AndroidAppsPreview() = PreviewHost {
FrontEndScreen(PreviewData.front(home = true), PreviewData.frontActions, initialSelection = "android-apps")
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
internal class PreviewCase(val name: String, val content: @Composable () -> Unit) {
override fun toString() = name
}
internal val screenPreviewCases = listOf(
PreviewCase("AlternateTheme") { AlternateThemePreview() },
PreviewCase("Launcher") { LauncherPreview() },
PreviewCase("RuntimeMissing") { RuntimeMissingPreview() },
PreviewCase("RuntimeDownloading") { RuntimeDownloadingPreview() },
PreviewCase("Library") { LibraryPreview() },
PreviewCase("EmptyLibrary") { EmptyLibraryPreview() },
PreviewCase("GameDetails") { GameDetailsPreview() },
PreviewCase("Desktop") { DesktopPreview() },
PreviewCase("Emulator") { EmulatorPreview() },
PreviewCase("Setup") { SetupPreview() },
PreviewCase("ControllerMapping") { ControllerMappingPreview() },
PreviewCase("GpuDrivers") { GpuDriversPreview() },
PreviewCase("Protons") { ProtonsPreview() },
PreviewCase("ProtonDownload") { ProtonDownloadPreview() },
PreviewCase("ProtonsEmpty") { ProtonsEmptyPreview() },
PreviewCase("Drivers") { DriversPreview() },
PreviewCase("Loading") { LoadingPreview() },
PreviewCase("SessionStarting") { SessionStartingPreview() },
PreviewCase("SessionError") { SessionErrorPreview() },
PreviewCase("Keyboard") { KeyboardPreview() },
PreviewCase("Hud") { HudPreview() },
PreviewCase("WirelessAdb") { WirelessAdbPreview() },
PreviewCase("ProcessLimit") { ProcessLimitPreview() },
PreviewCase("AddApp") { AddAppPreview() },
PreviewCase("EditApp") { EditAppPreview() },
PreviewCase("Confirmation") { ConfirmationPreview() },
PreviewCase("CustomResolution") { CustomResolutionPreview() },
PreviewCase("RomsFolder") { RomsFolderPreview() },
PreviewCase("GameEnvironment") { GameEnvironmentPreview() },
PreviewCase("WindowsComponents") { WindowsComponentsPreview() },
PreviewCase("Controls") { ControlsPreview() },
PreviewCase("Widgets") { WidgetsPreview() },
PreviewCase("Art") { ArtPreview() },
PreviewCase("Cursor") { CursorPreview() },
PreviewCase("SteamDisplay") { SteamDisplayPreview() },
PreviewCase("SteamControls") { SteamControlsPreview() },
PreviewCase("SteamGames") { SteamGamesPreview() },
PreviewCase("SteamAudio") { SteamAudioPreview() },
PreviewCase("SteamSession") { SteamSessionPreview() },
PreviewCase("SteamSteam") { SteamSteamPreview() },
PreviewCase("DesktopSettings") { DesktopSettingsPreview() },
PreviewCase("DrawerController") { DrawerControllerPreview() },
PreviewCase("DrawerSecondScreen") { DrawerSecondScreenPreview() },
PreviewCase("DrawerDisplay") { DrawerDisplayPreview() },
PreviewCase("DrawerEffects") { DrawerEffectsPreview() },
PreviewCase("DrawerGames") { DrawerGamesPreview() },
PreviewCase("DrawerSession") { DrawerSessionPreview() },
PreviewCase("Performance") { PerformancePreview() },
PreviewCase("Components") { ComponentsPreview() },
PreviewCase("ComponentsLoading") { ComponentsLoadingPreview() },
PreviewCase("GpuComponents") { GpuComponentsPreview() },
PreviewCase("Updates") { UpdatesPreview() },
PreviewCase("UpdatesDownloading") { UpdatesDownloadingPreview() },
PreviewCase("UpdatesError") { UpdatesErrorPreview() },
PreviewCase("Store") { StorePreview() },
PreviewCase("DeveloperDisplay") { DeveloperDisplayPreview() },
PreviewCase("AppDisplay") { AppDisplayPreview() },
PreviewCase("AndroidApps") { AndroidAppsPreview() },
PreviewCase("SetupController") { SetupControllerPreview() },
PreviewCase("SetupSession") { SetupSessionPreview() },
PreviewCase("SetupLauncher") { SetupLauncherPreview() },
)
@@ -0,0 +1,143 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.update.AppUpdates
import java.io.File
/** Deterministic fixtures shared by Studio previews and the render smoke tests. */
internal object PreviewData {
val games = listOf("Portal 2", "Celeste", "Stardew Valley", "Hollow Knight", "A Short Hike", "Dead Cells")
.mapIndexed { i, name -> Library.SteamGame(100 + i, name, null, "/preview/steam") }
val controller = ControllerPrefs.Settings(0xFF1A9FFF.toInt(), 80, 100, true, true, true, true, true, true, false, emptyMap())
fun front(empty: Boolean = false, busy: Boolean = false, ready: Boolean = true, home: Boolean = false, updates: UpdatesState = UpdatesState()) = FrontEndState(
installed = if (ready) "2026.10.07" else null, ready = ready, available = "2026.10.07",
busy = busy, stage = if (busy) "Downloading runtime" else "", percent = if (busy) 42 else -1,
desktopInstalled = ready, offlineAccount = "Player", offline = false, frameGenLabel = "Off", romsDir = "/preview/ROMs", logsEnabled = true,
steamGames = if (empty) emptyList() else games,
emulators = listOf(Library.Emulator("dolphin", "Dolphin", "GameCube / Wii", "dolphin-emu", true, listOf(
Library.Rom("Wind Waker", File("/preview/ROMs/Wind Waker.iso"), "/root/ROMs/Wind Waker.iso", "dolphin"),
))), running = null, controller = controller, animationsEnabled = false, storeEnabled = true,
buildLabel = "Design preview", updates = updates, isHomeApp = home, homeScreenEnabled = home,
androidApps = if (home) listOf(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null),
com.droiddeck.launcher.HomeApp.LaunchableApp("Settings", "preview.settings", "preview.settings.Main", null)) else emptyList(),
)
val frontActions = FrontEndActions(
onPlay = {},
onPlayDesktopUi = {},
onSteamGame = {},
onDesktop = {},
onEmulator = {},
onRom = {},
onResume = {},
onSteamSettings = {},
onDesktopSettings = {},
onInstallPackage = {},
onRemovePackage = {},
onRuntime = {},
onFrameGenPick = {},
onImportLossless = {},
onProtons = {},
onComponents = {},
onPerformance = {},
onRoms = {},
onFiles = {},
onLogs = {},
onOffline = {},
onWirelessAdbPair = { _, _, _, done -> done("Preview only") },
onWirelessAdbApply = { _, _, _, done -> done("Preview only") },
onSetPhantomProcessLimit = { _, done -> done("Preview only") },
controller = ControllerActions(
onOsc = {},
onTint = {},
onOpacity = {},
onSize = {},
onStickClick = {},
onAdaptiveSticks = {},
onRumble = {},
onSteamButton = {},
onQamButton = {},
onKeyboardButton = {},
onEditLayout = {},
onResetLayout = {},
onMapping = {},
onResetAll = {},
),
)
fun mode(steam: Boolean = true) = ModeSettings(
mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP, resolution = "1280x720",
hdr = false, hdrReason = "HDR is unavailable on this display", touchMode = SessionPrefs.TOUCH_AUTO,
suspendPolicy = SessionPrefs.SUSPEND_AUTO, oscMode = SessionPrefs.OSC_AUTO,
directAudio = true, mic = false, renderer = "vulkan", stretch16x9 = false,
pipSupported = true, syncBackend = "auto", steamChannel = "stable", steamController = "deck",
addedGamesDirs = listOf("/preview/Games"), deckyInstalled = "3.0.0", deckyEnabled = true,
)
val modeActions = ModeSettingsActions(
onResolution = {},
onHdr = {},
onTouch = {},
onSuspendPolicy = {},
onOsc = {},
onDirectAudio = {},
onMic = {},
onRenderer = {},
onDismiss = {},
)
val components = ComponentsManager.Snapshot(
protons = listOf(ComponentsManager.ProtonView(
ComponentsManager.Proton("ge-proton", "GE-Proton", File("/preview/proton"), "/opt/proton", "10-20", false),
mapOf("fex" to ComponentsManager.Component("FEX-2609", "FEX-2609", "Original", null, null),
"dxvk" to ComponentsManager.Component("2.7", "DXVK 2.7", "Original", null, null),
"vkd3d" to ComponentsManager.Component("2.14", "VKD3D-Proton 2.14", "Original", null, null)),
emptyList(), false,
)),
packages = listOf(ComponentsManager.Package("dxvk-2.7.tzst", "dxvk", "2.7", "DXVK 2.7", 24_000_000)),
)
val catalog = listOf(ComponentsManager.CatalogItem("dxvk-2.7.tzst", "dxvk", "2.7", "", 24_000_000, ""))
val drivers = listOf(DriverRow("auto", "Auto", "Recommended for this device", false),
DriverRow("turnip", "Turnip 25.3", "Android and Linux", true, DriverRow.BUNDLE))
val gpu = GpuDriversState(gpuName = "Adreno 740", gpuFamily = "Adreno 7xx", soc = "Snapdragon 8 Gen 2",
supportText = "Turnip supported", autoStatus = "Turnip 25.3", activeBundle = "Turnip 25.3",
pairs = listOf(PairRow("turnip", "Turnip", "25.3", "Android and Linux", true, true, true, true, true)),
androidRows = drivers, linuxRows = drivers)
val drawerActions = DrawerActions(
steam = true, isHomeApp = false, androidApps = emptyList(), hudOn = true,
frameGen = FrameGen.Mode.OFF, lossless = Lossless.State.NONE, oscMode = SessionPrefs.OSC_AUTO,
onScreenButtonsVisible = true, suspendPolicy = SessionPrefs.SUSPEND_AUTO, backActionsInverted = false,
touchMode = SessionPrefs.TOUCH_AUTO, touchAuto = "Touchpad", fexPreset = "balanced",
secondScreenMode = SecondScreenMode.NONE, secondScreenDisplays = listOf(SecondScreenDisplay(2, "Secondary screen")),
selectedSecondScreenDisplay = 2, controller = controller, components = components, pipSupported = true,
onHud = {},
onFrameGenPick = {},
onImportLossless = {},
onKeyboard = {},
onHardwareKeyboard = {},
onSteamMenu = {},
onQam = {},
onOsc = {},
onSuspendPolicy = {},
onBackActionsInverted = {},
onTouch = {},
onFexPreset = {},
onSecondScreenMode = {},
onSecondScreenDisplay = {},
onLaunchAndroidApp = { _, _ -> },
onBackground = {},
onShareLogs = {},
onStop = {},
onClose = {},
)
const val TIME = 1_791_331_200_000L
val installed = AppUpdates.Installed("preview-old", 0, "0.3.0", 10, true, TIME - 86_400_000, true)
val release = AppUpdates.Release("v0.3.1", "Improve handheld layouts", "Settings and library improvements.", "preview-new", 0, "0.3.1", 11, TIME,
"", AppUpdates.Apk("DroidDeck.apk", "", 200_000_000, "", "com.droiddeck.launcher", 11, com.droiddeck.launcher.BuildConfig.RELEASE_SIGNER))
val updatesCatalog = AppUpdates.Catalog(release, release, emptyList(), TIME,
listOf(AppUpdates.PreviewChange("preview-new", "Improve handheld layouts", "Settings and library improvements.", TIME)))
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.ui
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.app.ActivityOptionsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
@Preview(name = "Handheld", widthDp = 960, heightDp = 540, apiLevel = 34)
@Preview(name = "Compact", widthDp = 480, heightDp = 360, apiLevel = 34)
@Preview(name = "Portrait", widthDp = 400, heightDp = 700, apiLevel = 34)
@Preview(name = "Large text", widthDp = 960, heightDp = 540, fontScale = 1.3f, apiLevel = 34)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
annotation class DroidDeckPreviews
/** Every preview uses the production theme and inert Android navigation/picker owners. */
@Composable
internal fun PreviewHost(theme: String = Themes.GRAPHITE, content: @Composable () -> Unit) {
val owner = remember { PreviewOwner() }
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalOnBackPressedDispatcherOwner provides owner,
LocalActivityResultRegistryOwner provides owner,
) {
DroidDeckTheme(theme = theme) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { content() }
}
}
}
private class PreviewOwner : OnBackPressedDispatcherOwner, ActivityResultRegistryOwner, LifecycleOwner {
override val lifecycle: Lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.RESUMED }
override val onBackPressedDispatcher = OnBackPressedDispatcher()
override val activityResultRegistry = object : ActivityResultRegistry() {
override fun <I, O> onLaunch(requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat?) = Unit
}
}
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
@DroidDeckPreviews
@Composable
internal fun GpuDriversPreview() {
PreviewHost {
GpuDriversPanel(PreviewData.gpu, GpuDriversActions()) {}
}
}
@DroidDeckPreviews
@Composable
internal fun ProtonsPreview() {
PreviewHost {
ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", "10-20", false), ProtonRow("proton", "Proton Experimental", null, false)), null, null, -1, true, false, {}, {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun ProtonDownloadPreview() {
PreviewHost {
ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", null, false)), "ge-proton", "Downloading", 42, true, false, {}, {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun ProtonsEmptyPreview() {
PreviewHost {
ProtonPage(emptyList(), null, null, -1, false, false, {}, {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun DriversPreview() {
PreviewHost {
DriverPage("GPU drivers", "Choose a driver", PreviewData.drivers, "auto",
listOf(DownloadRow("turnip", "Turnip 25.3", "Adreno 7xx", 42)), "Downloading", false, "Import driver", true,
{}, {}, {}, {}, {}, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun ComponentsPreview() {
PreviewHost {
ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", false, null, emptyMap(),
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
}
}
@DroidDeckPreviews
@Composable
internal fun ComponentsLoadingPreview() {
PreviewHost {
ComponentsPage(null, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", true, null, emptyMap(),
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
}
}
@DroidDeckPreviews
@Composable
internal fun GpuComponentsPreview() {
PreviewHost {
ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", GPU_TAB, false, null, emptyMap(),
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
}
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import com.droiddeck.launcher.session.LoadingState
@DroidDeckPreviews
@Composable
internal fun LoadingPreview() {
PreviewHost {
LoadingOverlay("Downloading runtime", 42, "00:32", "", false, LoadingState.Topic.RUNTIME, true, onCancel = {})
}
}
@DroidDeckPreviews
@Composable
internal fun SessionStartingPreview() {
PreviewHost {
LoadingOverlay("Starting Steam", -1, "00:08", "", false, LoadingState.Topic.STEAM_STARTING, true, onCancel = {})
}
}
@DroidDeckPreviews
@Composable
internal fun SessionErrorPreview() {
PreviewHost {
LoadingOverlay("Steam stopped", -1, "00:08", "Try starting Steam again.", true, LoadingState.Topic.STEAM, true, endedDetail = "Exit code 1", onRetry = {}, onShareLogs = {}, onClose = {})
}
}
@DroidDeckPreviews
@Composable
internal fun KeyboardPreview() {
PreviewHost {
PcKeyboard({ _, _ -> }, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun HudPreview() {
PreviewHost {
HudText("60 FPS • GPU 72% • CPU 38%")
}
}
@DroidDeckPreviews
@Composable
internal fun CursorPreview() {
PreviewHost {
CursorOverlay(Offset(220f, 180f), true, 2f)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerControllerPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.CONTROLLER, false, {}, PreviewData.drawerActions)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerSecondScreenPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.SECOND_SCREEN, false, {}, PreviewData.drawerActions)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerDisplayPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.DISPLAY, false, {}, PreviewData.drawerActions)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerEffectsPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.EFFECTS, false, {}, PreviewData.drawerActions)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerGamesPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.GAMES, false, {}, PreviewData.drawerActions)
}
}
@DroidDeckPreviews
@Composable
internal fun DrawerSessionPreview() {
PreviewHost {
SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, PreviewData.drawerActions)
}
}
@@ -0,0 +1,165 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
@DroidDeckPreviews
@Composable
internal fun SetupPreview() {
PreviewHost {
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "setup")
}
}
@DroidDeckPreviews
@Composable
internal fun ControllerMappingPreview() {
PreviewHost {
ControllerMappingPage(mapOf("a" to "b"), { _, _ -> }, {}, {})
}
}
@DroidDeckPreviews
@Composable
internal fun ControlsPreview() {
PreviewHost {
val host = rememberMenuHost()
SettingsPage(host, "Controller", {}, scroll = rememberScrollState()) {
ControllerRows(host, "auto", PreviewData.controller, PreviewData.frontActions.controller!!)
}
}
}
@DroidDeckPreviews
@Composable
internal fun WidgetsPreview() {
PreviewHost {
val host = rememberMenuHost()
SettingsPage(host, "Settings controls", {}, scroll = rememberScrollState()) {
SettingsGroup("Display") {
ChoiceRow(host, "resolution", "Resolution", "Virtual screen size", listOf("720p" to "1280 × 720", "1080p" to "1920 × 1080"), "720p") {}
ToggleRow(host, "hdr", "HDR", "High dynamic range", true) {}
SliderRow("Sharpness", null, 75, 0..100) {}
ActionRow("GPU driver", null, "Manage", {})
}
}
}
}
@DroidDeckPreviews
@Composable
internal fun SteamDisplayPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 0)
}
}
@DroidDeckPreviews
@Composable
internal fun SteamControlsPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 1)
}
}
@DroidDeckPreviews
@Composable
internal fun SteamGamesPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 2)
}
}
@DroidDeckPreviews
@Composable
internal fun SteamAudioPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 3)
}
}
@DroidDeckPreviews
@Composable
internal fun SteamSessionPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 4)
}
}
@DroidDeckPreviews
@Composable
internal fun SteamSteamPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 5)
}
}
@DroidDeckPreviews
@Composable
internal fun DesktopSettingsPreview() {
PreviewHost {
ModeSettingsPage(PreviewData.mode(steam = false), PreviewData.modeActions)
}
}
@DroidDeckPreviews
@Composable
internal fun PerformancePreview() {
PreviewHost {
PerformancePage(
cores = List(8) { CoreRow(it, if (it < 4) "Efficiency" else "Performance") },
clientOverride = false,
clientCores = setOf(0, 1, 2, 3),
gameCores = (0..7).toSet(),
tuSysmem = false,
zinkLazy = false,
glThread = false,
noGlError = false,
noXalia = false,
fastSync = false,
syncFallback = false,
fsyncFirst = false,
gamescopeRealtime = false,
gpuClockPin = false,
prootNoSeccomp = false,
prootFastPath = false,
guestHostname = "droiddeck",
phantomWarning = null,
onClientOverride = {},
onTuSysmem = {},
onZinkLazy = {},
onGlThread = {},
onNoGlError = {},
onNoXalia = {},
onFastSync = {},
onSyncFallback = {},
onFsyncFirst = {},
onGamescopeRealtime = {},
onGpuClockPin = {},
onProotNoSeccomp = {},
onProotFastPath = {},
onGuestHostname = {},
onClientCore = { _, _ -> },
onGameCore = { _, _ -> },
onDismiss = {},
)
}
}
@DroidDeckPreviews
@Composable
internal fun SetupControllerPreview() = PreviewHost {
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 1)
}
@DroidDeckPreviews
@Composable
internal fun SetupSessionPreview() = PreviewHost {
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 2)
}
@DroidDeckPreviews
@Composable
internal fun SetupLauncherPreview() = PreviewHost {
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 3)
}
-3
View File
@@ -30,9 +30,6 @@
<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
+1 -3
View File
@@ -136,7 +136,6 @@ 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;
}
@@ -152,7 +151,6 @@ 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;
}
@@ -162,7 +160,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, cwd, cmd_cwd);
(*env)->ReleaseStringUTFChars(env, cmd, cmd_cwd);
if (argv) {
for (char** tmp = argv; *tmp; ++tmp) free(*tmp);
@@ -22,6 +22,8 @@ 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,7 +14,6 @@ class HomeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = com.droiddeck.launcher.session.SessionPrefs.orientation(this)
routeHome()
}
@@ -10,7 +10,6 @@ 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
@@ -18,7 +17,6 @@ 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
@@ -121,6 +119,7 @@ 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)
@@ -163,7 +162,6 @@ 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)
@@ -343,6 +341,7 @@ 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)
@@ -364,8 +363,6 @@ 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)
@@ -535,8 +532,6 @@ 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)
@@ -594,7 +589,6 @@ class MainActivity : ComponentActivity() {
updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
@@ -616,15 +610,7 @@ 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 {
// 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) }
}
},
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else 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) },
@@ -749,11 +735,6 @@ 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
@@ -843,7 +824,6 @@ 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 },
@@ -913,7 +893,6 @@ 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)
@@ -955,15 +934,6 @@ 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. */
@@ -990,8 +960,6 @@ 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.
@@ -1286,8 +1254,6 @@ 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,
@@ -1299,6 +1265,8 @@ 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,
@@ -1343,7 +1311,6 @@ 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)
@@ -1376,7 +1343,13 @@ 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 ->
@@ -1447,8 +1420,6 @@ 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 }
@@ -1494,11 +1465,9 @@ 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
@@ -1509,6 +1478,8 @@ 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)
@@ -1567,6 +1538,7 @@ 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)
@@ -1607,39 +1579,13 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// 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.
// 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,7 +19,6 @@ 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
@@ -52,7 +51,6 @@ 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
@@ -111,10 +109,6 @@ 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
@@ -125,7 +119,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"); applyUnfoldedControls() } }
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
/**
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
@@ -138,8 +132,6 @@ 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"
@@ -150,11 +142,8 @@ 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() {
// 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()
if (resumed && !pipUi && SessionState.deckPad) 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
@@ -252,8 +241,6 @@ 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)
@@ -312,7 +299,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.claimConnected()
updateOnScreenControls()
updatePointerCapture()
}
@@ -329,7 +315,6 @@ 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
@@ -385,22 +370,13 @@ 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
@@ -592,14 +568,6 @@ 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)
@@ -652,8 +620,6 @@ 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() },
@@ -834,7 +800,6 @@ 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)
@@ -850,7 +815,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
}
if (nextTouchMode == SessionPrefs.TOUCH_OFF || !usingTouchpad()) cursorVisible = false
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
}
touchMode = nextTouchMode
routedTouchMode = effectiveTouchMode()
@@ -860,7 +825,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
padType = SessionPrefs.steamController(this)
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
@@ -1161,7 +1125,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -1389,7 +1352,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
val fromController = PadBridge.isControllerKeyEvent(event)
val fromController = PadBridge.isControllerKey(event)
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
@@ -1445,7 +1408,8 @@ 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.
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
val fromPad = fromController
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromPad) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
@@ -1483,7 +1447,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (drawerOpen && PadBridge.isControllerMotionEvent(event)) {
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
if (!drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
@@ -1494,7 +1458,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) 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)
@@ -1718,8 +1682,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
},
sendWheel = { steps -> WaylandCompositor.nativeSendSceneInput(4, steps, 0) },
onSteamMenu = ::tapSteamButton,
onQam = ::tapQam,
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()
},
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
)
secondScreenPresentation = presentation
@@ -1736,64 +1709,6 @@ 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)
@@ -2021,9 +1936,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
// 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) {
if (pipUi) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
@@ -2058,8 +1971,7 @@ 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() && !SteamControllerBle.connected &&
SessionState.mode != SessionService.MODE_DESKTOP
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (steam != null) {
@@ -2144,7 +2056,6 @@ 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?) {
@@ -2201,7 +2112,6 @@ 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()
@@ -2308,7 +2218,6 @@ 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,7 +14,6 @@ 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,30 +182,26 @@ 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) {
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);
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(); }
}
/** Runs pactl against this daemon; its stdout, or null when it failed or timed out. */
@@ -227,14 +223,8 @@ public class PulseAudioComponent extends SessionPart {
builder.environment().put("PULSE_SERVER", "unix:" + socket().getAbsolutePath());
process = builder.start();
String output;
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());
try (InputStream in = process.getInputStream()) {
output = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly();
@@ -244,7 +234,7 @@ public class PulseAudioComponent extends SessionPart {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} catch (Throwable t) {
} catch (Exception e) {
return null;
} finally {
if (process != null && process.isAlive()) process.destroyForcibly();
@@ -152,6 +152,7 @@ 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,8 +31,7 @@ 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.
// Older NativeAOT runtimes require bare hex digits. CoreCLR accepts this form too.
"DOTNET_GCRegionRange" to "800000000",
"DOTNET_GCRegionRange" to "0x800000000",
// .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,12 +93,9 @@ 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<Learned> = emptyList()
private var accounts: List<Regex> = emptyList()
/**
* Learns every Steam account on the device from the client's loginusers.vdf - its AccountName
@@ -106,13 +103,12 @@ object LogRedactor {
* Names under three characters are skipped: blanking every "a" would ruin a log.
*/
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Learned>()
val found = ArrayList<Regex>()
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 ->
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)
found += Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])")
}
}
} catch (e: Exception) {
@@ -129,7 +125,7 @@ object LogRedactor {
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@Volatile
private var own: List<Learned> = emptyList()
private var own: List<Regex> = emptyList()
/**
* Learns the device's addresses from the link file the app writes for the session
@@ -137,7 +133,7 @@ object LogRedactor {
* private, link-local and loopback addresses identify nobody and are left out.
*/
fun learnOwnAddresses(linkFile: java.io.File) {
val found = ArrayList<Learned>()
val found = ArrayList<Regex>()
try {
if (linkFile.isFile) linkFile.forEachLine { line ->
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
@@ -146,12 +142,9 @@ 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 += 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" },
)
found += Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?")
}
"public IPv4" -> found += Learned(Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])"), addr)
"public IPv4" -> found += Regex("(?<![0-9.])" + Regex.escape(addr) + "(?![0-9.])")
}
}
} catch (e: Exception) {
@@ -270,87 +263,41 @@ 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 {
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) {
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 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>" })
}
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>"))
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>")
// Mask, not delete: the last four digits let a reader correlate lines to one account.
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 ->
out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" }
out = STEAMID3.replace(out) { 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])}" })
}
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
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
} catch (t: Throwable) {
// A log line is never worth crashing a session over, but an unscrubbed one must not
// reach the file either.
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.files
import androidx.compose.ui.platform.LocalInspectionMode
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
@@ -122,7 +123,8 @@ class RailState internal constructor(
fun rememberRailState(screenKey: String): RailState {
val context = LocalContext.current
// The same file the File Manager keeps its own browse settings in (no androidx.preference here).
val prefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val inspecting = LocalInspectionMode.current
val prefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val choseKey = "rail_${screenKey}_userChose"
@@ -130,9 +132,9 @@ fun rememberRailState(screenKey: String): RailState {
// Keep the landscape choice in saveable state regardless of orientation so portrait→landscape
// restores it. In portrait we simply report collapsed+locked and ignore it.
var userChose by rememberSaveable(screenKey) { mutableStateOf(prefs.getBoolean(choseKey, false)) }
var userChose by rememberSaveable(screenKey) { mutableStateOf((prefs?.getBoolean(choseKey, false) ?: false)) }
var landscapeCollapsed by rememberSaveable(screenKey) {
mutableStateOf(if (prefs.getBoolean(choseKey, false)) prefs.getBoolean(collapsedKey, false) else false)
mutableStateOf(if ((prefs?.getBoolean(choseKey, false) ?: false)) (prefs?.getBoolean(collapsedKey, false) ?: false) else false)
}
if (!isLandscape) {
@@ -144,10 +146,7 @@ fun rememberRailState(screenKey: String): RailState {
onToggle = {
landscapeCollapsed = !landscapeCollapsed
userChose = true
prefs.edit()
.putBoolean(choseKey, true)
.putBoolean(collapsedKey, landscapeCollapsed)
.apply()
prefs?.edit()?.putBoolean(choseKey, true)?.putBoolean(collapsedKey, landscapeCollapsed)?.apply()
},
)
}
@@ -22,7 +22,6 @@ 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)
}
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -187,6 +188,7 @@ internal fun FileItemRow(
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val context = LocalContext.current
val inspecting = LocalInspectionMode.current
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
@@ -195,7 +197,7 @@ internal fun FileItemRow(
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
@@ -364,13 +366,15 @@ internal fun FavoritesList(
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
previewFavorites: List<File> = emptyList(),
) {
val context = LocalContext.current
val inspecting = LocalInspectionMode.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
if (inspecting) previewFavorites else FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
if (inspecting) currentDir in previewFavorites else FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
@@ -545,10 +549,11 @@ internal fun FileGridTile(
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val inspecting = LocalInspectionMode.current
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
@@ -0,0 +1,6 @@
package com.droiddeck.launcher.files
import java.io.File
/** Metadata for design previews. [FileManagerScreen] ignores it outside inspection mode. */
class FileManagerPreviewState(val directory: File, val entries: List<File>, val grid: Boolean = true, val favorites: Boolean = false)
@@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
@@ -109,8 +110,11 @@ fun FileManagerScreen(
initialDir: File? = null,
pickerTitle: String? = null,
onPick: ((File) -> Unit)? = null,
preview: FileManagerPreviewState? = null,
) {
val context = LocalContext.current
val inspecting = LocalInspectionMode.current
val sample = preview.takeIf { inspecting }
val scope = rememberCoroutineScope()
// Only matching files are shown in pick mode (directories are always shown). Empty = all files.
@@ -121,14 +125,15 @@ fun FileManagerScreen(
return lowerExts.any { name.endsWith(".$it") }
}
val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val pickPrefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val browsePrefs = pickPrefs
val rootDir = remember {
// Both modes: honour an explicit caller-supplied start dir (e.g. Log Manager's game-log
// folder), else open at the INTERNAL STORAGE ROOT. Selection screens (drive-folder pick,
// local component pick, imports) previously defaulted to Download which - combined with the
// currentRoot floor below - trapped users in Download with no way up (reported bug).
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
if (inspecting) sample?.directory ?: File("/storage/emulated/0")
else initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
}
var currentDir by remember { mutableStateOf(rootDir) }
@@ -156,7 +161,7 @@ fun FileManagerScreen(
}
mutableStateOf(vol)
}
var entries by remember { mutableStateOf(listOf<File>()) }
var entries by remember { mutableStateOf(sample?.entries.orEmpty()) }
var selectedEntry by remember { mutableStateOf<File?>(null) }
var showMenuFor by remember { mutableStateOf<File?>(null) }
// Clipboard holds a LIST so one paste can carry a whole selection. Cut/copy semantics are a
@@ -177,9 +182,9 @@ fun FileManagerScreen(
// Browse controls. Persisted so the list doesn't reset its order every time you open a folder.
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
var showHidden by remember { mutableStateOf(browsePrefs.getBoolean("fmShowHidden", true)) }
var sortBy by remember { mutableStateOf(browsePrefs?.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs?.getBoolean("fmSortDesc", false) ?: false) }
var showHidden by remember { mutableStateOf(browsePrefs?.getBoolean("fmShowHidden", true) ?: true) }
var showSortMenu by remember { mutableStateOf(false) }
// View mode: list of cards (default) or a thumbnail grid. Density applies to the list only -
// a grid tile has no second line to compact.
@@ -187,9 +192,9 @@ fun FileManagerScreen(
// choice persists across rotation). Grid is the default - most useful in landscape, and in
// portrait GridCells.Adaptive naturally renders fewer columns (~2). Do NOT force portrait to list:
// that broke the toggle on-device (tapping it did nothing in portrait).
var gridView by remember { mutableStateOf(browsePrefs.getBoolean("fmGridView", true)) }
var gridView by remember { mutableStateOf(sample?.grid ?: (browsePrefs?.getBoolean("fmGridView", true) ?: true)) }
val showGrid = gridView
var compactRows by remember { mutableStateOf(browsePrefs.getBoolean("fmCompactRows", false)) }
var compactRows by remember { mutableStateOf(browsePrefs?.getBoolean("fmCompactRows", false) ?: false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<File?>(null) }
// Properties sheet (basic info + Read-only / Hidden toggles) target; null when closed.
@@ -203,7 +208,7 @@ fun FileManagerScreen(
// Favorites view: when on, a dedicated bookmarks list replaces the file list.
// favTick is bumped on any add/remove/toggle so the favorites view + per-row star recompute.
var showFavorites by remember { mutableStateOf(false) }
var showFavorites by remember { mutableStateOf(sample?.favorites == true) }
var favTick by remember { mutableIntStateOf(0) }
// resetScroll: jump to the top of the list (true for navigation; false for in-place reloads
@@ -211,7 +216,8 @@ fun FileManagerScreen(
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
currentDir = dir
// Remember the browsed directory so the next pick resumes here.
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
if (inspecting) return
if (pickMode) pickPrefs?.edit()?.putString("lastFilePickerDir", dir.absolutePath)?.apply()
scope.launch {
val list = withContext(Dispatchers.IO) {
dir.listFiles()?.toList()
@@ -281,6 +287,7 @@ fun FileManagerScreen(
}
fun performDelete(file: File) {
if (inspecting) return
scope.launch {
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_deleting)
@@ -302,6 +309,7 @@ fun FileManagerScreen(
}
fun performPaste() {
if (inspecting) return
val sources = clipboardFiles
if (sources.isEmpty()) return
val dstDir = currentDir
@@ -387,6 +395,7 @@ fun FileManagerScreen(
}
fun performRename(file: File, newName: String) {
if (inspecting) return
val target = File(file.parentFile, newName)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
@@ -403,6 +412,7 @@ fun FileManagerScreen(
}
fun createFolder(parent: File, name: String) {
if (inspecting) return
val target = File(parent, name)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
@@ -430,9 +440,9 @@ fun FileManagerScreen(
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val drives = remember(storageTick) { StorageRoots.list(context) }
val drives = remember(storageTick) { if (inspecting) emptyList() else StorageRoots.list(context) }
val linuxRoot = remember(storageTick) {
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
if (inspecting) null else com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
}
// ── Dialogs ──
@@ -537,6 +547,7 @@ fun FileManagerScreen(
pendingBulkDelete = emptyList()
selectionMode = false
selectedPaths = emptySet()
if (inspecting) return@TextButton
operationJob = scope.launch {
isOperationRunning = true
var failed = 0
@@ -754,7 +765,7 @@ fun FileManagerScreen(
}
IconButton(onClick = {
gridView = !gridView
browsePrefs.edit().putBoolean("fmGridView", gridView).apply()
browsePrefs?.edit()?.putBoolean("fmGridView", gridView)?.apply()
}) {
Icon(
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
@@ -780,8 +791,8 @@ fun FileManagerScreen(
// Tapping the active field flips direction; a different
// field switches to it ascending.
if (sortBy == key) sortDesc = !sortDesc else { sortBy = key; sortDesc = false }
browsePrefs.edit().putString("fmSortBy", sortBy)
.putBoolean("fmSortDesc", sortDesc).apply()
browsePrefs?.edit()?.putString("fmSortBy", sortBy)
?.putBoolean("fmSortDesc", sortDesc)?.apply()
showSortMenu = false
loadDirectory(currentDir, resetScroll = false)
},
@@ -792,7 +803,7 @@ fun FileManagerScreen(
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
onClick = {
compactRows = !compactRows
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
browsePrefs?.edit()?.putBoolean("fmCompactRows", compactRows)?.apply()
showSortMenu = false
},
)
@@ -800,7 +811,7 @@ fun FileManagerScreen(
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
onClick = {
showHidden = !showHidden
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
browsePrefs?.edit()?.putBoolean("fmShowHidden", showHidden)?.apply()
showSortMenu = false
loadDirectory(currentDir, resetScroll = false)
},
@@ -1016,15 +1027,15 @@ fun FileManagerScreen(
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
}
val quickItems = buildList {
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
File("/storage/emulated/0/Download").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
(if (inspecting) "" else SessionPrefs.romsDir(context)).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
// The app's own logs folder: private, which this file manager (the app itself) can read.
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
val favItems = remember(favTick) { if (inspecting) sample?.entries.orEmpty().filter { it.isDirectory } else FavoritesStore.list(context).map(::File).filter { it.exists() } }
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
val locationSections = buildList {
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
@@ -1043,8 +1054,9 @@ fun FileManagerScreen(
FavoritesList(
currentDir = currentDir,
favTick = favTick,
previewFavorites = sample?.entries.orEmpty().filter { it.isDirectory },
onPinCurrent = {
FavoritesStore.add(context, currentDir.absolutePath)
if (!inspecting) FavoritesStore.add(context, currentDir.absolutePath)
favTick++
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
},
@@ -1053,7 +1065,7 @@ fun FileManagerScreen(
openDrive(dir)
},
onUnpin = { dir ->
FavoritesStore.remove(context, dir.absolutePath)
if (!inspecting) FavoritesStore.remove(context, dir.absolutePath)
favTick++
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
},
@@ -1080,7 +1092,7 @@ fun FileManagerScreen(
}
items(shownEntries, key = { it.absolutePath }) { file ->
val isFav = remember(file.absolutePath, favTick) {
FavoritesStore.isFavorite(context, file.absolutePath)
(!inspecting && FavoritesStore.isFavorite(context, file.absolutePath))
}
FileGridTile(
file = file,
@@ -1108,7 +1120,7 @@ fun FileManagerScreen(
if (file.isDirectory) loadDirectory(file)
else if (pickMode) {
if (matchesPickExt(file)) {
pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply()
pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply()
onPick?.invoke(file)
}
}
@@ -1127,7 +1139,7 @@ fun FileManagerScreen(
onCut = { clipboardFiles = listOf(file); isCutOperation = true; showMenuFor = null },
onDelete = { selectedEntry = file; showMenuFor = null },
onToggleFavorite = {
val nowFav = FavoritesStore.toggle(context, file.absolutePath)
val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath)
favTick++
showMenuFor = null
Toast.makeText(
@@ -1159,7 +1171,7 @@ fun FileManagerScreen(
val shown = shownEntries
items(shown, key = { it.absolutePath }) { file ->
val isFav = remember(file.absolutePath, favTick) {
FavoritesStore.isFavorite(context, file.absolutePath)
(!inspecting && FavoritesStore.isFavorite(context, file.absolutePath))
}
FileItemRow(
file = file,
@@ -1189,7 +1201,7 @@ fun FileManagerScreen(
if (file.isDirectory) loadDirectory(file)
else if (pickMode) {
if (matchesPickExt(file)) {
pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply()
pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply()
onPick?.invoke(file)
}
}
@@ -1208,7 +1220,7 @@ fun FileManagerScreen(
onRename = { renameTarget = file; showMenuFor = null },
isFavorite = isFav,
onToggleFavorite = {
val nowFav = FavoritesStore.toggle(context, file.absolutePath)
val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath)
favTick++
showMenuFor = null
Toast.makeText(
@@ -35,7 +35,6 @@ 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)
@@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -58,11 +59,13 @@ internal fun FilePropertiesDialog(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val inspecting = LocalInspectionMode.current
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(if (inspecting) FileAttrState(false, false, false) else null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
if (inspecting) return@LaunchedEffect
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
@@ -101,7 +104,11 @@ internal fun FilePropertiesDialog(
description = stringResource(R.string.fm_read_only_hint),
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
onToggle = toggle@{ want ->
if (inspecting) {
attrs = attrs?.copy(readOnly = want)
return@toggle
}
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
@@ -125,7 +132,11 @@ internal fun FilePropertiesDialog(
else stringResource(R.string.fm_hidden_hint),
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
onToggle = toggle@{ want ->
if (inspecting) {
attrs = attrs?.copy(hidden = want)
return@toggle
}
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
@@ -137,7 +137,8 @@ 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 = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
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 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)
@@ -221,11 +222,10 @@ object AddedGameArt {
val cache = cacheDir(context, game)
if (File(cache, "p.jpg").isFile) continue
cache.mkdirs()
val match = steamMatch(context, game) { name ->
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
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 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,13 +237,16 @@ object AddedGameArt {
}
/**
* 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.
* 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.
*/
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
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") } }
}
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
@@ -43,17 +43,22 @@ object AddedGames {
fun folderName(): String = folder.name
}
/** A Games folder an earlier build had, and where the session sees it. */
/** One Games folder and where the session sees it. */
class Root(val host: File, val guest: String)
/**
* 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.
* 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).
*/
fun roots(context: Context): List<Root> {
GamesFolderMigration.run(context)
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
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")
}
}
private val SKIP = Regex(
@@ -93,26 +98,18 @@ object AddedGames {
return null
}
/** The .exe files a game folder offers, best first ([GameExePicker]); declared Store exes lead. */
/** The .exe files a game folder offers, best first ([GameExePicker]); also what its exe box lists, in that order. */
fun candidates(folder: File): List<File> = rank(folder).exes
/** [GameExePicker]'s ranking, with a Store-declared executable taking precedence when present. */
/** [GameExePicker]'s ranking of [folder], without the names this list never offers. */
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 }
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
return GameExePicker.rank(folder) { !SKIP.matches(it.name) }.also {
if (ranked.size > 256) ranked.clear()
ranked[key] = now to it
}
}
/** A folder's ranking for a minute, so one add (rank, then the game built from it) walks it once. */
@@ -163,29 +160,10 @@ 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>()
GamesFolderMigration.run(context)
val folders = listOfNotNull(
val folders = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -259,19 +237,17 @@ 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 (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.
* 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.
*/
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 }
// Games folders of earlier builds are entries now, found above.
val dirs = listOfNotNull(
val dirs = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
GameStorage.effective(context)?.let { File(it.path) },
)
@@ -345,13 +321,12 @@ object AddedGames {
/**
* The game folders already listed, without building any game: the first-level folders of the
* 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.
* 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.
*/
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
GamesFolderMigration.run(context)
val dirs = listOfNotNull(
val dirs = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
@@ -1,419 +0,0 @@
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,9 +134,6 @@ 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
@@ -160,19 +157,6 @@ 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) } }
AddedGames.roots(context).forEach { check(it.host.listFiles() != null) { context.getString(R.string.game_sync_err_added_games) } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).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()
}
@@ -1,66 +0,0 @@
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")
}
}
}
@@ -1,95 +0,0 @@
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()
}
}
@@ -1,202 +0,0 @@
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
}
}
@@ -1,77 +0,0 @@
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
}
}
@@ -1,334 +0,0 @@
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() }
}
}
}
@@ -1,98 +0,0 @@
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
}
}
}
@@ -1,160 +0,0 @@
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),
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
) + SessionPrefs.addedGamesDirs(context).map(::File)
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
@@ -47,7 +47,6 @@ 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,7 +46,6 @@ 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>,
@@ -66,7 +65,6 @@ 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) },
@@ -123,11 +121,6 @@ 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,52 +94,18 @@ 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 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.
// 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.
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
// 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 int RING_FILE_SIZE = RING_SIZE + 64;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -249,24 +215,11 @@ 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
@@ -523,23 +476,6 @@ 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
@@ -566,121 +502,6 @@ 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,27 +29,13 @@ 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. 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.
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
*/
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
@@ -79,7 +65,6 @@ 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;
@@ -103,17 +88,6 @@ 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).
@@ -139,11 +113,6 @@ 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) {
@@ -185,58 +154,8 @@ 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();
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));
return (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|| (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
}
/** A Bluetooth keyboard can share a device with a joystick. Classify the key, not just
@@ -290,9 +209,10 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isControllerKeyEvent(event)) return false;
if (!isControllerKey(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;
@@ -302,11 +222,6 @@ 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;
@@ -324,7 +239,6 @@ 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();
@@ -334,7 +248,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 (!isControllerMotionEvent(event)) return false;
if (!isFromController(event.getDevice())) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
PadState pad = stateFor(slot);
@@ -382,23 +296,6 @@ 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;
@@ -424,19 +321,6 @@ 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++) {
@@ -497,55 +381,23 @@ public final class PadBridge {
publish();
}
/**
* 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.
*/
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
private int slotFor(InputDevice device) {
int id = device.getId();
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[0] == id) return 0;
for (int slot = 1; 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);
}
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++) {
for (int slot = 0; slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
owners[slot] = device.getId();
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
owners[slot] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
return slot;
}
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;
return 0;
}
private PadState stateFor(int slot) {
@@ -20,10 +20,9 @@ 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, 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.
* 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.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
@@ -33,11 +32,6 @@ 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
@@ -51,13 +45,8 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
thread = worker
val handler = Handler(worker.looper)
active = true
handler.post(object : Runnable {
override fun run() {
if (!active) return
follow(handler, gyroscope, accelerometer)
handler.postDelayed(this, POLL_MS)
}
})
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
@@ -65,71 +54,31 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
val worker = thread ?: return
thread = null
active = false
// On the worker, after any poll or reading already queued there, so nothing registers again
// or overwrites the still controller.
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.
Handler(worker.looper).post {
sensors?.unregisterListener(this)
reading = false
idlePolls = 0
still()
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
SteamTouchDevice.current?.setMotion(accel, gyro)
}
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: 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 (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 z = values[2]
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 }
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
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
@@ -150,9 +99,6 @@ 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())
}
@@ -1,362 +0,0 @@
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,20 +256,6 @@ 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/Droid-Deck/DroidDeck-Components/main/desktop.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/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/Droid-Deck/DroidDeck-Components/main/steam-seed.json"
const val STEAM_SEED_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/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/Droid-Deck/DroidDeck-Components/main/linuxfs.json";
"https://raw.githubusercontent.com/The412Banner/winlator-contents/main/linuxfs.json";
private static final String VERSION_FILE = ".version";
@@ -32,8 +32,6 @@ 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
@@ -74,8 +72,8 @@ object UserApps {
class FlatpakBundle(val file: File) : Source()
}
/** [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())
/** [icon]: a picked file's path or a suggested icon's URL. */
class Request(val source: Source, val name: String?, val icon: String?)
/** 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)
@@ -123,8 +121,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, request.meta, onProgress)
is Source.AppImage -> AppImageManager.import(context, s.file, name, icon, request.meta) { onProgress(it, -1) }
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.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)
@@ -199,14 +197,6 @@ 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
@@ -219,9 +209,7 @@ 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?, meta: Map<String, String>, onProgress: (String, Int) -> Unit,
): String? {
private fun addScript(context: Context, script: File, name: String?, icon: File?, 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() }
@@ -244,7 +232,6 @@ 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,19 +68,12 @@ 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 }
@@ -90,10 +83,6 @@ 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,7 +4,6 @@ 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
@@ -34,8 +33,6 @@ 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
@@ -98,21 +95,13 @@ 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(); 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)
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
try {
target.play(strong, weak, duration)
sent[slot] = Sent(strong, weak, now, now + duration)
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
@@ -129,11 +118,8 @@ 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
@@ -174,8 +160,6 @@ 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,26 +52,6 @@ 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,10 +94,6 @@ 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",
@@ -133,8 +129,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", "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" }
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" }
val optional = (arrayOf(
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
@@ -1,7 +1,6 @@
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
@@ -19,9 +18,6 @@ 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"
@@ -39,22 +35,6 @@ 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)
@@ -134,9 +114,9 @@ object SessionPrefs {
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session with Deck mode's performance overlay (mangoapp). */
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && mangoapp(context)
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && 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)
@@ -452,21 +432,27 @@ 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), [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).
* 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).
*/
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() }
/** [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
/** 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()
}
/**
@@ -600,6 +586,24 @@ 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)
@@ -608,9 +612,8 @@ object SessionPrefs {
}
/**
* 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.
* 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.
*/
fun addedGamesDirs(context: Context): List<String> {
val p = prefs(context)
@@ -624,26 +627,6 @@ 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.
@@ -900,26 +883,6 @@ 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,12 +37,9 @@ 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
@@ -410,8 +407,6 @@ 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
@@ -625,7 +620,8 @@ class SessionService : Service() {
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread and no GL error checks.
// 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).
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
@@ -638,7 +634,9 @@ 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"))
}
@@ -682,9 +680,7 @@ 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.
// 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 wantsDirectAudio = SessionState.mode == MODE_STEAM && 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,
@@ -742,18 +738,13 @@ 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()
// 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()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) 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()
@@ -763,11 +754,6 @@ 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)
@@ -1228,13 +1214,6 @@ 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 }
@@ -1264,48 +1243,6 @@ 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) {
@@ -1782,7 +1719,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) "switchable, starting as " + SessionPrefs.steamController(this) else "an Xbox 360 controller")
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
}
companion object {
@@ -44,36 +44,6 @@ 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
@@ -99,65 +69,11 @@ 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. */
/** 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> {
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
@@ -197,8 +113,7 @@ object SteamDeckPad {
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
padNodes.clear()
padNodes[MINOR] = HIDRAW
link(File(hidrawClass, NODE), 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.
@@ -238,88 +153,6 @@ 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/Droid-Deck/DroidDeck-Components/main/components.json"
const val CATALOG_URL = "https://raw.githubusercontent.com/The412Banner/winlator-contents/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,21 +77,6 @@ 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 ""
@@ -133,13 +118,8 @@ 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; 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")
/** Catalog entries nothing here can use: the IE8 security update patches a browser no prefix has. */
private val POINTLESS = setOf("ie8_kb2936068")
private fun protonProvides(name: String): Boolean = name in PROTON_PROVIDES || name in POINTLESS || name.startsWith("mono-")
@@ -148,24 +128,8 @@ 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>? = 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>? {
fun fetch(): List<Component>? {
val body = Downloader.downloadString(CATALOG_URL) ?: return null
return try {
val arr = JSONObject(body).getJSONArray("components")
(0 until arr.length()).mapNotNull { i ->
@@ -186,16 +150,6 @@ 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
@@ -547,7 +501,7 @@ object WinComponents {
val inside = archive.relativeTo(root).path
var problem: String? = null
var ok = false
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
val status = GuestCommand.run(context, 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)) }
@@ -586,7 +540,7 @@ object WinComponents {
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
var result: JSONObject? = null
var problem: String? = null
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
val status = GuestCommand.run(context, 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)) }
@@ -663,101 +617,36 @@ object WinComponents {
fun uninstall(context: Context, id: String) {
require(validId(id))
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
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()
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
}
/** Each game's picks, by the appid its prefix is named after. */
@Synchronized
fun selections(context: Context): Map<String, Selection> = try {
fun read(context: Context): Map<String, List<String>> = try {
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
val games = json.optJSONObject("games") ?: JSONObject()
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)
games.keys().asSequence().associateWith { app ->
val ids = games.getJSONArray(app)
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
}
} catch (_: java.io.FileNotFoundException) {
emptyMap()
}
/** 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> = read(context)[appKey].orEmpty()
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 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)
}
/** 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 })
val all = read(context).toMutableMap()
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
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()
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()
write(File(context.filesDir, SELECTION), text)
publish(context, text)
}
@@ -52,9 +52,6 @@ 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,11 +156,7 @@ 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,
// 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()),
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
)
StoreInstalls.complete(app, folder, sidecar)
return folder.path
@@ -107,11 +107,6 @@ 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<>();
}
@@ -120,12 +115,7 @@ 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 {
@@ -188,7 +178,7 @@ public final class EpicDownloadManager {
if (pending.isEmpty()) {
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
}
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
@@ -275,7 +265,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).withPrereq(manifest);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
}
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
}
@@ -303,7 +293,7 @@ public final class EpicDownloadManager {
}
deleteDir(chunkCacheDir);
cb.onProgress("Complete", 100);
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
return new Result(manifest.launchExe, manifest.buildVersion, planned);
} catch (InstallException e) {
throw e;
} catch (Exception e) {
@@ -480,13 +470,6 @@ 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());
}
@@ -554,9 +537,6 @@ 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");
@@ -773,22 +753,6 @@ 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,9 +111,6 @@ 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,10 +79,6 @@ 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; }
}
@@ -154,12 +150,10 @@ 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, ...): logged, and kept in the
// sidecar, where Windows components take them as this game's list (WinCompSources).
// 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.
JSONArray deps = manifest.optJSONArray("dependencies");
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);
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
@@ -268,8 +262,6 @@ 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() : "");
@@ -1039,48 +1031,6 @@ 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";
@@ -90,6 +90,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.files.InAppFilePicker
@@ -302,7 +303,8 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St
/** Open from the first frame; [onDismiss] once the closing animation has run. */
@Composable
internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
val shown = remember { MutableTransitionState(false).apply { targetState = true } }
val inspecting = LocalInspectionMode.current
val shown = remember { MutableTransitionState(inspecting).apply { targetState = true } }
LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() }
return shown
}
@@ -507,7 +509,9 @@ private fun IconActions(onChoose: () -> Unit, clear: Pair<Int, () -> Unit>?) {
/** Icons found in [repo], looked up off the main thread once the name has settled; [selected] is ringed. */
@Composable
private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> Unit) {
val inspecting = LocalInspectionMode.current
val found by produceState<List<String>?>(null, repo) {
if (inspecting) { value = emptyList(); return@produceState }
value = null
delay(500)
value = withContext(Dispatchers.IO) { runCatching { UserApps.githubIcons(repo) }.getOrDefault(emptyList()) }
@@ -570,8 +574,10 @@ private fun FileRow(path: String?, onChoose: () -> Unit) {
/** Whether the script's folder will be linked or copied in, worked out off the main thread. */
@Composable
private fun FolderNote(scriptPath: String) {
val inspecting = LocalInspectionMode.current
var plan by remember(scriptPath) { mutableStateOf<UserApps.ScriptPlan?>(null) }
LaunchedEffect(scriptPath) {
if (inspecting) return@LaunchedEffect
plan = withContext(Dispatchers.IO) { runCatching { UserApps.planScript(File(scriptPath)) }.getOrNull() }
}
val p = plan
@@ -28,7 +28,6 @@ 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,
@@ -71,7 +70,6 @@ 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,16 +15,10 @@ 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
/**
@@ -32,8 +26,7 @@ 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. 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.
* row beneath. 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.
*/
@@ -43,29 +36,18 @@ 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) { 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 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 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(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) }
}
select(selected)
}
override fun onDetachedFromWindow() {
@@ -83,7 +65,6 @@ 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
@@ -107,7 +88,7 @@ class DeckControlsPanel(
}
private fun select(tab: Tab) {
if (pad != null) selectedWithPad = tab else selected = tab
selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
@@ -115,15 +96,12 @@ 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),
)
@@ -173,79 +151,6 @@ 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
@@ -357,7 +262,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, private val captioned: Boolean = true) : Surface(context) {
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
@@ -378,7 +283,7 @@ class DeckControlsPanel(
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
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)
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)
@@ -428,271 +333,9 @@ 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
@@ -705,6 +348,5 @@ 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)
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import android.graphics.Bitmap
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
@@ -69,7 +70,8 @@ internal class Tile(
@Composable
internal fun Poster(art: File?, name: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val t = remember { Animatable(0f) }
val inspecting = LocalInspectionMode.current
val t = remember { Animatable(if (inspecting) 1f else 0f) }
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
Box(
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
@@ -91,6 +91,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.HomeApp
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.UserApps
@@ -264,7 +265,8 @@ private fun Content(
val installed = s.emulators.filter { it.installed }
val available = s.emulators.filter { !it.installed }
val ctx = LocalContext.current
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
val inspecting = LocalInspectionMode.current
LaunchedEffect(Unit) { if (!inspecting) UserAppsState.refresh(ctx) }
val apps = UserAppsState.items
var adding by rememberSaveable { mutableStateOf(false) }
Rise(0) {
@@ -322,18 +322,8 @@ 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) {
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
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
}
}
}
@@ -452,14 +442,6 @@ 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) {
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.ui
import java.io.File
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.runtime.staticCompositionLocalOf
@@ -33,16 +34,13 @@ 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.systemBars
import androidx.compose.foundation.layout.systemBarsPadding
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
@@ -129,7 +127,6 @@ 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. */
@@ -205,7 +202,6 @@ 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 = {},
@@ -264,7 +260,8 @@ internal fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h +
@Composable
internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val state = remember { MutableTransitionState(false) }.apply { targetState = true }
val inspecting = LocalInspectionMode.current
val state = remember { MutableTransitionState(inspecting) }.apply { targetState = true }
AnimatedVisibility(
visibleState = state, modifier = modifier,
enter = fadeIn(Motion.tw(450, i * 60)) + slideInVertically(Motion.tw(450, i * 60)) { it / 3 },
@@ -275,6 +272,7 @@ internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable ()
@Composable
private fun Modifier.staggerIn(i: Int): Modifier {
if (LocalInspectionMode.current) return this
val t = remember { Animatable(0f) }
LaunchedEffect(Unit) { t.animateTo(1f, Motion.tw(360, i * 30)) }
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 10.dp.toPx() }
@@ -383,15 +381,16 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
}
@Composable
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null, initialSelection: String = if (s.shortcutPicker) "games" else "steam") {
val frontFocus = remember { FrontFocus() }
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus) }
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus, initialSelection) }
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus, initialSelection: String) {
val inspecting = LocalInspectionMode.current
var selected by rememberSaveable { mutableStateOf(initialSelection) }
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
LaunchedEffect(s.navigate) {
val target = s.navigate ?: return@LaunchedEffect
@@ -440,7 +439,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
LaunchedEffect(s.steamGames.isEmpty()) {
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
}
remember { Motion.refresh(ctx); true }
remember { if (!inspecting) Motion.refresh(ctx); true }
val railSelection = when {
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
@@ -473,7 +472,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
var anyFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize().background(colors.background)
.windowInsetsPadding(if (s.launcherFullscreen) WindowInsets.displayCutout else WindowInsets.systemBars)
.then(if (s.launcherFullscreen) Modifier else Modifier.systemBarsPadding())
.onFocusChanged { anyFocused = it.hasFocus },
) {
// Start controllers on the current page's main action, else on the rail.
@@ -76,6 +76,7 @@ import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.session.SessionPrefs
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.R
@@ -130,6 +131,7 @@ internal fun SetupPanel(
a: FrontEndActions,
onOpenDeveloperOptions: () -> Unit,
onRequestWirelessAdb: (Boolean) -> Unit,
initialTab: Int = 0,
) {
val host = rememberMenuHost()
val ctx = LocalContext.current
@@ -158,9 +160,10 @@ internal fun SetupPanel(
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
val inspecting = LocalInspectionMode.current
val gpu = remember { if (inspecting) com.droiddeck.launcher.gpu.GpuInfo("Adreno 740", 740, com.droiddeck.launcher.gpu.GpuInfo.Family.A7XX, "Snapdragon 8 Gen 2", false) else com.droiddeck.launcher.gpu.GpuInfo.detect() }
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
val gpuName = remember { DeviceSupport.gpuName(ctx) }
val gpuName = remember { if (inspecting) gpu.name else DeviceSupport.gpuName(ctx) }
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
val signedIn = s.offlineAccount != null
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
@@ -169,7 +172,7 @@ internal fun SetupPanel(
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
// and credits are on the Updates page.
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
var tab by rememberSaveable { mutableStateOf(0) }
var tab by rememberSaveable { mutableStateOf(initialTab.coerceIn(0, tabs.lastIndex)) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
@@ -351,11 +354,6 @@ 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),
@@ -510,7 +508,6 @@ 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)) {
@@ -38,6 +38,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.GameEnvironmentOptions
@@ -81,12 +82,13 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
}
@Composable
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
internal fun GameEnvironmentEditor(byPad: Boolean, initialConfig: GameEnvironment.Config = GameEnvironment.Config(), onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
val inspecting = LocalInspectionMode.current
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var config by remember { mutableStateOf<GameEnvironment.Config?>(if (inspecting) initialConfig else null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
var scope by remember { mutableStateOf("") }
var otherId by remember { mutableStateOf<String?>(null) }
@@ -94,12 +96,13 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
val preset = SessionPrefs.fexPreset(context)
val forceSsbs = SessionPrefs.forceSsbs(context)
val preset = if (inspecting) com.droiddeck.launcher.core.FexPreset.all.first().id else SessionPrefs.fexPreset(context)
val forceSsbs = !inspecting && SessionPrefs.forceSsbs(context)
val firstFocus = remember { FocusRequester() }
var editByPad by remember { mutableStateOf(false) }
val dialogInput = LocalInputModeManager.current
LaunchedEffect(Unit) {
if (inspecting) return@LaunchedEffect
runCatching {
withContext(Dispatchers.IO) {
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
@@ -108,6 +111,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
.onFailure { error = true }
}
fun save(next: GameEnvironment.Config) {
if (inspecting) { config = next; return }
busy = true
coroutine.launch {
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
@@ -1,116 +0,0 @@
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,13 +86,15 @@ 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: the QAM's performance overlay (mangoapp) is started. */
/** Steam, Deck mode: 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. */
@@ -137,10 +139,11 @@ 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 = {},
@@ -162,12 +165,12 @@ private enum class ModeSettingsTab(val label: Int) {
}
@Composable
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions, initialTab: Int = 0) {
val steam = s.mode == SessionService.MODE_STEAM
val host = rememberMenuHost()
var confirmDeckyRemoval by remember { mutableStateOf(false) }
var explainWifiDiscovery by remember { mutableStateOf(false) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(initialTab.coerceIn(0, if (steam) ModeSettingsTab.entries.lastIndex else 2)) }
val tabs = if (steam) ModeSettingsTab.entries else listOf(
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
)
@@ -263,19 +266,11 @@ 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(
@@ -378,12 +373,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
)
}
}
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
if (steam && tab == ModeSettingsTab.STEAM && s.steamChannel != null) SettingsGroup(stringResource(R.string.mode_client)) {
ToggleRow(
host, "steamdeck", stringResource(R.string.mode_deck_mode),
stringResource(R.string.mode_deck_mode_hint),
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),
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.content.Context
@@ -53,8 +54,10 @@ fun PhantomProcessGatePage(
) {
BackHandler(onBack = onDismiss)
val context = LocalContext.current
val inspecting = LocalInspectionMode.current
val stage by WirelessAdbPairingService.stage.collectAsState()
val environment by produceState(GateEnvironment.read(context)) {
val environment by produceState(if (inspecting) GateEnvironment(true, true) else GateEnvironment.read(context)) {
if (inspecting) return@produceState
while (true) {
value = GateEnvironment.read(context)
kotlinx.coroutines.delay(2_000)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.animateFloat
@@ -148,8 +149,10 @@ fun LoadingOverlay(
val active = reached.coerceAtMost(stages.lastIndex)
val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
val context = LocalContext.current
val inspecting = LocalInspectionMode.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
if (inspecting) return@produceState
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
}
Box(
@@ -194,9 +194,6 @@ 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,
@@ -245,9 +242,6 @@ 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]. */
@@ -422,15 +416,6 @@ 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)
@@ -460,9 +445,6 @@ 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)) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
@@ -269,7 +270,8 @@ fun AnchoredMenu(
compact: Boolean = false, minWidth: androidx.compose.ui.unit.Dp = 120.dp,
content: @Composable ColumnScope.(FocusRequester) -> Unit,
) {
val state = remember { MutableTransitionState(false) }
val inspecting = LocalInspectionMode.current
val state = remember { MutableTransitionState(inspecting && open) }
state.targetState = open
if (!state.currentState && !state.targetState && state.isIdle) return
val firstItemFocus = remember { FocusRequester() }
@@ -4,6 +4,7 @@ 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
@@ -11,6 +12,7 @@ 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
@@ -39,38 +41,21 @@ 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 -
@@ -141,49 +126,32 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
) {
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) }
) = TiltedWall(games.size, driftMs, modifier) { i, m ->
Capsule(if (games.isEmpty()) null else games[i], RoundedCornerShape(10.dp), m)
}
/** 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] 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.
* [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.
*/
@Composable
internal fun TiltedWall(
count: Int,
driftMs: Int,
modifier: Modifier = Modifier,
capsule: (index: Int) -> WallCapsule,
capsule: @Composable (index: Int, modifier: Modifier) -> Unit,
) {
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)
@@ -194,17 +162,6 @@ 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
@@ -214,52 +171,26 @@ internal fun TiltedWall(
.graphicsLayer { rotationZ = -13f },
) {
repeat(columns) { c ->
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) }
}
},
)
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))
}
}
}
}
}
}
/** 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")
@@ -281,14 +212,12 @@ 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]
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(
Capsule(
games[i], RoundedCornerShape(14.dp),
Modifier
.offset(x = maxWidth * slot.x, y = maxHeight * slot.y)
.size(h * 2f / 3f, h)
@@ -299,11 +228,23 @@ 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())
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.activity.compose.BackHandler
@@ -69,8 +70,10 @@ import com.droiddeck.launcher.store.StoreState
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
@Composable
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, previewSections: Map<String, List<FlathubApi.AppSummary>>? = null) {
val ctx = LocalContext.current
val inspecting = LocalInspectionMode.current
val sections = previewSections.takeIf { inspecting } ?: StoreState.sections
val narrow = LocalNarrowPane.current
val padH = if (narrow) 16.dp else 22.dp
val padV = if (narrow) 12.dp else 18.dp
@@ -78,8 +81,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
var query by rememberSaveable { mutableStateOf("") }
var category by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
LaunchedEffect(Unit) { StoreState.loadSections() }
LaunchedEffect(s.ready) { if (!inspecting) StoreState.refresh(ctx) }
LaunchedEffect(Unit) { if (!inspecting) StoreState.loadSections() }
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
// a tab's contents - and would be lost with it. Each move says where focus goes next.
val ff = LocalFrontFocus.current
@@ -132,7 +135,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
}
BusyBar()
when (tab) {
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; if (!inspecting) StoreState.search("", it) }, sections = sections)
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
else -> Installed(a, onOpen = { openApp = it })
}
@@ -142,6 +145,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
/** Flatpak is not in the runtime yet: one button puts it there. */
@Composable
private fun SetupCard() {
val inspecting = LocalInspectionMode.current
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -156,7 +160,7 @@ private fun SetupCard() {
fontSize = 14.sp, color = colors.onSurfaceVariant,
)
Actions {
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { if (!inspecting) StoreState.setup(ctx) }
}
}
}
@@ -181,20 +185,21 @@ private fun BusyBar() {
}
@Composable
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit, sections: Map<String, List<FlathubApi.AppSummary>>) {
val inspecting = LocalInspectionMode.current
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
Rise(3) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
}
}
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
if (StoreState.sectionsFailed && sections.values.all { it.isEmpty() }) {
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { if (!inspecting) StoreState.loadSections(force = true) } }
return
}
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
val apps = StoreState.sections[key]
val apps = sections[key]
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
}
@@ -202,12 +207,13 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
@Composable
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
val inspecting = LocalInspectionMode.current
val view = androidx.compose.ui.platform.LocalView.current
val run = {
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
?.hideSoftInputFromWindow(view.windowToken, 0)
StoreState.search(query.trim(), category)
if (!inspecting) StoreState.search(query.trim(), category)
}
Rise(3) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
@@ -220,9 +226,9 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
}
Rise(4) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); if (!inspecting) StoreState.search(query.trim(), null) }
FlathubApi.categories.forEach { c ->
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); if (!inspecting) StoreState.search(query.trim(), c.id) }
}
}
}
@@ -240,6 +246,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
@Composable
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
val inspecting = LocalInspectionMode.current
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -247,10 +254,10 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
Rise(3) {
Actions {
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
StoreState.checkUpdates(ctx)
if (!inspecting) StoreState.checkUpdates(ctx)
}
if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
if (!inspecting) StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true)
}
}
@@ -276,7 +283,7 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
)
}
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { if (!inspecting) StoreState.update(ctx, app.id, app.name) }
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
}
}
@@ -292,7 +299,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val narrow = LocalNarrowPane.current
LaunchedEffect(id) { StoreState.loadDetails(id) }
val inspecting = LocalInspectionMode.current
LaunchedEffect(id) { if (!inspecting) StoreState.loadDetails(id) }
val d = StoreState.details[id]
val local = StoreState.installed.firstOrNull { it.id == id }
val name = d?.name ?: local?.name ?: id
@@ -317,15 +325,15 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
Actions {
if (installed) {
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { if (!inspecting) StoreState.update(ctx, id, name) }
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) {
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
if (confirmRemove) { confirmRemove = false; if (!inspecting) StoreState.uninstall(ctx, id, name) } else confirmRemove = true
}
} else {
PrimaryButton(
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true,
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
) { StoreState.install(ctx, id, name) }
) { if (!inspecting) StoreState.install(ctx, id, name) }
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false)
}
d?.version?.let { ActionChip("v$it", ok = false) }
@@ -36,7 +36,6 @@ 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
@@ -775,12 +774,14 @@ private fun SignInCard(store: Store, height: Dp) {
.takeIf { it.size >= 8 }.orEmpty()
}
val shape = RoundedCornerShape(20.dp)
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
val ground = SolidColor(c.fill)
val capsule = RoundedCornerShape(10.dp)
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 -> if (tiles.isEmpty()) blank else WallCapsule(ground, tiles[i]) }
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()) }
}
}
// The wall gives way to the words: dark at the left and the foot, and the store's glow under them.
Box(
@@ -92,10 +92,10 @@ class UpdatesActions(
)
@Composable
internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, installed: AppUpdates.Installed? = null) {
val u = s.updates
val ua = a.updates
val me = remember { AppUpdates.installed() }
val me = remember(installed) { installed ?: AppUpdates.installed() }
val colors = MaterialTheme.colorScheme
Column(modifier = modifier) {
PageHeader(stringResource(R.string.upd_title)) {
@@ -15,10 +15,6 @@ 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,11 +34,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalInspectionMode
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
@@ -58,38 +54,13 @@ 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 = 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))
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
}
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@@ -98,28 +69,22 @@ 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,
previewComponents: List<WinComponents.Component> = emptyList(),
onClose: () -> Unit,
) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
val inspecting = LocalInspectionMode.current
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
// 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 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 recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
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 selection by remember { mutableStateOf(WinComponents.selection(context, appKey)) }
val picks = selection.effective
var picks by remember { mutableStateOf(if (inspecting) emptyList() else WinComponents.picks(context, appKey)) }
// 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.
@@ -134,7 +99,6 @@ 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() }
@@ -143,49 +107,25 @@ internal fun WinComponentsDialog(
var switchFocused by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (inspecting) { catalog = previewComponents.associateBy { it.name }; return@LaunchedEffect }
val (entries, found) = withContext(Dispatchers.IO) {
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)) }
}
// 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)
}
offline = entries == null
catalog = entries.orEmpty().associateBy { it.name }
findings = found
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
recommended = found
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
}
/** 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 setPicks(next: List<String>) {
picks = next
if (!inspecting) coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
}
fun toggle(id: String, on: Boolean) {
if (inspecting) { picks = if (on) picks + id else picks - id; return }
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
// then is ignored here; the busy row is greyed instead.
if (busyId != null) return
@@ -195,16 +135,17 @@ 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
selection = WinComponents.Selection(selection.auto, selection.user + (id to false))
val next = picks - id
picks = next
coroutine.launch {
withContext(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, false) }
withContext(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
busyId = null; reverting = false
}
return
}
val all = catalog.orEmpty()
val c = all[id]
if (id in installed || c == null) { setUser(id, true); return }
if (id in installed || c == null) { setPicks(picks + id); return }
busyId = id; reverting = false
progress = WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1)
coroutine.launch {
@@ -213,48 +154,28 @@ internal fun WinComponentsDialog(
}
progress = null
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
if (problem != null) { error = problem; failed = id } else setUser(id, true)
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
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
// 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 recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
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. 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
// to its place in the alphabet.
val recSet = recIds.map { installable(it.componentName, all.orEmpty()) }.toSet()
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 && it !in recSet }.sortedBy { WinComponentNames.of(it).lowercase() }
val list = (ready + extra).distinct().filter { it !in active }.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 && (autoIds.isNotEmpty() || recIds.any { supportOf(installable(it.component!!, all)) == Support.READY })
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, 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) }
@@ -281,14 +202,13 @@ 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 && slowCatalog) Small(stringResource(R.string.wincomp_loading))
if (all == null) 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)
@@ -298,15 +218,12 @@ 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 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 usable = support == Support.READY
val busy = id == busyId
val caption = when {
!busy -> null
id == busyId && reverting -> stringResource(R.string.wincomp_reverting)
else -> shownProgress?.let { p ->
reverting -> stringResource(R.string.wincomp_reverting)
else -> progress?.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)
@@ -316,9 +233,9 @@ internal fun WinComponentsDialog(
}
}
ComponentRow(
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || (id in picks && id in installed),
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
dim = !usable || busy, busy = busy, caption = caption,
percent = if (busy && !reverting) shownProgress?.percent ?: -1 else -1,
percent = if (busy && !reverting) progress?.percent ?: -1 else -1,
modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
.onFocusChanged { if (it.isFocused) switchFocused = true },
) { on -> toggle(id, on) }
@@ -329,97 +246,48 @@ internal fun WinComponentsDialog(
return { id -> focus.takeIf { id == first } }
}
// 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) } }
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))
}
}
} 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))
@@ -429,39 +297,6 @@ 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)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.LocalInspectionMode
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import android.text.Editable
@@ -61,7 +62,8 @@ fun WirelessAdbFixPage(
onApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
) {
val context = LocalContext.current
val savedAddress = remember { WirelessAdbFix.savedConnectionAddress(context) }
val inspecting = LocalInspectionMode.current
val savedAddress = remember { if (inspecting) null else WirelessAdbFix.savedConnectionAddress(context) }
var step by rememberSaveable { mutableStateOf(if (savedAddress == null) 0 else 1) }
var pairingAddress by rememberSaveable { mutableStateOf("") }
var connectionAddress by rememberSaveable { mutableStateOf(savedAddress.orEmpty()) }
+10 -25
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,30 +312,12 @@
<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. 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 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_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>
@@ -852,8 +839,6 @@
<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>
+8 -5
View File
@@ -232,9 +232,6 @@
<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>
@@ -260,8 +257,16 @@
<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>
@@ -725,8 +730,6 @@
<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>
+10 -23
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,28 +312,12 @@
<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です(コンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
<string name="wincomp_applies">Protonに含まれないWindows DLLです(Bannerlatorのコンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用の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>
@@ -842,8 +831,6 @@
<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>
+10 -23
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,28 +312,12 @@
<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로, 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, Bannerlator의 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 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>
@@ -842,8 +831,6 @@
<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>
+8 -5
View File
@@ -254,9 +254,6 @@
<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>
@@ -284,8 +281,16 @@
<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>
@@ -809,8 +814,6 @@
<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>
+10 -23
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,28 +312,12 @@
<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,来自组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自 Bannerlator 的组件列表。开启此游戏需要的组件:它们会放入游戏自己的 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>
@@ -842,8 +831,6 @@
<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>
+10 -23
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,28 +312,12 @@
<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,取自組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自 Bannerlator 的組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 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>
@@ -842,8 +831,6 @@
<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>
+10 -23
View File
@@ -254,9 +254,6 @@
<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>
@@ -286,8 +283,16 @@
<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>
@@ -307,28 +312,12 @@
<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,取自元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自 Bannerlator 的元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 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>
@@ -842,8 +831,6 @@
<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>
+11 -37
View File
@@ -262,11 +262,8 @@
<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. 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_controller_note">Deck: Quick Access button and gyro. 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>
@@ -296,8 +293,16 @@
<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>
@@ -317,29 +322,12 @@
<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 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_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_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>
@@ -647,10 +635,6 @@
<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>
@@ -865,8 +849,6 @@
<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>
@@ -991,12 +973,6 @@
<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>
@@ -1060,8 +1036,6 @@
<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,26 +46,10 @@ class GameEnvironmentTest {
@Test fun dotnetRuntimeFitsAndroidAddressSpaceForEveryGame() {
val values = GameEnvironment.effective(GameEnvironment.Config(), "PERFORMANCE", "43")
assertEquals("800000000", values["DOTNET_GCRegionRange"])
assertEquals("0x800000000", 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,8 +83,7 @@ class AddedExesTest {
val result = AddedGames.addExe(app, deep)
assertTrue(result is AddedGames.AddResult.Switched)
assertEquals(deep.path, AddedGames.scan(app).single().exe.path)
// 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 })
assertEquals(0, AddedExes.list(app).size)
}
@Test fun anExeTheSessionCannotSeeIsNotAdded() {
@@ -33,40 +33,27 @@ class AddedGameArtLookupTest {
}
@Test fun anAppidFromTheGamesFilesIsUsedWithoutASearch() {
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)
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 })
}
@Test fun withoutAnAppidTheTitleIsSearchedOnceAWeek() {
val dir = tmp.newFolder("Nothing Like It")
val exe = File(dir, "game.exe").apply { writeText("x") }
val lookup = File(tmp.newFolder(), "steam-appid")
var searches = 0
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)
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(1, searches)
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)
lookup.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)
assertEquals("42", AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "42" })
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,45 +27,4 @@ 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 })
}
}
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more