Files
DroidDeck/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
T
MaxsTechReview c5577478ca chore: remove source comments and unused code, translate every missing string (#1965)
* chore(i18n): translate every missing string in all locales

Adds the 87 strings the bottom-screen Dashboard and in-game stats pages
introduced to all 32 locales, plus the 51 older settings strings Japanese
and the 50 Chinese locales were still missing. Every locale now has all
3456 translatable strings with the same placeholders as English.

Also translates 36 entries that were still English where the language
does not use the English word, and makes es-419 touch_app_id use the same
%1$s placeholder as English.

* chore(patches): drop comments from the lines downstream patches add

Removes comment lines and trailing comments from lines our patches add,
with hunk headers recounted. Lines that a later patch uses as context
and the upstream backport 0123 are left as they are.

The gamescope, FEX, proot and xodus series still apply to their pinned
sources, and the patched trees differ only in those comments.
test_hud_frames now finds the HUD writer by its first declaration.

* docs: credit Greenlight, Better xCloud, viture-v2, GameNative and DiRTTelemetryErrorFix

These were credited only in source headers and NOTICE files.

* chore: remove source comments and unused functions

Removes explanatory comments from first-party Kotlin, Java, C, C++,
Rust, TypeScript, Python, shell, Gradle, CMake and workflow files.
License and attribution headers stay, and vendored third-party trees,
generated files and license files are untouched. A shellcheck directive
is kept, and udevmon's fallthrough comments became fallthrough attributes.

Syntax trees and token streams match the previous sources apart from the
comments and Python docstrings. Module docstrings that argparse prints
are kept, and device-smoke.sh prints the same --help text from a heredoc.

Also removes 37 functions and 4 fields that nothing referenced.

* chore: remove comments from the Syncthing integration and the latest main sources

* docs: credit Syncthing and the syncthing-android fork

* chore(i18n): translate the Syncthing strings in all locales

The 107 strings the Syncthing integration added are now in all 32 locales.
sync_web_path_locked names the Syncthing tab by its label.

* docs: credit @xXJSONDeruloXx for the HUD metric readers

The CPU, GPU load, thermal and battery readers come from their
PerformanceHudView pull requests #777, #877, #991, #1406 and #1573.
The FusionHUD credit line is unchanged.

* docs: credit the HUD metric readers to @xXJSONDeruloXx's work
2026-11-01 16:27:27 -05:00

2769 lines
153 KiB
Kotlin

package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.Manifest
import android.app.ActivityOptions
import android.content.Intent
import android.content.ClipData
import android.content.ClipboardManager
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
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.ui.PackageRow
import com.droiddeck.launcher.ui.exposeTestTags
import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.SteamIntegration
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettings
import com.droiddeck.launcher.ui.ModeSettingsActions
import com.droiddeck.launcher.ui.ConfirmDialog
import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.GameFiles
import com.droiddeck.launcher.frontend.GameFileSync
import com.droiddeck.launcher.frontend.GameLaunchIntent
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
class FrontEndActivity : ComponentActivity() {
override fun attachBaseContext(newBase: android.content.Context) {
super.attachBaseContext(newBase)
com.droiddeck.launcher.core.AppLanguage.applyTo(this, newBase)
}
private val ui = Handler(Looper.getMainLooper())
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui) { !busy && flood == null && compatLaunch == null && pkgStage == null && components.compBusy == null }
private var installed by mutableStateOf<String?>(null)
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
private class CompatLaunch(val intent: Intent, val loading: com.droiddeck.launcher.session.LoadingState, var menuInstall: Boolean)
private var compatLaunch by mutableStateOf<CompatLaunch?>(null)
private var compatFlood by mutableStateOf<androidx.compose.ui.graphics.Color?>(null)
private var compatLaunchPoll: Runnable? = null
private var compatLaunchVisible = false
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var removalPending by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var catalogFresh = false
private var componentsQueueBusy by mutableStateOf(false)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
private var switchDeadline = 0L
private var showResetSettings by mutableStateOf(false)
private var lowSpace by mutableStateOf<Long?>(null)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var returnAfterGame by mutableStateOf(true)
private var shaderPrecache by mutableStateOf<Boolean?>(null)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var componentsGameKey by mutableStateOf<String?>(null)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
private var onSavePicked: ((File) -> Unit)? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
private var catalogLoading by mutableStateOf(false)
private var packageRows by mutableStateOf<List<PackageRow>?>(null)
private object PackageOperation {
val id = mutableStateOf<String?>(null)
val stage = mutableStateOf<String?>(null)
val percent = mutableIntStateOf(-1)
}
private var pkgId by PackageOperation.id
private var pkgStage by PackageOperation.stage
private var pkgPercent by PackageOperation.percent
private var desktopInstalled by mutableStateOf(false)
private var desktopPresent by mutableStateOf(false)
private var battleNetInstalled by mutableStateOf(false)
private var offlineAccount by mutableStateOf<String?>(null)
private var offline by mutableStateOf(false)
private var showPerformance by mutableStateOf(false)
private var clientOverride by mutableStateOf(false)
private var clientCores by mutableStateOf<Set<Int>>(emptySet())
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var fastSync by mutableStateOf(false)
private var syncFallback by mutableStateOf(true)
private var fsyncFirst by mutableStateOf(false)
private var gamescopeRealtime by mutableStateOf(false)
private var ramCapMb by mutableStateOf(0)
private var zramClient by mutableStateOf(true)
private var vramCapMb by mutableStateOf(0)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var prootFastPath by mutableStateOf(true)
private var cursorComposite by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherOrientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_USER)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var downloadProxy by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var navRequest by mutableStateOf<String?>(null)
private var storesShowMature by mutableStateOf(false)
private var storesSort by mutableStateOf(SessionPrefs.STORES_SORTS.first())
private var storesView by mutableStateOf(SessionPrefs.DEFAULT_STORES_VIEW)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
private var wifiDiscoveryLocation by mutableStateOf(false)
private var wifiDiscoveryAsked by mutableStateOf(false)
private var wifiDiscoveryBlocked by mutableStateOf(false)
private var integrationState by mutableStateOf(SteamIntegration.State())
private var integrationFocus by mutableStateOf<SteamIntegration.Feature?>(null)
private val wifiLocationReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
refreshWifiDiscovery()
}
}
private val mediaRefresh = Runnable {
refresh()
refreshAddedGames()
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
usbScan++
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
}
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
}
private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
com.droiddeck.launcher.handtracking.HandTracking.refreshPermission(this)
}
private var placementCheck by mutableStateOf<Boolean?>(null)
private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
}
private val pickComponent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importComponent(it) }
}
private val pickLinuxDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = true) }
}
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
}
private val pickBootMovie = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importBootMovie(it) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private fun refreshWifiDiscovery() {
wifiDiscoveryPermission = WifiDiscovery.permissionGranted(this)
wifiDiscoveryLocation = WifiDiscovery.locationEnabled(this)
wifiDiscoveryAsked = SessionPrefs.wifiDiscoveryAsked(this)
wifiDiscoveryBlocked = !wifiDiscoveryPermission && wifiDiscoveryAsked &&
WifiDiscovery.permissions.none { shouldShowRequestPermissionRationale(it) }
if (!wifiDiscoveryPermission && SessionPrefs.wifiDiscoveryEnabled(this)) {
SessionPrefs.setWifiDiscoveryEnabled(this, false)
}
wifiDiscovery = SessionPrefs.wifiDiscoveryEnabled(this)
integrationState = readIntegration()
}
private var shortcutsTried = false
private fun readIntegration() = SteamIntegration.read(this)
.copy(locationBlocked = wifiDiscoveryBlocked, shortcutsTried = shortcutsTried)
private fun openIntegration(feature: SteamIntegration.Feature? = null) {
SteamIntegration.dismissSuggestion(this)
refreshWifiDiscovery()
integrationState = readIntegration()
integrationFocus = feature
navRequest = "setup:integration"
}
private fun readIntegrationIntent(incoming: Intent) {
if (incoming.action != SteamIntegration.ACTION_SETUP) return
openIntegration(incoming.getStringExtra(SteamIntegration.EXTRA_FEATURE)?.let {
runCatching { SteamIntegration.Feature.valueOf(it) }.getOrNull()
})
}
private fun integrationSettings(intent: Intent, fallback: String = android.provider.Settings.ACTION_SETTINGS) {
try { startActivity(intent) }
catch (_: android.content.ActivityNotFoundException) { startActivity(Intent(fallback)) }
catch (_: SecurityException) { startActivity(Intent(fallback)) }
}
private fun configureIntegration(feature: SteamIntegration.Feature) {
when (feature) {
SteamIntegration.Feature.WIFI -> {
if (wifiDiscovery && wifiDiscoveryPermission) {
SessionPrefs.setWifiDiscoveryEnabled(this, false)
} else if (wifiDiscoveryPermission) {
SessionPrefs.setWifiDiscoveryEnabled(this, true)
} else {
SessionPrefs.setWifiDiscoveryAsked(this)
wifiLocationPermission.launch(WifiDiscovery.permissions)
}
refreshWifiDiscovery()
}
SteamIntegration.Feature.BRIGHTNESS -> integrationSettings(SteamIntegration.brightnessSettings(this))
SteamIntegration.Feature.HOME -> if (HomeApp.isDefault(this)) HomeApp.openSystemHomeSettings(this) else {
HomeApp.setHomeScreenEnabled(this, true)
refreshHomeAppState()
manageHomeApp()
}
SteamIntegration.Feature.SHORTCUTS -> {
if (!integrationState.shortcuts) shortcutsTried = true
if (!HomeApp.isDefault(this)) configureIntegration(SteamIntegration.Feature.HOME)
else {
SteamIntegration.setShortcutsListed(this, true)
integrationSettings(SteamIntegration.shortcutSettings(this), android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS)
}
}
}
}
private fun openAppInfo() =
integrationSettings(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
private fun openWifiDiscoverySettings() {
if (!WifiDiscovery.permissionGranted(this)) {
wifiPermissionSettings.launch(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")))
} else {
startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}
}
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
ui.post { lossless = state }
}, "lossless-sync").start()
}
private fun importBootMovie(file: File) {
bootMovieImporting = true
bootMovieProgress = -1
Thread({
val result = com.droiddeck.launcher.session.BootMovie.import(
this, file,
onStart = { ui.post { android.widget.Toast.makeText(this, getString(R.string.boot_movie_converting_started, file.name), android.widget.Toast.LENGTH_LONG).show() } },
onProgress = { percent -> ui.post { bootMovieProgress = percent } },
)
val message = when (result) {
is com.droiddeck.launcher.session.BootMovie.Import.Done -> getString(
if (result.audioDropped) R.string.boot_movie_imported_silent else R.string.boot_movie_imported, result.name)
com.droiddeck.launcher.session.BootMovie.Import.TooLong ->
getString(R.string.boot_movie_too_long, com.droiddeck.launcher.session.BootMovie.MAX_CONVERT_SECONDS)
is com.droiddeck.launcher.session.BootMovie.Import.Unsupported -> getString(R.string.boot_movie_unsupported)
com.droiddeck.launcher.session.BootMovie.Import.Failed -> getString(R.string.boot_movie_import_failed)
}
ui.post {
bootMovieImporting = false
bootMovieProgress = -1
loadBootMovie()
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "boot-movie-import").start()
}
private fun loadBootMovie() {
bootMovie = com.droiddeck.launcher.session.BootMovie.choice(this)
bootMovieSound = SessionPrefs.bootMovieSound(this)
bootMovieCustomName = com.droiddeck.launcher.session.BootMovie.customName(this)
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
val state = Lossless.state(this)
ui.post {
lossless = state
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "lossless-import").start()
}
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, R.string.main_saves_close_session, android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction(getString(R.string.main_saves_importing, name)) {
val (written, backup) = GameSaves.import(this, game(), zip)
val kind = GameSaves.layoutOf(zip)?.label(this) ?: getString(R.string.saves_zip)
resources.getQuantityString(
if (backup != null) R.plurals.main_saves_imported_backup else R.plurals.main_saves_imported,
written, written, kind, name,
)
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), getString(R.string.main_saves_pick_zip, name), GameSaves.savesDir().parentFile?.parentFile?.path))
}
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction(getString(R.string.main_saves_exporting, name)) {
val (zip, count) = GameSaves.export(this, game(), layout, dir)
resources.getQuantityString(R.plurals.main_saves_exported, count, count, layout.label(this), zip.path.removePrefix("/storage/emulated/0/"))
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_saves_pick_folder, name, layout.label(this)), GameSaves.savesDir().path))
}
private fun addGameExe(path: String) {
val exe = java.io.File(path)
Thread({
val result = try { com.droiddeck.launcher.frontend.AddedGames.addExe(this, exe) } catch (e: Exception) {
Log.w(TAG, "add game: ${e.message}"); com.droiddeck.launcher.frontend.AddedGames.AddResult.NotFound
}
val game = when (result) {
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Added -> result.game
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Existing -> result.game
is com.droiddeck.launcher.frontend.AddedGames.AddResult.Switched -> result.game
else -> null
}
ui.post {
if (game == null) {
if (result is com.droiddeck.launcher.frontend.AddedGames.AddResult.Unreachable)
android.widget.Toast.makeText(this, R.string.games_add_unreachable, android.widget.Toast.LENGTH_LONG).show()
return@post
}
updateAddedGames(listOf(game.folder.path), then = { fetchAddedGameArt() })
navRequest = "app:${game.steamAppId ?: game.appId.toInt()}"
}
}, "add-game-exe").start()
}
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var sgdbUserKey by mutableStateOf(false)
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
romsDir = path
}
}
private var settingsMode by mutableStateOf<String?>(null)
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 steamClientArch by mutableStateOf("arm64")
private var runSteamAtStartup by mutableStateOf(false)
private var bootMovie by mutableStateOf(com.droiddeck.launcher.session.BootMovie.DROIDDECK_30)
private var bootMovieSound by mutableStateOf(true)
private var bootMovieCustomName by mutableStateOf<String?>(null)
private var bootMovieImporting by mutableStateOf(false)
private var bootMovieProgress by mutableStateOf(-1)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backHold by mutableStateOf(true)
private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("")
private var slowUsb by mutableStateOf<com.droiddeck.launcher.session.UsbLink.SlowDrive?>(null)
private var usbScan = 0
private var slowUsbNever by mutableStateOf(false)
private fun showSlowUsb(drive: com.droiddeck.launcher.session.UsbLink.SlowDrive?) {
val off = slowUsbNever
if (off) SessionPrefs.setSlowUsbWarningOff(this)
slowUsbNever = false
slowUsb = drive.takeUnless { off }
}
private var storageDiagnostics by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<GameStorage.Option>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
}
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
private var steamGamesX64 by mutableStateOf(false)
@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)
private var gameDebugLogsEnabled by mutableStateOf(false)
private var gameDebugLogsNeedsRestart by mutableStateOf(false)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var clearCachedApps by mutableStateOf(false)
private var adbPaired by mutableStateOf(false)
private var memoryHud by mutableStateOf(SessionPrefs.MEMORY_HUD_OFF)
private var backgroundAppLimit by mutableStateOf(com.droiddeck.launcher.core.BackgroundAppLimit.OFF)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
private var defaultHomeLabel by mutableStateOf<String?>(null)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
private var androidAppsListing = 0
private val packageCallback = object : android.content.pm.LauncherApps.Callback() {
private fun changed() {
refreshAndroidApps()
if (HomeWidgets.providers != null) HomeWidgets.refreshProviders(this@FrontEndActivity)
}
override fun onPackageRemoved(packageName: String, user: android.os.UserHandle) = changed()
override fun onPackageAdded(packageName: String, user: android.os.UserHandle) = changed()
override fun onPackageChanged(packageName: String, user: android.os.UserHandle) = changed()
override fun onPackagesAvailable(packageNames: Array<out String>, user: android.os.UserHandle, replacing: Boolean) = changed()
override fun onPackagesUnavailable(packageNames: Array<out String>, user: android.os.UserHandle, replacing: Boolean) = changed()
override fun onShortcutsChanged(packageName: String, shortcuts: MutableList<android.content.pm.ShortcutInfo>, user: android.os.UserHandle) {
if (homeAppSelected) HomePins.refresh(this@FrontEndActivity)
}
}
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var dualDisplayId by mutableStateOf(-1)
private var dualScreenDevice by mutableStateOf(false)
private var dualScreenMode by mutableStateOf(com.droiddeck.launcher.input.DualScreen.MODE_AUTO)
private var dualScreenLauncher by mutableStateOf(true)
private var dualScreenControls by mutableStateOf(true)
private var launcherStarted = false
private lateinit var displayManager: DisplayManager
private val secondScreenDisplayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
}
private val secondScreenTouchListener = object : android.hardware.input.InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceRemoved(deviceId: Int) = refreshSecondScreenDisplays()
override fun onInputDeviceChanged(deviceId: Int) = refreshSecondScreenDisplays()
}
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshHomeAppState()
}
override fun startActivity(intent: Intent?) {
val compat = protons.protonBusyId != null || ProtonExtras.installInProgress
if (intent?.component?.className == SessionActivity::class.java.name) {
if (compatLaunch != null) return
when {
busy || LinuxRuntimeInstaller.isBusy() -> {
android.widget.Toast.makeText(this, R.string.main_wait_runtime, android.widget.Toast.LENGTH_SHORT).show()
return
}
SessionState.installing != null && !SessionState.running -> {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
compat -> Unit
pkgStage != null -> {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return
}
}
}
if (intent?.component?.className == SessionActivity::class.java.name) {
if (flood != null) return
val from = com.droiddeck.launcher.ui.LaunchOrigin.take()
if (from != null && !SessionState.running && com.droiddeck.launcher.ui.Motion.scale != 0f) {
flood = PendingFlood(from, intent)
return
}
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
if (compat) {
waitForCompat(intent)
return
}
}
super.startActivity(intent)
if (intent?.component?.className == SessionActivity::class.java.name) overridePendingTransition(R.anim.session_rise, R.anim.session_hold)
}
private fun launchFlooded(f: PendingFlood) {
if (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
flood = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
if (!busy && !LinuxRuntimeInstaller.isBusy() && !(SessionState.installing != null && !SessionState.running) &&
(protons.protonBusyId != null || ProtonExtras.installInProgress)) {
compatFlood = com.droiddeck.launcher.ui.Themes.byId(theme).signal
waitForCompat(f.intent)
}
return
}
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
}
private fun waitForCompat(next: Intent, menuInstall: Boolean = protons.protonBusyId != null) {
if (compatLaunch != null) return
com.droiddeck.launcher.ui.LaunchOrigin.take()
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
val waiting = CompatLaunch(Intent(next), com.droiddeck.launcher.session.LoadingState(this), menuInstall)
compatLaunch = waiting
val poll = object : Runnable {
override fun run() {
if (isDestroyed || isFinishing || compatLaunch !== waiting) return
if (protons.protonBusyId != null || ProtonExtras.installInProgress) {
waiting.menuInstall = waiting.menuInstall || protons.protonBusyId != null
val percent = if (protons.protonBusyId != null) protons.protonPercent else -1
val stage = protons.protonStage ?: getString(R.string.load_compat_wait)
waiting.loading.say(if (percent >= 0) getString(R.string.load_compat_progress, stage, percent) else stage,
com.droiddeck.launcher.session.LoadingState.Topic.RUNTIME, readable = true)
waiting.loading.percent = percent
waiting.loading.tick()
ui.postDelayed(this, 250)
return
}
if (!compatLaunchVisible) { ui.postDelayed(this, 250); return }
val problem = protons.protonProblem.takeIf { waiting.menuInstall }
if (problem == null && (busy || LinuxRuntimeInstaller.isBusy() || (SessionState.installing != null && !SessionState.running) || pkgStage != null)) {
waiting.loading.say(pkgStage ?: stage.takeIf { it.isNotBlank() } ?: getString(R.string.main_wait_runtime),
com.droiddeck.launcher.session.LoadingState.Topic.RUNTIME, readable = true)
waiting.loading.percent = if (pkgStage != null) pkgPercent else percent
waiting.loading.tick()
ui.postDelayed(this, 250)
return
}
compatLaunchPoll = null
if (problem != null) waiting.loading.showEnded(problem)
else {
compatLaunch = null
startActivity(waiting.intent)
}
}
}
compatLaunchPoll = poll
poll.run()
}
private fun cancelCompatLaunch() {
compatLaunchPoll?.let(ui::removeCallbacks)
compatLaunchPoll = null
compatLaunch = null
compatFlood = null
}
private fun restoreCompatLaunch(state: Bundle) {
savedIntent(state, KEY_COMPAT_LAUNCH)?.let { next ->
val problem = state.getString(KEY_COMPAT_PROBLEM)
if (problem == null) waitForCompat(next, state.getBoolean(KEY_COMPAT_MENU_INSTALL))
else compatLaunch = CompatLaunch(next,
com.droiddeck.launcher.session.LoadingState(this).apply { showEnded(problem) }, false)
}
}
private fun saveCompatLaunch(state: Bundle) {
compatLaunch?.let {
state.putParcelable(KEY_COMPAT_LAUNCH, it.intent)
state.putBoolean(KEY_COMPAT_MENU_INSTALL, it.menuInstall)
if (it.loading.ended) state.putString(KEY_COMPAT_PROBLEM, it.loading.step)
}
}
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
@Volatile private var lastComponentRestore = 0L
@Volatile private var componentRestoreRetry = false
private val componentRestoreLock = Any()
private fun retryComponentRestore() {
val run = synchronized(componentRestoreLock) {
componentRestoreRetry = false
val now = android.os.SystemClock.elapsedRealtime()
(!isDestroyed && now - lastComponentRestore >= COMPONENT_RESTORE_MS).also { if (it) lastComponentRestore = now }
}
if (run) Thread({
runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
}, "components-restore").start()
}
private var pendingGameLink: String? = null
private var gameLinkReading = false
private var gameLinkTried = false
private var gameSyncFolder by mutableStateOf<String?>(null)
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
val saved = if (game != null) GameFiles.export(this, folder, game)
else {
GameFileSync.enable(this, folder)
folder
}
ui.post {
gameSyncFolder = GameFileSync.folder(this)
}
getString(R.string.game_file_exported, saved.path)
}
}
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
}
private fun switchLauncherIcon(style: String): Boolean {
val before = LauncherIcon.current(this)
LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
finishAndRemoveTask()
return true
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
steamGames = emptyList()
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
gameLinkReading = false
gameLinkTried = false
if (GameLaunchIntent.accepts(request.action)) {
gameLinkReading = true
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
val copy = Intent(request)
Thread({
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
gameLinkReading = false
pendingGameLink = id
if (id == null) {
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
} else {
handleGameLink(steamGames, scanned = false)
refresh()
}
}
}
}, "game-launch-file").start()
}
}
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
if (pkgStage != null) {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, game)) return launchWithoutSteam(game, returnHome)
if (SessionState.running) {
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
return true
}
val steamVr = game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID
if (!steamVr && !SessionService.launchGame(this, game.gameIdString)) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.returnTo(returnHome)
.apply {
if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) {
putExtra(SessionActivity.EXTRA_STEAMVR, true)
} else {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}, steamSession = true)
}
private fun Intent.returnTo(returnHome: Boolean): Intent {
val task = taskId
return putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.apply { if (returnHome) putExtra(SessionActivity.EXTRA_LINK_TASK, task) }
}
private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean {
val app = applicationContext
Thread({
val added = if (game.library == Library.ADDED) runCatching { com.droiddeck.launcher.session.NoSteamLaunch.addedGame(app, game) }.getOrNull() else null
val command = com.droiddeck.launcher.session.NoSteamLaunch.command(game, added)
ui.post {
if (isDestroyed) return@post
if (command == null) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return@post
}
com.droiddeck.launcher.session.NoSteamLaunch.remember(command)
val next = Intent(this, SessionActivity::class.java)
.returnTo(returnHome)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
.putExtra(SessionService.EXTRA_GAME_ID, com.droiddeck.launcher.session.NoSteamLaunch.scope(game))
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
command.program == com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET && handToBattleNet(command.args, returnHome) { pendingSwitch = next } -> {}
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
else -> pendingSwitch = next
}
}
}, "no-steam-launch").start()
return true
}
private fun opensEpicLauncher(next: Intent): Boolean =
next.getStringExtra(SessionService.EXTRA_PROGRAM) == com.droiddeck.launcher.session.NoSteamLaunch.EPIC ||
next.getStringExtra(SessionService.EXTRA_STEAM_URL) == com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId))
private fun uninstallEpicLauncher() {
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) return
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = true
pendingSwitch?.takeIf(::opensEpicLauncher)?.let { pendingSwitch = null }
switchingTo?.takeIf(::opensEpicLauncher)?.let {
switchPoll?.let { poll -> ui.removeCallbacks(poll) }
switchPoll = null
switchingTo = null
}
if (SessionState.running) SessionService.stop(this)
val app = applicationContext
val deadline = android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS
Thread({
while ((SessionState.running || SessionState.phase !in SESSION_ENDED) && android.os.SystemClock.uptimeMillis() < deadline) {
Thread.sleep(SESSION_SWITCH_POLL_MS)
}
val ended = !SessionState.running && SessionState.phase in SESSION_ENDED
val done = try {
ended && com.droiddeck.launcher.stores.epic.EpicClient.uninstall(app)
} finally {
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = false
}
ui.post {
if (!isDestroyed) refresh()
android.widget.Toast.makeText(app, if (done) R.string.epic_client_uninstalled else R.string.epic_client_uninstall_failed,
android.widget.Toast.LENGTH_LONG).show()
}
}, "epic-uninstall").start()
}
private fun openEpicLauncher(): Boolean {
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) {
android.widget.Toast.makeText(this, R.string.epic_client_uninstalling, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (pkgStage != null) {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (!com.droiddeck.launcher.stores.epic.EpicClient.launcherRunning(this) && !com.droiddeck.launcher.stores.epic.EpicClient.fexReady(this)) {
com.droiddeck.launcher.stores.epic.EpicClient.requestFex(this)
android.widget.Toast.makeText(this, R.string.epic_client_needs_fex, android.widget.Toast.LENGTH_LONG).show()
if (!SessionState.running) startSteamSession()
else if (SessionState.mode == SessionService.MODE_STEAM) startActivity(Intent(this, SessionActivity::class.java)
.setAction(SessionService.ACTION_RESUME).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)) {
com.droiddeck.launcher.stores.epic.EpicClient.launcherEntry(this, Library.EPIC_LAUNCHER_NAME)?.let { return launchWithoutSteam(it, false) }
}
val launcher = Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
.putExtra(SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)))
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested) {
val app = applicationContext
val shortcut = com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId
val listed = com.droiddeck.launcher.frontend.AddedGames.inSteam(app, shortcut)
if (listed && !SessionService.launchGame(this, com.droiddeck.launcher.stores.epic.EpicClient.gameId(shortcut))) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
if (!listed) Thread({
com.droiddeck.launcher.frontend.AddedGamesPublisher.republish(app)
val id = com.droiddeck.launcher.stores.epic.EpicClient.steamLauncherId(app) ?: shortcut
ui.post { SessionService.launchGame(app, com.droiddeck.launcher.stores.epic.EpicClient.gameId(id)) }
}, "epic-launcher-open").start()
return true
}
if (SessionState.running) {
if (SessionState.stopRequested) android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
else pendingSwitch = launcher
return true
}
return startSessionOrWait(launcher)
}
private fun startSessionOrWait(next: Intent): Boolean {
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL) ||
next.getStringExtra(SessionService.EXTRA_PROGRAM) in setOf(com.droiddeck.launcher.session.NoSteamLaunch.PROTON_RUN, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET))
if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return started
}
private fun startAfterSession(next: Intent) {
SessionService.stop(this)
awaitSessionEnd(next, android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS)
}
private fun awaitSessionEnd(next: Intent, deadline: Long) {
switchingTo = next
switchDeadline = deadline
switchPoll?.let { ui.removeCallbacks(it) }
val poll = object : Runnable {
override fun run() {
if (isDestroyed) return
if (SessionState.running || SessionState.phase !in SESSION_ENDED) {
if (android.os.SystemClock.uptimeMillis() < deadline) ui.postDelayed(this, SESSION_SWITCH_POLL_MS)
else {
switchPoll = null
switchingTo = null
android.widget.Toast.makeText(this@FrontEndActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
}
return
}
switchPoll = null
switchingTo = null
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling && opensEpicLauncher(next)) return
startSessionOrWait(next)
}
}
switchPoll = poll
ui.postDelayed(poll, SESSION_SWITCH_POLL_MS)
}
private fun handleGameLink(games: List<Library.SteamGame>, scanned: Boolean = true) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
if (!scanned && games.none { it.gameIdString == id }) return
gameLinkTried = true
ui.postDelayed({
if (!isDestroyed && lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) syncDualPanel()
}, LINK_PANEL_MS)
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
syncDualPanel()
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
}
}
private fun chooseGameShortcut(game: Library.SteamGame) {
setResult(RESULT_OK, com.droiddeck.launcher.frontend.GameShortcuts.result(this, game))
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) readIntegrationIntent(intent)
readGameIntent(intent)
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
if (!SessionState.running) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
val (cached, cachedX64) = com.droiddeck.launcher.frontend.LibraryCache.snapshot(this)
steamGames = if (shortcutPicker) emptyList() else cached
steamGamesX64 = cachedX64
backActionsInverted = SessionPrefs.backActionsInverted(this)
orientation = SessionPrefs.orientation(this)
launcherOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
requestedOrientation = launcherOrientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
downloadSource = DownloadSource.mode(this)
downloadMirror = DownloadSource.mirror(this)
downloadPrefix = DownloadSource.custom(this)
downloadProxy = DownloadSource.proxyHostPort(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
LauncherIcon.repair(this, intent?.component)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesShowMature = SessionPrefs.storesShowMature(this)
storesSort = SessionPrefs.storesSort(this)
storesView = SessionPrefs.storesView(this)
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
slowUsb?.let { drive ->
com.droiddeck.launcher.ui.SlowUsbDialog(drive, slowUsbNever, { slowUsbNever = it }) {
com.droiddeck.launcher.session.UsbLink.markShown(this, drive)
showSlowUsb(null)
checkUsbLink()
}
}
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
showMapping -> { { MappingHost() } }
else -> null
}
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
shortcutPicker = shortcutPicker,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
defaultHomeLabel = defaultHomeLabel,
integration = integrationState,
integrationFocus = integrationFocus,
androidApps = androidApps,
secondScreenDisplays = secondScreenDisplays,
dualScreen = dualDisplayId >= 0 && com.droiddeck.launcher.ui.DualScreenPanel.available,
dualScreenDevice = dualScreenDevice,
dualScreenMode = dualScreenMode,
dualScreenLauncher = dualScreenLauncher,
dualScreenControls = dualScreenControls,
packages = packageRows,
packageCatalogLoading = catalogLoading,
packageBusyId = pkgId,
packageStage = pkgStage,
packagePercent = pkgPercent,
sessionRunning = SessionState.running,
battleNetInstalled = battleNetInstalled,
backActionsInverted = backActionsInverted,
buildLabel = BuildConfig.BUILD_LABEL,
oscMode = oscMode,
controller = controllerSettings,
updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherOrientation = launcherOrientation,
launcherFullscreen = launcherFullscreen,
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
navigate = navRequest,
addedGamesArt = addedGamesArt,
sgdbUserKey = sgdbUserKey,
storesShowMature = storesShowMature,
storesSort = storesSort,
storesView = storesView,
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { playSteam() },
onPlaySteamVr = { playSteamVr() },
onSteamUrl = { url ->
if (!SessionState.running) startSession(
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
)
},
onPlayDesktopUi = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g ->
if (shortcutPicker) chooseGameShortcut(g)
else {
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
}
},
onEpicLauncher = { openEpicLauncher() },
onUninstallEpicLauncher = { uninstallEpicLauncher() },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
onAddGameExe = { path -> addGameExe(path) },
onAddedGamesChanged = { folders ->
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
},
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onGameRemoved = { g -> removeGame(g) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
com.droiddeck.launcher.frontend.SteamGridDb.setUserKey(this, key)
ui.post { sgdbUserKey = key.isNotBlank(); refreshAddedGames() }
}, "sgdb-key").start()
},
onStopGameFileSync = {
Thread({
GameFileSync.disable(this)
ui.post { gameSyncFolder = null }
}, "game-file-stop-sync").start()
},
onCopyGameLink = { g ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText(g.name, com.droiddeck.launcher.frontend.GameLaunchLink.uri(g.gameIdString)))
android.widget.Toast.makeText(this, R.string.game_link_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDesktop = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP))
},
onEmulator = { e -> launchProgram(e.program) },
onUserApp = { app ->
app.args.firstOrNull()?.let { Library.flatpakNames[it] = app.name }
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, app.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, app.args.toTypedArray()))
},
onBattleNet = { openBattleNet() },
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id)))
},
onRom = { g ->
val e = emulatorList.first { it.id == g.emulatorId }
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, e.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, Library.launchArgs(e.id, g.guestPath).toTypedArray()))
},
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
onRemoveDesktop = { removeDesktop() },
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
onFrameGenPick = { mode ->
FrameGen.set(this, mode)
frameGenLabel = FrameGen.label(this)
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
onBrowseFiles = { dir ->
startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)
.putExtra(com.droiddeck.launcher.files.FileManagerActivity.EXTRA_START_DIR, dir.absolutePath))
},
onLogs = {
val changed = SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
if (!changed) android.widget.Toast.makeText(
this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG,
).show()
logsEnabled = SessionPrefs.logsEnabled(this)
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
},
onGameDebugLogs = {
val changed = SessionPrefs.setGameDebugLogsEnabled(this, !SessionPrefs.gameDebugLogsEnabled(this))
if (!changed) android.widget.Toast.makeText(
this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG,
).show()
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
},
onVacDiagnostics = {
SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this))
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
},
onClearCachedApps = { on -> SessionPrefs.setClearCachedApps(this, on); clearCachedApps = on },
onMemoryHud = { value -> SessionPrefs.setMemoryHud(this, value); memoryHud = value },
onBackgroundAppLimit = { limit ->
SessionPrefs.setBackgroundAppLimit(this, limit); backgroundAppLimit = limit
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
},
onShareLogs = {
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
},
onClearLogs = {
Thread({
val cleared = runCatching { SessionArtifacts.clearAll(this) }.getOrDefault(0)
ui.post {
android.widget.Toast.makeText(this,
if (cleared == 0) getString(R.string.main_no_logs_to_clear) else resources.getQuantityString(R.plurals.main_logs_cleared, cleared, cleared),
android.widget.Toast.LENGTH_SHORT).show()
}
}, "clear-logs").start()
},
onResetSettings = {
if (SessionState.running) android.widget.Toast.makeText(this, R.string.setup_reset_settings_session_running, android.widget.Toast.LENGTH_LONG).show()
else showResetSettings = true
},
onOffline = {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherIcon = { style -> switchLauncherIcon(style) },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
recreate()
}
},
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onOrientation = { value ->
SessionPrefs.setOrientation(this, value)
orientation = value
},
onLauncherOrientation = { value ->
com.droiddeck.launcher.core.AppUiPrefs.setOrientation(this, value)
launcherOrientation = value
requestedOrientation = value
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
applyLauncherFullscreen()
},
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
onNavigated = { navRequest = null },
onStoresShowMature = { show -> SessionPrefs.setStoresShowMature(this, show); storesShowMature = show },
onStoresSort = { sort -> SessionPrefs.setStoresSort(this, sort); storesSort = sort },
onStoresView = { view -> SessionPrefs.setStoresView(this, view); storesView = view },
onLibraryChanged = { refreshAddedGames(); refresh() },
onDualScreenMode = { mode ->
SessionPrefs.setDualScreenMode(this, mode)
refreshSecondScreenDisplays()
},
onDualScreenLauncher = { on ->
SessionPrefs.setDualScreenLauncher(this, on)
refreshSecondScreenDisplays()
},
onDualScreenControls = { on ->
SessionPrefs.setDualScreenSessionControls(this, on)
dualScreenControls = on
},
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
com.droiddeck.launcher.ui.Motion.refresh(this)
},
onDownloadSource = { mode ->
DownloadSource.setMode(this, mode)
downloadSource = mode
},
onDownloadMirror = { host ->
DownloadSource.setMirror(this, host)
downloadMirror = host
},
onDownloadPrefix = { prefix ->
DownloadSource.setCustom(this, prefix)
downloadPrefix = DownloadSource.custom(this)
},
onDownloadProxy = { value ->
DownloadSource.setProxy(this, value)
downloadProxy = DownloadSource.proxyHostPort(this)
},
onHomeApp = { manageHomeApp() },
onSteamIntegration = ::openIntegration,
onIntegration = { integrationFocus = null; configureIntegration(it) },
onIntegrationAppInfo = ::openAppInfo,
onIntegrationLocation = ::openWifiDiscoverySettings,
onDismissIntegration = {
SteamIntegration.dismissSuggestion(this)
integrationState = readIntegration()
},
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
refreshHomeAppState()
},
onAndroidApp = { app, displayId -> launchAndroidApp(app, displayId) },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted
},
onRefreshPhantomStatus = { refreshPhantomStatus() },
onOpenDeveloperOptions = { displayId -> openDeveloperOptions(displayId) },
onWirelessAdbPair = { host, port, code, complete ->
Thread({
val result = runCatching {
kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@FrontEndActivity, host, port, code) }
}
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_pair_failed) }
ui.post { complete(error) }
}, "wireless-adb-pair").start()
},
onFindWirelessAdbPort = { host, complete ->
Thread({
val port = WirelessAdbFix.findConnectPort(this@FrontEndActivity, host)
ui.post { complete(port) }
}, "wireless-adb-discovery").start()
},
onWirelessAdbApply = { host, port, enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setChildProcessLimit(this@FrontEndActivity, host, port, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_command_failed) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
}
}, "wireless-adb-fix").start()
},
onSetPhantomProcessLimit = { enabled, complete ->
Thread({
val result = runCatching { WirelessAdbFix.setUsingSavedPairing(this@FrontEndActivity, enabled) }
val error = result.exceptionOrNull()?.let { it.localizedMessage ?: getString(R.string.main_wadb_unavailable) }
ui.post {
if (error == null) refreshPhantomStatus()
complete(error)
}
}, "wireless-adb-setting").start()
},
onCopyPhantomCommand = { enabled ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText(getString(R.string.main_adb_clip_label), PhantomProcessLimit.adbCommand(enabled)),
)
android.widget.Toast.makeText(this, R.string.main_adb_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onShowPhantomGate = {
refreshPhantomStatus()
showPhantomGate = true
},
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onStartPairingGuide = { displayId, toggleOnly -> PairingGuideActivity.start(this, displayId, toggleOnly) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
updates = updates.actions(),
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
onOpacity = { o -> ControllerPrefs.setOpacity(this, o); refreshController() },
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
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() },
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
),
),
page = page,
)
}
if (showRoms) RomsDialog(
path = romsDir,
onChoose = {
showRoms = false
pickRomsDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_roms), romsDir))
},
onClear = { SessionPrefs.setRomsDir(this, ""); romsDir = null; showRoms = false },
onDismiss = { showRoms = false },
)
showNonAdreno?.let { release ->
ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_title),
text = androidx.compose.ui.res.stringResource(R.string.main_non_adreno_text, com.droiddeck.launcher.core.DeviceSupport.gpuName(this@FrontEndActivity), release.size / 1e6),
confirm = androidx.compose.ui.res.stringResource(R.string.main_install_anyway),
onConfirm = { showNonAdreno = null; install(release) },
onDismiss = { showNonAdreno = null },
)
}
pendingSwitch?.let { next ->
ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.drawer_stop_title),
text = runningLabel ?: "",
confirm = androidx.compose.ui.res.stringResource(R.string.drawer_stop),
onConfirm = { pendingSwitch = null; startAfterSession(next) },
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
confirm = androidx.compose.ui.res.stringResource(R.string.store_remove),
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
lowSpace?.let { free ->
com.droiddeck.launcher.ui.LowSpaceDialog(
free,
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
onDismiss = { lowSpace = null },
)
}
com.droiddeck.launcher.ui.HandCameraHost(
check = placementCheck,
onAskPermission = { cameraPermission.launch(Manifest.permission.CAMERA) },
onPlay = { placementCheck = null; startSteamSession(steamVr = true) },
onClose = { placementCheck = null },
)
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
onDismiss = { showResetSettings = false },
)
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
}
androidx.activity.compose.BackHandler(compatLaunch != null) { cancelCompatLaunch() }
compatLaunch?.loading?.let { loading ->
if (!loading.ended) com.droiddeck.launcher.ui.VeilRing()
com.droiddeck.launcher.ui.LoadingOverlay(
loading.step, loading.percent, loading.elapsed, getString(R.string.load_compat_hint), loading.ended,
loading.topic, loading.readable, title = getString(R.string.load_compat_title),
onCancel = ::cancelCompatLaunch, onClose = ::cancelCompatLaunch,
flood = compatFlood, onFlooded = { compatFlood = null },
)
}
}
}
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
else {
restoreCompatLaunch(savedInstanceState)
pendingSwitch = savedIntent(savedInstanceState, KEY_PENDING_SWITCH)
savedIntent(savedInstanceState, KEY_SWITCHING_TO)?.let { awaitSessionEnd(it, savedInstanceState.getLong(KEY_SWITCH_DEADLINE)) }
}
val wanted = ArrayList<String>()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
SessionPrefs.setMicAsked(this)
}
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
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)
SessionArtifacts.prune(this)
}, "finish-abandoned").start()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.action == SteamIntegration.ACTION_SETUP) {
gameLinkReading = false
pendingGameLink = null
syncDualPanel()
readIntegrationIntent(intent)
return
}
readGameIntent(intent)
refresh()
startSteamAtStartupIfEnabled()
}
private fun startSteamAtStartupIfEnabled() {
if (intent.action != Intent.ACTION_MAIN) return
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
storage.refresh(this)
} else startSteamSession()
}
private fun playSteamVr() {
val tracking = com.droiddeck.launcher.glasses.Glasses.settings.value
if (!com.droiddeck.launcher.glasses.Glasses.state.value.usesSpatialControllers(tracking) &&
com.droiddeck.launcher.handtracking.HandTracking.wanted(this)) placementCheck = true
else startSteamSession(steamVr = true)
}
private fun startSteamSession(steamVr: Boolean = false) {
startSession(Intent(this, SessionActivity::class.java).putExtra(SessionActivity.EXTRA_STEAMVR, steamVr), steamSession = true)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
val outside = com.droiddeck.launcher.ui.DualScreenPanel.shownOn >= 0 &&
com.droiddeck.launcher.ui.DualScreenPanel.opensOutside(packageName, intent.component?.packageName ?: intent.`package` ?: intent.resolveActivity(packageManager)?.packageName)
if (outside) {
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
window.decorView.postDelayed({ if (hasWindowFocus()) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false) }, 1500)
}
try {
super.startActivityForResult(intent, requestCode, options)
} catch (e: RuntimeException) {
if (outside) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
throw e
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
applyLauncherFullscreen()
}
}
private fun applyLauncherFullscreen() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = if (launcherFullscreen) {
(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
} 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
}
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (compatLaunch != null) {
if (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
if (event.action == KeyEvent.ACTION_UP) cancelCompatLaunch()
return true
}
if (!compatLaunch!!.loading.ended && event.keyCode !in listOf(
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE)) return true
}
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
}
return true
}
return try {
super.dispatchKeyEvent(event)
} catch (e: IllegalStateException) {
if (e.message?.contains("active focus target") != true) throw e
android.util.Log.w("MainActivity", "key ${event.keyCode} dropped: ${e.message}")
true
}
}
override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
if (compatLaunch != null && !compatLaunch!!.loading.ended &&
com.droiddeck.launcher.input.PadBridge.isControllerMotionEvent(event)) return true
return super.dispatchGenericMotionEvent(event)
}
override fun onPause() {
compatLaunchVisible = false
if (android.os.Build.VERSION.SDK_INT >= 29) com.droiddeck.launcher.ui.DualScreenPanel.ownerPaused(taskId, true)
super.onPause()
}
override fun onResume() {
super.onResume()
compatLaunchVisible = true
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
com.droiddeck.launcher.ui.DualScreenPanel.ownerPaused(taskId, false)
orientation = SessionPrefs.orientation(this)
launcherOrientation = com.droiddeck.launcher.core.AppUiPrefs.orientation(this)
requestedOrientation = launcherOrientation
SteamIntegration.syncShortcutsListing(this)
refreshWifiDiscovery()
com.droiddeck.launcher.stores.StoresState.refresh(this)
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = { args -> openBattleNet(args) }
com.droiddeck.launcher.ui.Motion.refresh(this)
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
catalogFresh = false
if (!SessionState.running && !componentsQueueBusy && !LinuxRuntimeInstaller.isBusy()) {
componentsQueueBusy = true
Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, getString(R.string.main_applied, applied.joinToString(", ")), android.widget.Toast.LENGTH_LONG).show()
}
ui.post {
componentsQueueBusy = false
if (showComponents) components.refreshComponents()
autoUpdateRuntime()
}
}, "components-queue").start()
} else if (showComponents) components.refreshComponents()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
refreshSecondScreenDisplays()
refresh()
decky.deckyInstalled = DeckyManager.installed(this)
DeckyManager.syncCefMarker(this)
decky.deckySupervisor = DeckyManager.supervisorEnabled(this)
refreshAddedGames()
if (catalog == null) loadDesktopCatalog()
if (!busy) Thread({ checkCatalog() }, "catalog").start()
}
override fun onStart() {
super.onStart()
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
getSystemService(android.hardware.input.InputManager::class.java)?.registerInputDeviceListener(secondScreenTouchListener, ui)
launcherStarted = true
refreshSecondScreenDisplays()
getSystemService(android.content.pm.LauncherApps::class.java).registerCallback(packageCallback, ui)
HomeWidgets.startListening(this)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (HomeWidgets.onActivityResult(this, requestCode, resultCode)) return
@Suppress("DEPRECATION")
super.onActivityResult(requestCode, resultCode, data)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (slowUsbNever) SessionPrefs.setSlowUsbWarningOff(this)
saveCompatLaunch(outState)
pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) }
switchingTo?.let {
outState.putParcelable(KEY_SWITCHING_TO, it)
outState.putLong(KEY_SWITCH_DEADLINE, switchDeadline)
}
}
private fun savedIntent(state: Bundle, key: String): Intent? =
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
else @Suppress("DEPRECATION") state.getParcelable(key)
override fun onDestroy() {
cancelCompatLaunch()
updates.unregister()
super.onDestroy()
}
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
getSystemService(android.content.pm.LauncherApps::class.java).unregisterCallback(packageCallback)
HomeWidgets.stopListening(this)
ui.removeCallbacks(mediaRefresh)
com.droiddeck.launcher.stores.StoresState.libraryListener = null
com.droiddeck.launcher.stores.battlenet.BattleNetClient.opener = null
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
getSystemService(android.hardware.input.InputManager::class.java)?.unregisterInputDeviceListener(secondScreenTouchListener)
launcherStarted = false
com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
flood = null
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
}
private fun refreshHomeAppState() {
homeScreenEnabled = HomeApp.isHomeScreenEnabled(this)
homeAppSelected = homeScreenEnabled && HomeApp.isDefault(this)
defaultHomeLabel = HomeApp.defaultLabel(this)
refreshAndroidApps()
integrationState = readIntegration()
}
private fun refreshAndroidApps() {
if (!homeAppSelected) {
++androidAppsListing
androidApps = emptyList()
return
}
val seq = ++androidAppsListing
HomePins.refresh(this)
Thread({
val apps = runCatching { HomeApp.launchableApps(this) }.getOrDefault(emptyList())
ui.post { if (seq == androidAppsListing) androidApps = apps }
}, "home-apps").start()
}
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
dualScreenMode = SessionPrefs.dualScreenMode(this)
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
dualScreenDevice = com.droiddeck.launcher.input.DualScreen.anyDisplay(displayManager, ownDisplayId) != null
dualScreenLauncher = SessionPrefs.dualScreenLauncher(this)
dualScreenControls = SessionPrefs.dualScreenSessionControls(this)
val target = com.droiddeck.launcher.input.DualScreen.display(this, displayManager, ownDisplayId)
dualDisplayId = if (dualScreenLauncher) target?.displayId ?: -1 else -1
syncDualPanel()
}
private fun syncDualPanel() {
if (launchPending()) return
if (launcherStarted && dualDisplayId >= 0) com.droiddeck.launcher.ui.DualScreenPanel.show(this, dualDisplayId, taskId)
else com.droiddeck.launcher.ui.DualScreenPanel.hide(taskId)
}
private fun launchPending(): Boolean = gameLinkReading || (pendingGameLink != null && !gameLinkTried)
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
val onOwnScreen = displayId == null || displayId == (display?.displayId ?: Display.DEFAULT_DISPLAY)
if (onOwnScreen) com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, true)
try {
HomeApp.launch(this, app, displayId)
} catch (_: Exception) {
com.droiddeck.launcher.ui.DualScreenPanel.yieldTo(taskId, false)
val label = secondScreenDisplays.firstOrNull { it.id == displayId }?.label
val message = when {
displayId == null || displayId == Display.DEFAULT_DISPLAY -> getString(R.string.android_app_open_failed_primary, app.label)
label != null -> getString(R.string.android_app_open_failed_on, app.label, label)
else -> getString(R.string.android_app_open_failed_display, app.label, displayId)
}
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
}
}
private fun manageHomeApp() {
val request = HomeApp.roleRequestIntent(this)
if (request != null) {
homeRoleRequest.launch(request)
} else {
HomeApp.openSystemHomeSettings(this)
}
}
private fun loadDesktopCatalog() {
if (catalogLoading || catalog != null) return
catalogLoading = true
Thread({
val fetched = DesktopCatalog.fetch()
ui.post {
catalog = fetched
catalogLoading = false
refreshPackages()
}
}, "catalog-desktop").start()
}
private fun saveAction(label: String, work: () -> String) {
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> getString(R.string.main_action_failed, label, e.message ?: e.javaClass.simpleName) }
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "game-saves-action").start()
}
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
private fun refreshController() {
controllerSettings = ControllerPrefs.read(this)
}
@Composable
private fun MappingHost() {
val settings = controllerSettings ?: return
ControllerMappingPage(
mapping = settings.mapping,
onPick = { id, target -> ControllerPrefs.setTarget(this, id, target); refreshController() },
onReset = { ControllerPrefs.resetMapping(this); refreshController() },
onBack = { showMapping = false },
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
focusComponentsContent = focusContent
componentsGameKey = gameKey
if (tab != null) components.compComp = tab
showPerformance = false
showMapping = false
showComponents = true
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
protons.refreshProtons()
protons.checkUpdates()
}
private fun importComponent(uri: Uri) {
val name = displayNameOf(uri) ?: "imported.wcp"
components.componentAction(getString(R.string.comp_busy_importing)) {
val tmp = File(cacheDir, "component-import.wcp")
contentResolver.openInputStream(uri)?.use { input -> tmp.outputStream().use { input.copyTo(it) } } ?: error(getString(R.string.comp_cannot_read))
try { getString(R.string.comp_imported, ComponentsManager.importPackage(this, tmp, name).version) } finally { tmp.delete() }
}
}
@Composable
private fun ComponentsHost() {
ComponentsPage(
snapshot = components.compSnapshot,
catalog = components.compCatalog,
catalogAt = components.compCatalogAt,
protonId = components.compProton,
comp = components.compComp,
checking = components.compChecking,
busy = if (busy) stage else components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { components.chooseProton(it) },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_swapping)) { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_restoring)) { ComponentsManager.restore(this, pid, components.compComp, version) } } },
onCancelQueued = { components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_cancelling)) { ComponentsManager.cancelQueued(this, pid, components.compComp); getString(R.string.comp_swap_cancelled) } } },
onDeletePackage = { file -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deletePackage(this, file) } },
onDeleteOriginal = { version -> components.compProton?.let { pid -> components.componentAction(getString(R.string.comp_busy_deleting)) { ComponentsManager.deleteOriginal(this, pid, components.compComp, version) } } },
onDownload = { components.downloadComponent(it) },
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
initialGameKey = componentsGameKey,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
onPair = { key -> drivers.selectPair(key) },
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_linux))) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
),
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
onRemove = { id, dir -> protons.removeProton(id, dir) },
onRemoveOlder = { id -> protons.removeProton(id, older = true) },
),
protonOptions = {
com.droiddeck.launcher.ui.ProtonOptions(
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
)
},
)
androidx.compose.runtime.LaunchedEffect(components.compDownloadRevision) { components.refreshComponents() }
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
}
private fun refreshPackages() {
packageRows = catalog?.map { PackageRow(it.id, it.kind, it.notes) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
}
private fun openBattleNet(args: List<String> = emptyList()) {
if (pkgStage != null) {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return
}
val app = applicationContext
Thread({
val full = if (args.firstOrNull() == "--library") args else com.droiddeck.launcher.stores.battlenet.BattleNetClient.library(app) + args
ui.post {
if (isDestroyed) return@post
val next = Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, full.toTypedArray())
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
handToBattleNet(full) { pendingSwitch = next } -> {}
else -> pendingSwitch = next
}
}
}, "battlenet-open").start()
}
private fun handToBattleNet(args: List<String>, returnHome: Boolean = false, otherwise: () -> Unit): Boolean {
if (SessionState.mode != SessionService.MODE_RUN || SessionState.program != com.droiddeck.launcher.session.NoSteamLaunch.BATTLENET) return false
val library = args.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
val sessionLibrary = SessionState.programArgs.takeIf { it.firstOrNull() == "--library" }?.getOrNull(1)
if (library != null && sessionLibrary != null && library != sessionLibrary) return false
val request = if (library != null) args.drop(2) else args
val app = applicationContext
Thread({
val handed = request.isEmpty() || com.droiddeck.launcher.stores.battlenet.BattleNetClient.request(LinuxRuntime.sessionRoot(app), request)
ui.post {
if (isDestroyed) return@post
if (!handed) otherwise()
else startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.returnTo(returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
}
}, "battlenet-request").start()
return true
}
private fun launchProgram(path: String) {
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = getString(R.string.store_starting); pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, entry.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "install-pkg").start()
}
private fun removePackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = getString(if (entry.kind == "appimage") R.string.main_removing_named else R.string.main_forgetting_named, entry.name); pkgPercent = -1
Thread({
DesktopCatalog.remove(this, entry)
ui.post {
pkgStage = null; pkgId = null
refreshPackages()
refresh()
}
}, "remove-pkg").start()
}
@Composable
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
if (settingsMode != mode) return
com.droiddeck.launcher.ui.ModeSheetRows(
ModeSettings(
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
steamDownloadsInBackground = steamDownloadsInBackground,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null,
unfoldedControlsWithController = unfoldedControlsWithController,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backHold = backHold,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
shaderPrecache = if (mode == SessionService.MODE_STEAM) shaderPrecache else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
bootMovie = if (mode == SessionService.MODE_STEAM) bootMovie else null,
bootMovieSound = bootMovieSound,
bootMovieCustomName = bootMovieCustomName,
bootMovieImporting = bootMovieImporting,
bootMovieProgress = bootMovieProgress,
integration = if (mode == SessionService.MODE_STEAM) integrationState else null,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
deckyBranch = decky.deckyBranch,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
onIntegration = ::configureIntegration,
onIntegrationAppInfo = ::openAppInfo,
onIntegrationLocation = ::openWifiDiscoverySettings,
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onSteamDownloadsInBackground = { enabled ->
SessionPrefs.setSteamDownloadsInBackground(this, enabled)
steamDownloadsInBackground = enabled
},
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
onUnfoldedControlsWithController = { on ->
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
},
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onPicturePosition = { value -> SessionPrefs.setPicturePosition(this, value); picturePosition = value },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
mic = on
if (on && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 2)
}
},
onGameStorage = { path, label -> setGameStorage(path, label) },
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
},
onStorageDiagnostics = { on ->
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
storageDiagnostics = on
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id ->
SessionPrefs.setSteamClientArch(this, id)
steamClientArch = id
loadShaderPrecache(mode)
refresh()
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
decky.deckyInstalled = null
decky.deckySupervisor = false
},
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
},
onDismiss = {},
),
tab, extras,
)
}
@Composable
private fun PerformanceHost() {
PerformancePage(
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
fastSync = fastSync,
syncFallback = syncFallback,
fsyncFirst = fsyncFirst,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
ramCapMb = ramCapMb, vramCapMb = vramCapMb, zramClient = zramClient,
deviceRamMb = (android.app.ActivityManager.MemoryInfo()
.also((getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)::getMemoryInfo).totalMem / (1024 * 1024)).toInt(),
ramCapAvailable = com.droiddeck.launcher.core.ZramSupport.supported(),
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, cursorComposite = cursorComposite,
guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onFastSync = { on -> SessionPrefs.setFastSync(this, on); fastSync = on },
onSyncFallback = { on -> SessionPrefs.setSyncFallback(this, on); syncFallback = on },
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onRamCapMb = { mb -> SessionPrefs.setRamCapMb(this, mb); ramCapMb = mb },
onZramClient = { on -> SessionPrefs.setZramClient(this, on); zramClient = on },
onVramCapMb = { mb -> SessionPrefs.setVramCapMb(this, mb); vramCapMb = mb },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
onCursorComposite = { on -> SessionPrefs.setCursorComposite(this, on); cursorComposite = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
},
onGameCore = { core, on ->
gameCores = if (on) gameCores + core else gameCores - core
SessionPrefs.setGameCpus(this, CpuCores.format(gameCores))
},
onDismiss = { showPerformance = false },
)
}
private fun updateAddedGames(folders: List<String>, then: () -> Unit = {}) {
if (folders.isEmpty()) return
Thread({
val fresh = folders.mapNotNull { f ->
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
}
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 }
then()
}
}, "added-games-update").start()
}
private val steamArtRefreshes = HashMap<Int, Int>()
private fun refreshSteamGameArt(appId: Int) {
val version = (steamArtRefreshes[appId] ?: 0) + 1
steamArtRefreshes[appId] = version
Thread({
val fresh = com.droiddeck.launcher.frontend.Library.steamGames(this).firstOrNull { it.appId == appId } ?: return@Thread
ui.post {
if (steamArtRefreshes[appId] != version) return@post
steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it }
}
}, "steam-art-update").start()
}
private fun removeGame(g: Library.SteamGame) {
val folder = g.gameFiles ?: return
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
if (installed != null) {
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
return
}
if (g.library == Library.ADDED) {
removeAddedGame(folder.path, g.gameId, g.name)
return
}
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
Thread({
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
ui.post { refreshAddedGames(); refresh() }
}, "game-remove").start()
}
}
}
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
}
private fun refreshAddedGames() {
checkUsbLink()
sgdbKeyKnown()
fetchAddedGameArt()
}
private fun sgdbKeyKnown() {
Thread({
val set = com.droiddeck.launcher.frontend.SteamGridDb.userKey(this).isNotEmpty()
ui.post { sgdbUserKey = set }
}, "sgdb-key-state").start()
}
private fun fetchAddedGameArt() {
if (SessionPrefs.addedGamesArt(this) && !artFetchRunning) {
artFetchRunning = true
Thread({
try {
val games = com.droiddeck.launcher.frontend.AddedGames.scan(this)
if (com.droiddeck.launcher.frontend.AddedGameArt.fetchMissing(this, games)) ui.post { refresh() }
} finally {
artFetchRunning = false
}
}, "added-art").start()
}
}
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private var unfoldedControlsWithController by mutableStateOf(false)
private var shaderPrecacheGeneration = 0
private fun loadShaderPrecache(mode: String) {
val generation = ++shaderPrecacheGeneration
shaderPrecache = null
if (mode != SessionService.MODE_STEAM) return
Thread({
val precache = SessionPrefs.shaderPrecache(this)
ui.post {
if (!isDestroyed && generation == shaderPrecacheGeneration) shaderPrecache = precache
}
}, "shader-precache").start()
}
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamClientArch = SessionPrefs.steamClientArch(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
loadShaderPrecache(mode)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
hdrOn = SessionPrefs.hdr(this, mode)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this)
backHold = SessionPrefs.backHold(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
picturePosition = SessionPrefs.picturePosition(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode
Thread({
drivers.refreshDrivers()
val storage = GameStorage.options(this)
val deckyInstalled = if (mode == SessionService.MODE_STEAM) DeckyManager.installed(this) else null
val deckySupervisor = mode == SessionService.MODE_STEAM && DeckyManager.supervisorEnabled(this)
ui.post {
storageOptions = storage
if (mode == SessionService.MODE_STEAM) {
decky.deckyInstalled = deckyInstalled
decky.deckySupervisor = deckySupervisor
decky.refreshDecky()
}
fetchAddedGameArt()
}
}, "mode-settings").start()
}
private fun setGameStorage(path: String, label: String) {
if (path.isNotEmpty() && path != SessionPrefs.GAME_STORAGE_OFF) {
val problem = GameStorage.prepare(this, path)
if (problem != null) {
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, problem), android.widget.Toast.LENGTH_LONG).show()
return
}
}
SessionPrefs.setGameStorage(this, path, label)
gameStorage = path
checkUsbLink()
}
private fun checkUsbLink() {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) { ui.post { checkUsbLink() }; return }
val scan = ++usbScan
Thread({
val drive = runCatching { com.droiddeck.launcher.session.UsbLink.slowLibrary(this) }.getOrNull()
ui.post {
if (scan != usbScan || isFinishing) return@post
val due = drive?.takeIf { com.droiddeck.launcher.session.UsbLink.stillDue(this, it) }
if (due == null) { if (slowUsb != null) showSlowUsb(null) } else if (slowUsb?.key != due.key) showSlowUsb(due)
}
}, "usb-link").start()
}
private fun refreshCores() {
clientOverride = SessionPrefs.clientCpusOverride(this)
glThread = SessionPrefs.glThread(this)
noGlError = SessionPrefs.noGlError(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)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
fastSync = SessionPrefs.fastSync(this)
syncFallback = SessionPrefs.syncFallback(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
ramCapMb = SessionPrefs.ramCapMb(this)
zramClient = SessionPrefs.zramClient(this)
vramCapMb = SessionPrefs.vramCapMb(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
prootFastPath = SessionPrefs.prootFastPath(this)
cursorComposite = SessionPrefs.cursorComposite(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
}
private fun gameDebugLogsNeedsRestart(): Boolean =
SessionPrefs.logsEnabled(this) && SessionPrefs.gameDebugLogsEnabled(this) &&
!SessionPaths.currentSessionLogsRetained(this)
private fun refresh() {
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
}
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
if (showComponents) drivers.refreshDrivers()
addedGamesArt = SessionPrefs.addedGamesArt(this)
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
battleNetInstalled = com.droiddeck.launcher.runtime.BattleNet.installed(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
ui.post { autoUpdateRuntime() }
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
clearCachedApps = SessionPrefs.clearCachedApps(this)
adbPaired = WirelessAdbFix.hasSavedPairing(this)
memoryHud = SessionPrefs.memoryHud(this)
backgroundAppLimit = SessionPrefs.backgroundAppLimit(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: getString(R.string.main_running_program)
else -> "Steam"
} else null
val scanGeneration = ++libraryScanGeneration
val artRefreshesAtStart = HashMap(steamArtRefreshes)
if (shortcutPicker) shortcutLibraryScanning = true
if (!OfflineMode.enabled(this)) {
Thread({ com.droiddeck.launcher.session.FexCatalog.refresh(this) }, "fex-catalog").start()
}
Thread({
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
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 && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
}
}
val scannedArch = SessionPrefs.activeSteamClientArch(this)
val scannedX64 = scannedArch == "x86_64"
val games = if (ready || com.droiddeck.launcher.runtime.LinuxRuntime.hasRootfs(this)) Library.launchableGames(this, arch = scannedArch) else emptyList()
val all = games.distinctBy { it.gameId }
if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64)
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
val sweepDue = ready && (!componentSweepDone || endedSession != null)
if (sweepDue) {
componentSweepDone = true
endedSession?.let { componentSweepSession = it }
}
val restoreDue = synchronized(componentRestoreLock) {
val now = android.os.SystemClock.elapsedRealtime()
val due = ready && (lastComponentRestore == 0L || now - lastComponentRestore >= COMPONENT_RESTORE_MS)
if (due) {
lastComponentRestore = now
} else if (ready && !componentRestoreRetry) {
componentRestoreRetry = true
ui.postDelayed({ retryComponentRestore() }, (COMPONENT_RESTORE_MS - (now - lastComponentRestore)).coerceAtLeast(0L))
}
due
}
if (restoreDue || sweepDue) ui.postDelayed({
Thread({
if (restoreDue) runCatching { com.droiddeck.launcher.frontend.AutoComponents.restoreMissing(this) }.onFailure { Log.w(TAG, "components: restore failed", it) }
if (sweepDue) com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all)
}, "components-restore").start()
}, 10_000)
ui.post {
if (scanGeneration == libraryScanGeneration) {
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
steamGamesX64 = scannedX64
steamGames = all.map { g ->
val newer = current[g.appId]
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon, newer.logo) else g
}
shortcutLibraryScanning = false
handleGameLink(all)
}
}
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
val refreshed = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { emulatorList = refreshed }
}
}, "library").start()
}
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
}
if (installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()) { showNonAdreno = release; return }
install(release)
}
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
if (SessionState.installing != null && !SessionState.running) {
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
return false
}
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
return false
}
val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()
if (warn && available != null) { showNonAdreno = available; return false }
startActivity(intent)
return true
}
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(this)
adbPaired = WirelessAdbFix.hasSavedPairing(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
getString(
R.string.phantom_warning, PhantomProcessLimit.title(this, phantomProcessStatus),
PhantomProcessLimit.instructions(this, phantomProcessStatus), PhantomProcessLimit.adbCommand(),
)
} else null
}
private fun openDeveloperOptions(displayId: Int?) {
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
try {
if (displayId == null) startActivity(intent)
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
} catch (error: Exception) {
if (displayId != null) {
runCatching { startActivity(intent) }
android.widget.Toast.makeText(this, R.string.main_settings_main_screen, android.widget.Toast.LENGTH_LONG).show()
} else {
startActivity(Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
android.widget.Toast.makeText(this, R.string.main_open_dev_options, android.widget.Toast.LENGTH_LONG).show()
}
}
}
private fun runtimeChangesBlocked(): Boolean =
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null || componentsQueueBusy ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeDesktop() {
if (runtimeChangesBlocked()) return
val operation = LinuxRuntimeInstaller.beginMaintenance(this, getString(R.string.desktop_removing)) ?: return
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { listener ->
operation.run(listener) { progress -> DesktopCatalog.removeDesktop(this, progress) }
}
}
private fun resetSettings(gameProfiles: Boolean) {
if (SessionState.running) return
val scopes = steamGames.map { it.profileKey }
Thread({
val ok = com.droiddeck.launcher.session.SettingsReset.resetAll(this, gameProfiles, scopes)
ui.post {
if (isDestroyed) return@post
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
com.droiddeck.launcher.ui.Motion.refresh(this)
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
}
}, "reset-settings").start()
}
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
}
private fun install(release: LinuxRuntimeInstaller.Release) {
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
followRuntimeOperation { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
}
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.main_removing_runtime else R.string.store_starting)
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
ui.post { stage = s; percent = p }
})
ui.post {
busy = false
if (ok != null) failed = !ok
if (ok == false) LinuxRuntimeInstaller.removalError()?.let {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
}
}, "runtime-operation").start()
}
private fun autoUpdateRuntime() {
val release = available?.takeIf { catalogFresh } ?: return
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED) || runtimeChangesBlocked() || removalPending) return
if (release.version == installed || installed == null) return
if (com.droiddeck.launcher.runtime.RuntimeAutoUpdate.claim(this, installed, release.version)) {
Log.i(TAG, "runtime: updating $installed to ${release.version} by itself")
install(release)
}
}
private fun checkCatalog() {
val release = LinuxRuntimeInstaller.fetchRelease()
Log.i(TAG, "catalog: " + (release?.version ?: "unreachable"))
ui.post {
if (release == null) return@post
available = release
catalogFresh = true
autoUpdateRuntime()
}
}
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
private const val COMPONENT_RESTORE_MS = 60_000L
private const val LINK_PANEL_MS = 1500L
private const val SESSION_SWITCH_WAIT_MS = 30_000L
private const val SESSION_SWITCH_POLL_MS = 250L
private const val KEY_PENDING_SWITCH = "pending_switch"
private const val KEY_COMPAT_LAUNCH = "compat_launch"
private const val KEY_COMPAT_MENU_INSTALL = "compat_menu_install"
private const val KEY_COMPAT_PROBLEM = "compat_problem"
private const val KEY_SWITCHING_TO = "switching_to"
private const val KEY_SWITCH_DEADLINE = "switch_deadline"
private val SESSION_ENDED = setOf(SessionPhase.IDLE, SessionPhase.FAILED)
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
}
}