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
32 changed files with 1237 additions and 88 deletions

No files matched your search

+24
View File
@@ -488,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)
}
@@ -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()
},
)
}
@@ -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(
@@ -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) }
@@ -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
@@ -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) {
@@ -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
@@ -259,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 },
@@ -270,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() }
@@ -378,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
@@ -435,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"
@@ -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 }
@@ -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) } }
@@ -165,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,
)
@@ -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(
@@ -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() }
@@ -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) }
@@ -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)) {
@@ -34,6 +34,7 @@ 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
@@ -68,10 +69,12 @@ 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,
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.
@@ -81,7 +84,7 @@ internal fun WinComponentsDialog(
var installed by remember { mutableStateOf(emptySet<String>()) }
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
var present by remember { mutableStateOf(emptySet<String>()) }
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
var 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.
@@ -104,6 +107,7 @@ 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) {
// The folder's own installers first, then what Steam installs with the game.
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
@@ -118,9 +122,10 @@ internal fun WinComponentsDialog(
}
fun setPicks(next: List<String>) {
picks = next
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, 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
@@ -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()) }
@@ -0,0 +1,80 @@
package com.droiddeck.launcher.ui
import android.app.Application
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Looper
import android.view.inspector.WindowInspector
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.ui.test.junit4.createComposeRule
import com.droiddeck.launcher.files.filePreviewCases
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.ParameterizedRobolectricTestRunner.Parameters
import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.time.Duration
/** Render the actual preview entry points, including dialogs and their inspection-only paths. */
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-land-mdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
internal class PreviewRenderTest(private val case: PreviewCase) {
@get:Rule val compose = createComposeRule()
@Test fun rendersContentWithoutDeviceOrRuntime() = render("handheld")
@Test @Config(qualifiers = "w480dp-h360dp-land-mdpi")
fun rendersCompactLayout() = render("compact")
@Test @Config(qualifiers = "w400dp-h700dp-port-mdpi")
fun rendersPortraitLayout() = render("portrait")
private fun render(size: String) {
if (case.name == "CustomResolution") {
// Compose 1.6's AlertDialog + weighted text fields never report idle to Espresso here.
// Render this dialog with bounded native frames instead of changing production UI.
val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup()
try {
activity.get().setContent { case.content() }
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1000))
saveWindow(size)
} finally {
activity.pause().stop().destroy()
}
return
}
compose.mainClock.autoAdvance = false
compose.setContent { case.content() }
compose.mainClock.advanceTimeBy(1000)
compose.waitForIdle()
compose.runOnIdle { saveWindow(size) }
}
private fun saveWindow(size: String) {
// Dialogs have their own root; capture their content rather than the empty host.
val view = WindowInspector.getGlobalWindowViews().last()
assertTrue("${case.name}: empty bounds", view.width > 0 && view.height > 0)
val image = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
view.draw(Canvas(image))
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
assertTrue("${case.name}: blank preview", pixels.any { it != pixels.first() })
val output = File("build/preview-renders/$size/${case.name}.png")
output.parentFile?.mkdirs()
output.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
image.recycle()
}
companion object {
@JvmStatic @Parameters(name = "{0}")
fun previews(): List<Array<Any>> = (screenPreviewCases + filePreviewCases).map { arrayOf(it) }
}
}
+49
View File
@@ -0,0 +1,49 @@
# Compose previews
Select the **debug** build variant in Android Studio, then open a preview file under
`app/src/debug/java/com/droiddeck/launcher/` and choose **Split** or **Design**.
Build the project after syncing Gradle. Previews call the production composables;
there is no separate mock UI to keep in sync.
| File | Coverage |
| --- | --- |
| `ui/LauncherPreviews.kt` | Steam home, runtime missing/downloading, populated/empty library, game details, desktop, emulator, Android apps, Store, Updates, alternate theme |
| `ui/SettingsPreviews.kt` | All four Setup tabs, all six Steam settings tabs, desktop settings, controller/mapping, performance, shared settings widgets |
| `ui/RuntimePreviews.kt` | Proton installed/empty/downloading, components/loading, GPU drivers and driver downloads |
| `ui/SessionPreviews.kt` | Every session drawer page, loading/starting/error screens, PC keyboard, HUD, cursor |
| `ui/DialogPreviews.kt` | Add/edit app, confirmation, custom resolution, ROM folder, environment variables, Windows components, display chooser, wireless ADB, process-limit help |
| `files/FilePreviews.kt` | File manager grid/list/empty/favorites, file/folder pickers, properties |
`@DroidDeckPreviews` shows handheld landscape (960×540 dp), compact landscape
(480×360 dp), portrait (400×700 dp), and larger text. The Paper theme has a separate
launcher preview. Dimensions are Android layout units, not physical display pixels.
Edit the real UI components to adjust layouts. Edit `PreviewData.kt` for sample
screen state and callbacks, and `FilePreviews.kt` for file metadata. Add a preview
for each new screen or materially different state. Add its entry to
`PreviewCases.kt` or `filePreviewCases` so it is covered by the render check.
Preview fixtures and tooling are debug-only. `PreviewHost` supplies the production
theme, inspection mode, and inert navigation/file-picker owners. Inspection paths
skip automatic runtime, directory, device, and network operations and settle
entrance animations so content is visible immediately. File manager mutation
commands are disabled in inspection mode. Previews are for layout and local UI
interaction; they do not validate installation, pairing, launching, or controller
input latency.
The native game surface, Termux terminal, on-screen gamepad, and second-screen
View controls are outside this Compose preview set. Their surrounding Compose
menus are covered. These components still need device validation.
Run the preview render smoke checks:
```sh
./gradlew app:testDebugUnitTest --tests '*PreviewRenderTest'
```
The checks use Robolectric native graphics with an ordinary `Application`, render
the same 68 preview entry points at handheld, compact, and portrait sizes, reject
empty/solid-color output, and write PNGs to
`app/build/preview-renders/`. This catches composition/render errors without starting
the Linux runtime. Android Studio uses Layoutlib, so the smoke check complements
Studio preview validation rather than replacing it.