From 8d3bb7a70af9c3a51e0f9f6dbbd7758dd8980b00 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Tue, 14 Jul 2026 18:53:24 +0000 Subject: [PATCH 1/8] Split SteamService.kt into per-domain extension-function files (behavior-neutral, 9.1k->4.6k lines) --- .../stores/steam/service/SteamService.kt | 4893 +---------------- .../stores/steam/service/SteamServiceCloud.kt | 562 ++ .../steam/service/SteamServiceConnection.kt | 668 +++ .../service/SteamServiceControllerConfig.kt | 321 ++ .../stores/steam/service/SteamServiceDepot.kt | 443 ++ .../steam/service/SteamServiceDownloadApp.kt | 218 + .../service/SteamServiceDownloadFinalize.kt | 554 ++ .../steam/service/SteamServiceDownloadMain.kt | 1450 +++++ .../service/SteamServiceDownloadQueue.kt | 473 ++ .../steam/service/SteamServiceFriendsChat.kt | 624 +++ .../steam/service/SteamServiceInstall.kt | 290 + .../stores/steam/service/SteamServiceLogin.kt | 622 +++ .../steam/service/SteamServiceMetadata.kt | 304 + 13 files changed, 6722 insertions(+), 4700 deletions(-) create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceCloud.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceConnection.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceControllerConfig.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceDepot.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceDownloadApp.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceDownloadFinalize.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceDownloadMain.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceDownloadQueue.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceFriendsChat.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceInstall.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceLogin.kt create mode 100644 app/src/main/feature/stores/steam/service/SteamServiceMetadata.kt diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index f4c5a1c25..9849c2cdc 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -162,7 +162,7 @@ import javax.inject.Inject import kotlin.io.path.pathString import kotlin.time.Duration.Companion.seconds -private fun JSONArray?.toIntList(): List { +internal fun JSONArray?.toIntList(): List { val len = this?.length() ?: 0 if (len == 0) return emptyList() val out = ArrayList(len) @@ -199,44 +199,34 @@ class SteamService : Service() { @Inject lateinit var downloadingAppInfoDao: DownloadingAppInfoDao - private lateinit var notificationHelper: NotificationHelper + internal lateinit var notificationHelper: NotificationHelper - private var _unifiedFriends: SteamUnifiedFriends? = null + internal var _unifiedFriends: SteamUnifiedFriends? = null - private var _loginResult: LoginResult = LoginResult.Failed + internal var _loginResult: LoginResult = LoginResult.Failed - private var retryAttempt = 0 + internal var retryAttempt = 0 // Auto-reconnect coroutine for the C++ WN-Steam-Client session. - @Volatile private var connectJob: Job? = null + @Volatile internal var connectJob: Job? = null // Pending backoff-delayed reconnect scheduled by onWnDisconnected. - @Volatile private var reconnectJob: Job? = null + @Volatile internal var reconnectJob: Job? = null // Resets retryAttempt to 0 only after the session stays up STABLE_CONNECTION_MS; a flapping connection must not reset it (unbounded reconnect / battery drain). - @Volatile private var stableConnectionJob: Job? = null - @Volatile private var refreshTokenWatchdogJob: Job? = null + @Volatile internal var stableConnectionJob: Job? = null + @Volatile internal var refreshTokenWatchdogJob: Job? = null // App-lifecycle gating: while backgrounded with nothing needing Steam (no download, no running game) the session is suspended — disconnected, reconnect/PICS loops cancelled — to draw no power; wakes on foreground. Driven from PluviaApp lifecycle callbacks. - @Volatile private var appInForeground = true - @Volatile private var suspendedForBackground = false - - private fun suspensionReasonForDiag(): String { - val reasons = mutableListOf() - if (suspendedForBackground) reasons += "background-idle" - if (suspendedForBionic) reasons += "bionic-handoff" - if (!appInForeground) reasons += "app-bg" - if (isLoggingOut) reasons += "logging-out" - if (isStopping) reasons += "stopping" - return reasons.joinToString(",") - } + @Volatile internal var appInForeground = true + @Volatile internal var suspendedForBackground = false - @Volatile private var suspendedForBionic = false + @Volatile internal var suspendedForBionic = false // Cancellable timer deferring the background suspend by BACKGROUND_IDLE_GRACE_MS — see scheduleBackgroundSuspendCheck. - @Volatile private var backgroundIdleJob: Job? = null + @Volatile internal var backgroundIdleJob: Job? = null - private val appPicsChannel = + internal val appPicsChannel = Channel>( capacity = 1_000, onBufferOverflow = BufferOverflow.SUSPEND, @@ -245,7 +235,7 @@ class SteamService : Service() { }, ) - private val packagePicsChannel = + internal val packagePicsChannel = Channel>( capacity = 1_000, onBufferOverflow = BufferOverflow.SUSPEND, @@ -254,9 +244,9 @@ class SteamService : Service() { }, ) - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + internal val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val onEndProcess: (AndroidEvent.EndProcess) -> Unit = { + internal val onEndProcess: (AndroidEvent.EndProcess) -> Unit = { Companion.stop() } @@ -265,8 +255,8 @@ class SteamService : Service() { private val appTokens: ConcurrentHashMap = ConcurrentHashMap() - private var picsGetProductInfoJob: Job? = null - private var picsChangesCheckerJob: Job? = null + internal var picsGetProductInfoJob: Job? = null + internal var picsChangesCheckerJob: Job? = null private var friendCheckerJob: Job? = null private val _isPlayingBlocked = MutableStateFlow(false) @@ -279,24 +269,24 @@ class SteamService : Service() { ) val localPersona = _localPersona.asStateFlow() - private val _friendsList = MutableStateFlow>(emptyList()) + internal val _friendsList = MutableStateFlow>(emptyList()) val friendsList = _friendsList.asStateFlow() - private val _incomingChat = + internal val _incomingChat = MutableSharedFlow>( replay = 32, extraBufferCapacity = 256, ) val incomingChat = _incomingChat.asSharedFlow() - private val _unreadCounts = MutableStateFlow>(emptyMap()) + internal val _unreadCounts = MutableStateFlow>(emptyMap()) val unreadCounts = _unreadCounts.asStateFlow() - private val _recentChats = MutableStateFlow>(emptyMap()) + internal val _recentChats = MutableStateFlow>(emptyMap()) val recentChats = _recentChats.asStateFlow() - private val activeConversations = java.util.concurrent.ConcurrentHashMap() - private var messagePollerJob: Job? = null + internal val activeConversations = java.util.concurrent.ConcurrentHashMap() + internal var messagePollerJob: Job? = null data class ManifestSizes( val installSize: Long = 0L, @@ -319,27 +309,27 @@ class SteamService : Service() { private const val STABLE_CONNECTION_MS = 60_000L // Reconnect backoff cap — even a permanently-flapping connection reconnects at most once per this interval. - private const val RECONNECT_BACKOFF_CAP_MS = 5 * 60_000L + internal const val RECONNECT_BACKOFF_CAP_MS = 5 * 60_000L // connectAndLogon gives up after this many consecutive failed bring-up attempts (exponential backoff) instead of retrying a doomed logon forever. - private const val CONNECT_LOGON_MAX_ATTEMPTS = 8 + internal const val CONNECT_LOGON_MAX_ATTEMPTS = 8 - private const val REFRESH_TOKEN_ROTATION_THRESHOLD_DAYS = 7 - private const val REFRESH_TOKEN_ROTATION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L + internal const val REFRESH_TOKEN_ROTATION_THRESHOLD_DAYS = 7 + internal const val REFRESH_TOKEN_ROTATION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L // Grace before a backgrounded session may suspend, so a brief app-switch doesn't thrash disconnect/reconnect (battery drain). While connection-critical work runs the check repeats once per interval. - private const val BACKGROUND_IDLE_GRACE_MS = 60_000L + internal const val BACKGROUND_IDLE_GRACE_MS = 60_000L // Stay fully offline this long after a PlanW game closes so Steam reaps the launcher's games-played registration (else next launch hits AlreadyRunning 0x10). - private const val WN_PLANW_REAP_OFFLINE_MS = 10_000L + internal const val WN_PLANW_REAP_OFFLINE_MS = 10_000L const val INVALID_APP_ID: Int = Int.MAX_VALUE const val INVALID_PKG_ID: Int = Int.MAX_VALUE - private const val STEAM_CONTROLLER_CONFIG_FILENAME = "steam_controller_config.vdf" - private const val DOWNLOAD_INFO_DIR = ".DownloadInfo" - private const val DOWNLOAD_INFO_FILE = "depot_bytes.json" - private const val LEGACY_DOWNLOAD_INFO_FILE = "bytes_downloaded.txt" - private const val COMPONENTS_BASE_URL = "https://github.com/maxjivi05/Components/releases/download/Components" + internal const val STEAM_CONTROLLER_CONFIG_FILENAME = "steam_controller_config.vdf" + internal const val DOWNLOAD_INFO_DIR = ".DownloadInfo" + internal const val DOWNLOAD_INFO_FILE = "depot_bytes.json" + internal const val LEGACY_DOWNLOAD_INFO_FILE = "bytes_downloaded.txt" + internal const val COMPONENTS_BASE_URL = "https://github.com/maxjivi05/Components/releases/download/Components" @Volatile private var startupMetadataRepairJob: Job? = null @@ -371,53 +361,6 @@ class SteamService : Service() { return if (cachedAchievementsAppId == appId) cachedAchievements ?: emptyList() else emptyList() } - // Overlay the real unlock state onto schema-derived achievement definitions. - private suspend fun mergeAchievementUnlockState( - appId: Int, - achievements: List, - nameToBlockBit: Map>, - ): List { - if (achievements.isEmpty() || nameToBlockBit.isEmpty()) return achievements - val statsJson = withWnSession { s -> s.getUserStatsFull(appId) } ?: return achievements - val blockUnlock = HashMap>() - runCatching { - val obj = JSONObject(statsJson) - if (obj.optInt("eresult", 2) != EResult.OK.code()) return achievements - val blocks = obj.optJSONArray("achievementBlocks") ?: return achievements - for (i in 0 until blocks.length()) { - val b = blocks.getJSONObject(i) - val times = b.optJSONArray("unlockTimes") - val list = ArrayList(times?.length() ?: 0) - for (j in 0 until (times?.length() ?: 0)) list.add(times!!.getLong(j)) - blockUnlock[b.optInt("achievementId")] = list - } - } - if (blockUnlock.isEmpty()) return achievements - val unlockedTotal = blockUnlock.values.sumOf { times -> times.count { it != 0L } } - Timber.i("Achievements: app=$appId merged unlock state ($unlockedTotal unlocked across ${blockUnlock.size} blocks)") - return achievements.map { ach -> - val mapped = nameToBlockBit[ach.name] ?: return@map ach - val t = blockUnlock[mapped.first]?.getOrNull(mapped.second) ?: 0L - if (t != 0L) ach.copy(unlocked = true, unlockTimestamp = t.toInt()) else ach.copy(unlocked = false) - } - } - - private fun downloadUrlsFor(fileName: String): List { - val alternate = - when (fileName) { - "steam-token.tzst" -> "steam-token-r2.tzst" - else -> null - } - return if (alternate != null) { - listOf( - "$COMPONENTS_BASE_URL/$fileName", - "$COMPONENTS_BASE_URL/$alternate", - ) - } else { - listOf("$COMPONENTS_BASE_URL/$fileName") - } - } - fun pauseAll() { DownloadCoordinator.runOnScope { DownloadCoordinator.pauseAll() } } @@ -526,31 +469,7 @@ class SteamService : Service() { DownloadCoordinator.blockingTick() } - private val downloadJobs = ConcurrentHashMap() - - private fun notifyDownloadStarted(appId: Int) { - PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, true)) - } - - private fun notifyDownloadStopped(appId: Int) { - PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) - } - - private fun removeDownloadJob( - appId: Int, - forceRemove: Boolean = false, - ) { - if (forceRemove) { - val removed = downloadJobs.remove(appId) - if (removed != null) { - notifyDownloadStopped(appId) - } - } else { - notifyDownloadStopped(appId) - } - checkQueue() - Unit - } + internal val downloadJobs = ConcurrentHashMap() fun clearCompletedDownloads() { clearCompletedDownloadsInternal(dispatchQueueAfterClear = true) @@ -562,219 +481,6 @@ class SteamService : Service() { clearCompletedDownloadsInternal(dispatchQueueAfterClear = false) } - private fun clearCompletedDownloadsInternal(dispatchQueueAfterClear: Boolean) { - val toRemove = - downloadJobs - .filterValues { - val status = it.getStatusFlow().value - status == DownloadPhase.COMPLETE || - status == DownloadPhase.CANCELLED || - status == DownloadPhase.FAILED - }.keys - toRemove.forEach { appId -> - val removed = downloadJobs.remove(appId) - if (removed != null) { - notifyDownloadStopped(appId) - } - } - if (dispatchQueueAfterClear && toRemove.isNotEmpty()) { - checkQueue() - } - } - - /** Returns true if there is an incomplete download on disk (in-progress marker or actively downloading). */ - private fun hasPartialDownloadFiles(appDirPath: String): Boolean { - val appDir = File(appDirPath) - if (!appDir.exists()) return false - - val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) - if (persistenceFile.exists() && persistenceFile.length() > 0L) { - return true - } - - // Complete marker present and no persisted resume file → fully installed, not a resumable partial. - if (MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER)) { - return false - } - - if (MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)) { - return true - } - - val rootFiles = appDir.listFiles() ?: return false - return rootFiles.any { file -> - if (file.name != DOWNLOAD_INFO_DIR) { - true - } else { - val nestedFiles = file.listFiles().orEmpty() - nestedFiles.any { nested -> - nested.name != DOWNLOAD_INFO_FILE && nested.name != LEGACY_DOWNLOAD_INFO_FILE - } - } - } - } - - private fun inferResumeDlcAppIds( - appId: Int, - appDirPath: String, - ): List { - // Try to recover selected DLCs from persisted depot progress when metadata row is missing. - return runCatching { - val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) - if (!persistenceFile.exists() || !persistenceFile.canRead()) return@runCatching emptyList() - - val text = persistenceFile.readText().trim() - if (text.isEmpty()) return@runCatching emptyList() - - val persistedDepotIds = mutableSetOf() - val json = JSONObject(text) - for (key in json.keys()) { - val depotId = key.toIntOrNull() ?: continue - persistedDepotIds.add(depotId) - } - if (persistedDepotIds.isEmpty()) return@runCatching emptyList() - - val context = instance!!.applicationContext - val container = - if (ContainerUtils.hasContainer(context, "STEAM_$appId")) { - ContainerUtils.getContainer(context, "STEAM_$appId") - } else { - null - } - val containerLanguage = container?.language ?: PrefManager.containerLanguage - val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage) - depots - .asSequence() - .filter { (depotId, _) -> depotId in persistedDepotIds } - .map { (_, depot) -> depot.dlcAppId } - .filter { it != INVALID_APP_ID } - .distinct() - .toList() - }.getOrElse { - emptyList() - } - } - - private fun hasPersistedDepotResumeMetadata(appDirPath: String): Boolean { - return runCatching { - val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) - if (!persistenceFile.exists() || !persistenceFile.canRead()) return@runCatching false - - val text = persistenceFile.readText().trim() - if (text.isEmpty()) return@runCatching false - - val json = JSONObject(text) - json.keys().asSequence().any { key -> key.toIntOrNull() != null } - }.getOrElse { - false - } - } - - private fun clearPersistedProgressSnapshot(appDirPath: String) { - val persistenceDir = File(appDirPath, DOWNLOAD_INFO_DIR) - val persistenceFile = File(persistenceDir, DOWNLOAD_INFO_FILE) - if (persistenceFile.exists()) { - persistenceFile.delete() - } - val legacyFile = File(persistenceDir, LEGACY_DOWNLOAD_INFO_FILE) - if (legacyFile.exists()) { - legacyFile.delete() - } - if (persistenceDir.exists() && persistenceDir.list().isNullOrEmpty()) { - persistenceDir.delete() - } - } - - private fun clearFailedResumeState(appId: Int) { - val appDirPath = getAppDirPath(appId) - clearPersistedProgressSnapshot(appDirPath) - runBlocking(Dispatchers.IO) { - instance?.downloadingAppInfoDao?.deleteApp(appId) - } - } - - private fun deleteRecursivelyWithRetries( - target: File, - maxAttempts: Int = 5, - delayMs: Long = 250L, - ): Boolean { - if (!target.exists()) return true - - repeat(maxAttempts) { - if (target.deleteRecursively()) return true - try { - Thread.sleep(delayMs) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - return !target.exists() - } - } - - return !target.exists() - } - - private fun cleanupSteamAppCacheDirs(appId: Int) { - StoreArtworkCache.deleteGame(PluviaApp.instance, "steam", appId.toString()) - steamAppCacheDirs(appId).forEach { dir -> - if (!dir.exists()) return@forEach - Timber.i("Deleting Steam cache folder for appId $appId: ${dir.absolutePath}") - if (!deleteRecursivelyWithRetries(dir)) { - Timber.w("Failed to fully delete Steam cache folder for appId $appId: ${dir.absolutePath}") - } - } - } - - private fun steamAppCacheDirs(appId: Int): List { - val appIdString = appId.toString() - val dirs = linkedMapOf() - - fun addDir(dir: File) { - val normalized = - try { - dir.canonicalFile - } catch (_: IOException) { - dir.absoluteFile - } - dirs[normalized.path] = normalized - } - - fun addSteamAppsRoot(root: File) { - addDir(File(root, "staging/$appIdString")) - addDir(File(root, "shadercache/$appIdString")) - } - - fun addInstallRoot(installRoot: String) { - if (installRoot.isBlank()) return - val root = File(installRoot) - val steamAppsRoot = - if (root.name.equals("common", ignoreCase = true)) { - root.parentFile ?: root - } else { - root - } - addSteamAppsRoot(steamAppsRoot) - } - - addDir(File(defaultAppStagingPath, appIdString)) - if (defaultStoragePath.isNotBlank()) { - addDir(File(defaultStoragePath, "Steam/steamapps/shadercache/$appIdString")) - } - - addInstallRoot(internalAppInstallPath) - addInstallRoot(externalAppInstallPath) - addInstallRoot(defaultAppInstallPath) - allInstallPaths.forEach(::addInstallRoot) - - return dirs.values.toList() - } - - private fun steamProtectedInstallRoots(): List = - listOf( - internalAppInstallPath, - externalAppInstallPath, - defaultAppInstallPath, - ).filter { it.isNotBlank() }.distinct() - fun hasPartialDownload(appId: Int): Boolean { if (isAppInstalled(appId)) return false @@ -807,30 +513,7 @@ class SteamService : Service() { return false } - private val syncInProgressApps = ConcurrentHashMap() - - private fun getSyncFlag(appId: Int): AtomicBoolean { - val existing = syncInProgressApps[appId] - if (existing != null) { - return existing - } - val created = AtomicBoolean(false) - val prior = syncInProgressApps.putIfAbsent(appId, created) - return prior ?: created - } - - private fun tryAcquireSync(appId: Int): Boolean { - val flag = getSyncFlag(appId) - return flag.compareAndSet(false, true) - } - - private fun releaseSync(appId: Int) { - val flag = syncInProgressApps[appId] - flag?.set(false) - if (flag != null && !flag.get()) { - syncInProgressApps.remove(appId, flag) - } - } + internal val syncInProgressApps = ConcurrentHashMap() // Track whether a game is currently running to prevent premature service stop @JvmStatic @@ -850,23 +533,23 @@ class SteamService : Service() { var isImporting: Boolean = false var isStopping: Boolean = false - private set + internal set private val _isConnectedFlow = MutableStateFlow(false) val isConnectedFlow = _isConnectedFlow.asStateFlow() /** Pure getter over [isConnectedFlow] — do not read the live socket here; only callbacks may write the flow, else concurrent reads flicker the UI during CM reconnect gaps. */ var isConnected: Boolean get() = _isConnectedFlow.value - private set(value) { + internal set(value) { _isConnectedFlow.value = value } var isRunning: Boolean = false - private set + internal set var isLoggingOut: Boolean = false - private set + internal set - private val _isLoggedInFlow = MutableStateFlow(false) + internal val _isLoggedInFlow = MutableStateFlow(false) val isLoggedInFlow = _isLoggedInFlow.asStateFlow() // Master chat switch (default on). When off, the Steam session stays logged on for @@ -892,13 +575,13 @@ class SteamService : Service() { get() = !isLoggingOut && _isLoggedInFlow.value var isWaitingForQRAuth: Boolean = false - private set + internal set // In-flight credentials/QR auth session; held in the companion so stopLoginWithQr() can cancel anywhere; cleared on success (ownership → wnSession) or failure. private var wnAuthSession: WnSteamSession? = null // Long-lived session carrying the post-logon CM connection — the sole Steam connection, from refresh-token acquisition through logout. @Volatile: logOut() reads on UI thread while the auth flow writes on IO. - @Volatile private var wnSession: WnSteamSession? = null + @Volatile internal var wnSession: WnSteamSession? = null @JvmStatic fun wnSessionStateForDiag(): Int = wnSession?.state() ?: -1 @@ -908,66 +591,22 @@ class SteamService : Service() { instance?.suspensionReasonForDiag() ?: "no-service" // True once onWnLoggedOn ran for the current wnSession; reset on disconnect/teardown so reconnect re-runs it. Guards the state observer double-firing. - @Volatile private var wnLoggedOnHandled = false + @Volatile internal var wnLoggedOnHandled = false // Serializes session bring-up: concurrent callers racing into bringUpWnSession() spin up separate CM sessions that kick each other (ClientLoggedOff eresult=34) since Steam allows one per account-instance. Only bring-up is gated; reusing a logged-on session stays lock-free. private val wnSessionBringUpMutex = kotlinx.coroutines.sync.Mutex() @Volatile internal var logonGateUntilMs: Long = 0L - private set + internal set @Volatile internal var lastLogonFailureEresult: Int = 0 - private set + internal set @Volatile internal var consecutiveLogonFailures: Int = 0 - private set - - internal fun recordLogonSuccess() { - if (logonGateUntilMs != 0L || consecutiveLogonFailures != 0) { - Timber.i("logon gate cleared (was open until ${logonGateUntilMs}, " + - "$consecutiveLogonFailures prior failure(s))") - } - logonGateUntilMs = 0L - lastLogonFailureEresult = 0 - consecutiveLogonFailures = 0 - } - - internal fun recordLogonFailure(eresult: Int) { - if (eresult == 1) return - if (eresult == 67 || eresult == 88) return - consecutiveLogonFailures += 1 - lastLogonFailureEresult = eresult - val backoffMs = when (consecutiveLogonFailures) { - 1 -> 30_000L - 2 -> 120_000L - 3 -> 300_000L - 4 -> 900_000L - 5 -> 1_800_000L - else -> 3_600_000L - } - logonGateUntilMs = System.currentTimeMillis() + backoffMs - Timber.w("logon gate engaged: EResult=$eresult, " + - "consecutive=$consecutiveLogonFailures, backoff=${backoffMs / 1000}s") - } + internal set /** Live Kotlin facade over wnSession's native library store; created with the session, torn down by teardownPriorWnSession()/logOut(). Collect `snapshots` to observe library changes; `current` is the latest value. */ @Volatile var wnLibrary: WnLibraryStore? = null - private set - @Volatile private var wnLibraryMirrorJob: Job? = null - - /** Tears down any prior wnSession at the top of every login entry so a retry doesn't leak the native handle (transport thread + heartbeat + TLS socket). */ - private fun teardownPriorWnSession() { - val prior = wnSession - wnSession = null - wnLoggedOnHandled = false - wnLibraryMirrorJob?.cancel() - wnLibraryMirrorJob = null - wnLibrary?.stopObserving() - wnLibrary = null - if (prior != null) { - Timber.i("Tearing down prior wnSession before relogin") - try { prior.disconnect() } catch (_: Throwable) {} - try { prior.close() } catch (_: Throwable) {} - } - } + internal set + @Volatile internal var wnLibraryMirrorJob: Job? = null /** Keeps [isConnectedFlow] in sync with the live socket; only touches the connected flow (writing isLoggedInFlow from a read flipped the UI to "signed out" on CM load-balancing). */ fun syncStates() { @@ -1360,52 +999,6 @@ class SteamService : Service() { return installedDlcAppIds.sorted() } - private fun getInstalledSelectableDlcAppIds(appId: Int): Set = - getSelectableDlcAppsOf(appId) - .mapNotNull { dlcApp -> - val dlcInfo = getInstalledApp(dlcApp.id) - if (dlcInfo?.isDownloaded == true) dlcApp.id else null - }.toSet() - - private fun getTrustedInstalledAppInfo(appId: Int): AppInfo? { - val appInfo = getInstalledApp(appId) ?: tryRecoverInstalledAppInfo(appId) - if (appInfo?.isDownloaded != true) return null - - val dirPath = getAppDirPath(appId) - val dir = File(dirPath) - if (!dir.isDirectory) return null - if (!MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER)) return null - - return appInfo - } - - private fun tryRecoverInstalledAppInfo(appId: Int): AppInfo? { - val dirPath = getAppDirPath(appId) - if (dirPath.isBlank()) return null - val hasCompleteMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - val hasInProgressMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - if (!hasCompleteMarker || hasInProgressMarker) return null - - val dir = File(dirPath) - if (!dir.exists() || !dir.isDirectory) return null - - val downloadedDepotIds = runCatching { getMainAppDepots(appId).keys.sorted() }.getOrDefault(emptyList()) - val installedDlcAppIds = getInstalledSelectableDlcAppIds(appId) - val recovered = - AppInfo( - id = appId, - isDownloaded = true, - downloadedDepots = downloadedDepotIds, - dlcDepots = installedDlcAppIds.sorted(), - ) - - runBlocking(Dispatchers.IO) { - PluviaDatabase.getInstance().appInfoDao().insert(recovered) - } - Timber.i("Recovered Steam installed metadata from disk for appId=$appId at $dirPath") - return recovered - } - fun repairInstalledMetadataFromDisk(): Int { return runBlocking(Dispatchers.IO) { val db = PluviaDatabase.getInstance() @@ -1427,54 +1020,6 @@ class SteamService : Service() { } } - private fun countCompletedInstallMarkers(maxCount: Int = Int.MAX_VALUE): Int { - var count = 0 - for (basePath in allInstallPaths) { - val baseDir = File(basePath) - val appDirs = baseDir.listFiles() ?: continue - for (appDir in appDirs) { - if (!appDir.isDirectory) continue - val hasCompleteMarker = File(appDir, Marker.DOWNLOAD_COMPLETE_MARKER.fileName).exists() - if (!hasCompleteMarker) continue - - val hasInProgressMarker = File(appDir, Marker.DOWNLOAD_IN_PROGRESS_MARKER.fileName).exists() - if (hasInProgressMarker) continue - - count++ - if (count >= maxCount) return count - } - } - return count - } - - private fun shouldRepairInstalledMetadata(): Boolean { - val db = - runCatching { PluviaDatabase.getInstance() }.getOrElse { - Timber.e(it, "Failed to access database for startup metadata repair gate") - return false - } - - val knownAppCount = - runBlocking(Dispatchers.IO) { - runCatching { db.steamAppDao().getAllAppIds().size }.getOrElse { - Timber.e(it, "Failed to load Steam app ids for startup metadata repair gate") - return@runBlocking 0 - } - } - if (knownAppCount == 0) return false - - val installedDbCount = - runBlocking(Dispatchers.IO) { - runCatching { db.appInfoDao().getAllInstalledAppIds().size }.getOrElse { - Timber.e(it, "Failed to load installed Steam app ids for startup metadata repair gate") - return@runBlocking 0 - } - } - - val diskInstallCount = countCompletedInstallMarkers(maxCount = installedDbCount + 1) - return diskInstallCount > installedDbCount - } - fun maybeRepairInstalledMetadataOnStartup(context: Context) { val appContext = context.applicationContext if (!hasStoredCredentials(appContext)) return @@ -1764,288 +1309,12 @@ class SteamService : Service() { return map } - private fun getEntitledDepotIds(packageId: Int): Set? { - if (packageId == INVALID_PKG_ID) return null - val depotIds = - runBlocking(Dispatchers.IO) { - instance - ?.licenseDao - ?.findLicense(packageId) - ?.depotIds - .orEmpty() - } - return depotIds.takeIf { it.isNotEmpty() }?.toSet() - } - - private fun isDepotEntitled( - depotId: Int, - depot: DepotInfo, - entitledDepotIds: Set?, - ): Boolean { - // Explicit package grant wins (covers low-violence / regional packages). - if (entitledDepotIds != null && depotId in entitledDepotIds) return true - // Low-violence content needs an explicit grant; Steam denies its depot key otherwise. - if (depot.lowViolence) return false - if (entitledDepotIds == null) return true - if (depot.sharedInstall || depot.depotFromApp != INVALID_APP_ID) return true - // Package depot lists are often incomplete for base content; owning the app entitles it. - return depot.dlcAppId == INVALID_APP_ID - } - - private fun getSelectedDownloadDepots( - appId: Int, - userSelectedDlcAppIds: Collection, - preferredLanguage: String = PrefManager.containerLanguage, - branch: String = "public", - ): Map { - val downloadableDepots = getDownloadableDepots(appId, preferredLanguage) - if (downloadableDepots.isEmpty()) return emptyMap() - - val selectedDlcIds = userSelectedDlcAppIds.toSet() - val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id }.toSet() - val mainDepots = getMainAppDepots(appId) - val appInfoForGrouping = getAppInfoOf(appId) - val groupedBaseDlcDepotIds = - appInfoForGrouping - ?.let { getGroupedBaseAppDlcContentDepotIds(it) } - .orEmpty() - // Base-package-entitled depots are always base content; never let positional DLC grouping drop them (a DLC marker preceding base depots would zero the game's size, e.g. DMC5 601151/2). - val baseEntitledDepotIds = - appInfoForGrouping?.packageId?.let { getEntitledDepotIds(it) }.orEmpty() - - val selectedMainDepots = - mainDepots.filter { (depotId, depot) -> - ( - depot.dlcAppId == INVALID_APP_ID && - (depotId !in groupedBaseDlcDepotIds || depotId in baseEntitledDepotIds) - ) || - (depot.dlcAppId in selectedDlcIds && resolveDepotManifestInfo(depot, branch) != null) - } + getSelectedBaseAppDlcContentDepots(appId, selectedDlcIds, preferredLanguage, branch) - - val selectedDlcDepots = - downloadableDepots.filter { (depotId, depot) -> - depotId !in selectedMainDepots && - depot.dlcAppId in selectedDlcIds && - depot.dlcAppId in indirectDlcAppIds && - resolveDepotManifestInfo(depot, branch) != null - } - - return selectedMainDepots + selectedDlcDepots - } - - private fun getGroupedBaseAppDlcContentDepotIds(appInfo: SteamApp): Set { - return getGroupedBaseAppDlcDepots(appInfo).map { it.depotId }.toSet() - } - - private fun getGroupedBaseAppDlcIds( - appInfo: SteamApp, - preferredLanguage: String = PrefManager.containerLanguage, - has64Bit: Boolean = - appInfo.depots.values.any { - it.osArch == OSArch.Arch64 && - (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) - }, - ): Set { - return getGroupedBaseAppDlcDepots(appInfo) - .filter { groupedDepot -> - filterForDownloadableDepots(groupedDepot.depot, has64Bit, preferredLanguage, ownedDlc = null) - }.map { it.dlcAppId } - .toSet() - } - - private data class GroupedBaseAppDlcDepot( + internal data class GroupedBaseAppDlcDepot( val depotId: Int, val dlcAppId: Int, val depot: DepotInfo, ) - private fun getGroupedBaseAppDlcDepots(appInfo: SteamApp): List { - val declaredDlcIds = - ( - appInfo.dlcAppIds.asSequence() + - appInfo.depots.values.asSequence() - .map { it.dlcAppId } - .filter { it != INVALID_APP_ID } - ).toSet() - if (declaredDlcIds.isEmpty()) return emptyList() - - val depotIds = mutableListOf() - var activeDlcAppId: Int? = null - for ((depotId, depot) in appInfo.depots) { - val isDlcMarkerDepot = - depotId in declaredDlcIds && - depot.manifests.isEmpty() - if (isDlcMarkerDepot) { - activeDlcAppId = depotId - continue - } - - val dlcAppId = activeDlcAppId - if (dlcAppId != null && depot.dlcAppId == INVALID_APP_ID) { - depotIds += GroupedBaseAppDlcDepot(depotId, dlcAppId, depot) - } - } - - return depotIds - } - - private fun getSelectedBaseAppDlcContentDepots( - appId: Int, - selectedDlcAppIds: Collection, - preferredLanguage: String = PrefManager.containerLanguage, - branch: String = "public", - ): Map { - if (selectedDlcAppIds.isEmpty()) return emptyMap() - val appInfo = getAppInfoOf(appId) ?: return emptyMap() - val selectedDlcIds = selectedDlcAppIds.toSet() - val declaredDlcIds = - ( - appInfo.dlcAppIds.asSequence() + - appInfo.depots.values.asSequence() - .map { it.dlcAppId } - .filter { it != INVALID_APP_ID } - ).toSet() - if (declaredDlcIds.isEmpty()) return emptyMap() - - val has64Bit = - appInfo.depots.values.any { - it.osArch == OSArch.Arch64 && - (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) - } - - val selectedDepots = linkedMapOf() - var activeDlcAppId: Int? = null - for ((depotId, depot) in appInfo.depots) { - val isDlcMarkerDepot = - depotId in declaredDlcIds && - depot.manifests.isEmpty() - if (isDlcMarkerDepot) { - activeDlcAppId = depotId.takeIf { it in selectedDlcIds } - continue - } - - val selectedDlcAppId = - when { - depot.dlcAppId in selectedDlcIds -> depot.dlcAppId - depot.dlcAppId == INVALID_APP_ID -> activeDlcAppId - else -> null - } ?: continue - - val selectedDepot = - if (depot.dlcAppId == selectedDlcAppId) { - depot - } else { - depot.copy(dlcAppId = selectedDlcAppId) - } - - if (!filterForDownloadableDepots(selectedDepot, has64Bit, preferredLanguage, ownedDlc = null)) continue - if (resolveDepotManifestInfo(selectedDepot, branch) == null) continue - selectedDepots[depotId] = selectedDepot - } - - if (selectedDepots.isNotEmpty()) { - Timber.i( - "Recovered base-app DLC content depots for appId=$appId " + - "selectedDlcAppIds=${selectedDlcIds.sorted()} " + - "depotIdsByDlc=${selectedDepots.values.groupBy({ it.dlcAppId }, { it.depotId })}", - ) - } - return selectedDepots - } - - private fun resolveDepotManifestInfo( - depot: DepotInfo, - branch: String, - visitedApps: MutableSet = mutableSetOf(), - ): ManifestInfo? { - depot.manifests[branch]?.let { return it } - depot.encryptedManifests[branch]?.let { return it } - - if (!branch.equals("public", ignoreCase = true)) { - depot.manifests["public"]?.let { return it } - depot.encryptedManifests["public"]?.let { return it } - } - - val sourceAppId = depot.depotFromApp - if (sourceAppId == INVALID_APP_ID || !visitedApps.add(sourceAppId)) { - return null - } - - val sourceDepot = getAppInfoOf(sourceAppId)?.depots?.get(depot.depotId) ?: return null - return resolveDepotManifestInfo(sourceDepot, branch, visitedApps) - } - - private fun manifestDownloadBytes(manifest: ManifestInfo?): Long { - if (manifest == null) return 0L - val size = manifest.size.coerceAtLeast(0L) - // Compressed download can't exceed uncompressed size; reject bogus stored values (legacy depots showed tens of TiB), bound to size — healCorruptManifestDownloadSizes() restores the real value. - return manifest.download.takeIf { it in 1L..size } ?: size - } - - private fun calculateManifestSizes( - depots: Collection, - branch: String, - ): ManifestSizes { - var totalInstallSize = 0L - var totalDownloadSize = 0L - - depots.forEach { depot -> - val manifest = resolveDepotManifestInfo(depot, branch) - totalInstallSize += manifest?.size ?: 0L - totalDownloadSize += manifestDownloadBytes(manifest) - } - - return ManifestSizes( - installSize = totalInstallSize, - downloadSize = totalDownloadSize, - ) - } - - private fun filterAlreadyInstalledDepots( - appId: Int, - depots: Map, - includeInstalledDepots: Boolean, - ): Map { - if (includeInstalledDepots || depots.isEmpty()) return depots - - val installedApp = getTrustedInstalledAppInfo(appId) ?: return depots - val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toSet() - - return depots.filter { (depotId, depot) -> - val isInstalledBaseDepot = - depot.dlcAppId == INVALID_APP_ID || - depotId in installedApp.downloadedDepots - val isInstalledDlcDepot = - depot.dlcAppId != INVALID_APP_ID && - depot.dlcAppId in installedDlcAppIds - - !isInstalledBaseDepot && !isInstalledDlcDepot - } - } - - private fun filterAlreadyInstalledDlcSelection( - appId: Int, - dlcAppIds: List, - includeInstalledDepots: Boolean, - customInstallPath: String?, - ): List { - val selected = dlcAppIds.distinct() - if (selected.isEmpty() || includeInstalledDepots || customInstallPath != null) return selected - - val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toSet() - if (installedDlcAppIds.isEmpty()) return selected - - val filtered = selected.filterNot { it in installedDlcAppIds } - val skipped = selected - filtered.toSet() - if (skipped.isNotEmpty()) { - Timber.i( - "Skipping already-installed Steam DLC selection for appId=$appId " + - "dlcAppIds=${skipped.sorted()}", - ) - } - return filtered - } - fun getSelectedManifestSizes( appId: Int, userSelectedDlcAppIds: Collection = emptyList(), @@ -2110,21 +1379,6 @@ class SteamService : Service() { return calculateManifestSizes(combined, branch) } - private fun isSuspiciousSteamInstallDirLeaf(value: String): Boolean { - val normalized = value.trim().replace('\\', '/').trimEnd('/') - if (normalized.isEmpty()) return false - val leaf = normalized.substringAfterLast('/') - return leaf.equals("common", ignoreCase = true) || - leaf.equals("steamapps", ignoreCase = true) - } - - private fun isSuspiciousSteamInstallPath(path: String): Boolean { - val normalized = path.trim().replace('\\', '/').trimEnd('/') - if (normalized.isEmpty()) return false - return normalized.endsWith("/steamapps/common", ignoreCase = true) || - normalized.endsWith("/steamapps", ignoreCase = true) - } - fun getAppDirName(app: SteamApp?): String { val configuredInstallDir = app?.config?.installDir @@ -2136,15 +1390,6 @@ class SteamService : Service() { ?: app?.name.orEmpty() } - private fun normalizeInstallPath(path: String): String { - if (path.isBlank()) return path - return try { - File(path).canonicalPath - } catch (_: IOException) { - File(path).absolutePath - } - } - fun getAppDirPath(gameId: Int): String { val info = getAppInfoOf(gameId) @@ -2218,51 +1463,6 @@ class SteamService : Service() { return normalizeInstallPath(Paths.get(internalAppInstallPath, targetName).pathString) } - private fun createSteamShortcut( - context: Context, - appId: Int, - ) { - try { - val container = ContainerUtils.getOrCreateContainer(context, "STEAM_$appId") - val appInfo = getAppInfoOf(appId) ?: return - val installPath = getAppDirPath(appId) - val launchExecutable = getInstalledExe(appId) - val desktopDir = container.getDesktopDir() - if (!desktopDir.exists()) desktopDir.mkdirs() - - val shortcutFile = File(desktopDir, "${appInfo.name}.desktop") - - // Skip if present — rewriting on every verify/update wiped per-game [Extra Data] (wine version, dxwrapper, env vars, cover art). - if (shortcutFile.exists() && shortcutFile.length() > 0L) { - Timber.i( - "Steam shortcut already exists for appId=$appId (${appInfo.name}); " + - "preserving existing per-game settings.", - ) - return - } - - val content = StringBuilder() - content.append("[Desktop Entry]\n") - content.append("Type=Application\n") - content.append("Name=${appInfo.name}\n") - content.append("Exec=wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"\n") - content.append("Icon=steam_icon_$appId\n") - content.append("\n[Extra Data]\n") - content.append("game_source=STEAM\n") - content.append("app_id=$appId\n") - content.append("container_id=${container.id}\n") - content.append("game_install_path=$installPath\n") - content.append("launch_exe_path=$launchExecutable\n") - content.append("use_container_defaults=1\n") - - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcutFile, content.toString()) - Timber.i("Created Steam shortcut for ${appInfo.name} in container ${container.id}") - } catch (e: Exception) { - Timber.e(e, "Failed to create Steam shortcut for appId $appId") - } - } - /** Resolves the executable for an installed Steam app from its appinfo `config.launch` entries — depot manifests store filenames AES-encrypted and are never decrypted, so scanning them is useless. */ fun getInstalledExe(appId: Int): String = getWindowsLaunchInfos(appId).firstOrNull()?.executable ?: "" @@ -2464,60 +1664,6 @@ class SteamService : Service() { downloadTaskType = DownloadRecord.TASK_VERIFY, ) - private fun resolveInstalledDlcIdsForUpdateOrVerify(appId: Int): List { - val dlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toMutableList() - - getDownloadableDlcAppsOf(appId)?.forEach { dlcApp -> - val installedDlcApp = getInstalledApp(dlcApp.id) - if (installedDlcApp != null) { - dlcAppIds.add(installedDlcApp.id) - } - } - - return dlcAppIds.distinct() - } - - private fun parseDownloadScopeIds(scope: String): Set = - scope - .split(',') - .mapNotNull { it.trim().toIntOrNull() } - .toSet() - - private fun activeDownloadRecordFor(appId: Int): DownloadRecord? = - runCatching { - runBlocking(Dispatchers.IO) { - DownloadCoordinator.findRecord( - DownloadRecord.STORE_STEAM, - appId.toString(), - ) - } - }.getOrNull() - ?.takeIf { - it.status in setOf( - DownloadRecord.STATUS_QUEUED, - DownloadRecord.STATUS_DOWNLOADING, - DownloadRecord.STATUS_PAUSED, - ) - } - - private fun rejectConflictingDownloadRequest(appId: Int, record: DownloadRecord): DownloadInfo? { - Timber.i( - "Refusing Steam download request for appId=$appId because an active record already exists " + - "status=${record.status} taskType=${record.taskType} selectedDlcs=${record.selectedDlcs}", - ) - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show( - service.applicationContext, - service.getString(R.string.store_game_download_already_active), - Toast.LENGTH_SHORT, - ) - } - } - // Return null so callers can tell the request was rejected; returning the pre-existing job would let a verify/update pop-up latch onto an unrelated in-flight download and mislabel it. - return null - } - fun downloadApp( appId: Int, dlcAppIds: List, @@ -2535,59 +1681,6 @@ class SteamService : Service() { ) } - private fun downloadApp( - appId: Int, - dlcAppIds: List, - includeInstalledDepots: Boolean, - enableVerify: Boolean, - allowPersistedProgress: Boolean = false, - hasPersistedResumeRow: Boolean = false, - customInstallPath: String? = null, - downloadTaskType: String = DownloadRecord.TASK_INSTALL, - targetDepotIds: Set? = null, - ): DownloadInfo? { - ensureFreshDepotData(appId) - val appInfo = getAppInfoOf(appId) - if (appInfo == null) { - Timber.e("Download aborted: Could not find AppInfo for appId: $appId") - return null - } - - val effectiveDlcAppIds = - filterAlreadyInstalledDlcSelection( - appId = appId, - dlcAppIds = dlcAppIds, - includeInstalledDepots = includeInstalledDepots, - customInstallPath = customInstallPath, - ) - - val downloadableDepots = getDownloadableDepots(appId) - if (downloadableDepots.isEmpty()) { - Timber.w("Download aborted: No downloadable depots found for appId: $appId") - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show(service.applicationContext, "No downloadable content found for this game", Toast.LENGTH_LONG) - } - } - return null - } - - // Delegate to the full depot-level downloadApp overload - return downloadApp( - appId = appId, - downloadableDepots = downloadableDepots, - userSelectedDlcAppIds = effectiveDlcAppIds, - branch = "public", - includeInstalledDepots = includeInstalledDepots, - enableVerify = enableVerify, - allowPersistedProgress = allowPersistedProgress, - hasPersistedResumeRow = hasPersistedResumeRow, - customInstallPath = customInstallPath, - downloadTaskType = downloadTaskType, - targetDepotIds = targetDepotIds, - ) - } - fun isImageFsInstalled(context: Context): Boolean = ImageFs.find(context).isValid() fun isSteamInstallable(context: Context): Boolean = File(context.filesDir, "steam.tzst").exists() @@ -2691,162 +1784,6 @@ class SteamService : Service() { fetchFileWithFallback(fileName, dest, context, onDownloadProgress) } - private fun selectSteamControllerConfig(details: List): SteamControllerConfigDetail? { - if (details.isEmpty()) return null - - val branchPriority = listOf("default", "public") - val controllerPriority = - listOf( - "controller_xbox360", - "controller_xboxone", - "controller_steamcontroller_gordon", - "controller_generic", - ) - - for (branch in branchPriority) { - for (controllerType in controllerPriority) { - val match = - details.firstOrNull { detail -> - detail.controllerType.equals(controllerType, ignoreCase = true) && - detail.enabledBranches.any { it.equals(branch, ignoreCase = true) } - } - if (match != null) return match - } - } - - return null - } - - private fun resolveSteamInputManifestFile( - appId: Int, - appDirPath: String, - ): File? { - val manifestPath = - getAppInfoOf(appId) - ?.config - ?.steamInputManifestPath - ?.trim() - .orEmpty() - if (manifestPath.isEmpty()) return null - - return resolvePathCaseInsensitive(appDirPath, manifestPath) - } - - private fun loadConfigFromManifest(manifestFile: File): String? { - if (!manifestFile.exists()) return null - val manifestDirPath = manifestFile.parentFile?.path ?: return null - - val manifestText = manifestFile.readText(Charsets.UTF_8) - val configText = - try { - parseManifestForConfig(manifestDirPath, manifestText) - } catch (e: Exception) { - Timber.e(e, "Failed to parse Steam Input manifest config at ${manifestFile.path}") - return null - } - return configText ?: manifestText - } - - private fun parseManifestForConfig( - manifestDirPath: String, - manifestText: String, - ): String? { - return try { - val kv = KeyValue.loadFromString(manifestText) ?: return null - val actionManifest = - if (kv.name?.equals("Action Manifest", ignoreCase = true) == true) { - kv - } else { - kv["Action Manifest"] - } - if (actionManifest === KeyValue.INVALID) return null - - val configs = actionManifest["configurations"] - if (configs === KeyValue.INVALID || configs.children.isEmpty()) { - throw IllegalStateException("No configurations found in Action Manifest") - } - - val preferredControllers = - listOf( - "controller_xboxone", - "controller_steamcontroller_gordon", - "controller_generic", - "controller_xbox360", - ) - - for (controllerType in preferredControllers) { - val controllerBlock = configs[controllerType] - if (controllerBlock === KeyValue.INVALID) continue - - for (entry in controllerBlock.children) { - val pathNode = entry["path"] - val configPath = pathNode.asString().orEmpty() - if (pathNode === KeyValue.INVALID || configPath.isEmpty()) continue - - val configFile = - resolvePathCaseInsensitive(manifestDirPath, configPath) - ?: continue - return configFile.readText(Charsets.UTF_8) - } - } - - throw IllegalStateException("No valid controller configuration found in Action Manifest") - } catch (e: Exception) { - Timber.e(e, "Failed to parse Steam Input manifest config") - null - } - } - - private fun resolvePathCaseInsensitive( - baseDirPath: String, - relativePath: String, - ): File? { - val normalizedPath = relativePath.replace('\\', '/') - val directFile = File(baseDirPath, normalizedPath) - if (directFile.exists()) return directFile - - var currentDir = File(baseDirPath) - if (!currentDir.exists() || !currentDir.isDirectory) return null - - val segments = normalizedPath.split('/').filter { it.isNotEmpty() } - for ((index, segment) in segments.withIndex()) { - if (segment == ".") continue - if (segment == "..") { - currentDir = currentDir.parentFile ?: return null - continue - } - val entries = currentDir.listFiles() ?: return null - val matched = - entries.firstOrNull { - it.name.equals(segment, ignoreCase = true) - } ?: return null - - if (index == segments.lastIndex) { - return matched - } - - if (!matched.isDirectory) return null - currentDir = matched - } - - return null - } - - private fun readBuiltInSteamInputTemplate(fileName: String): String? { - val assets = instance?.assets ?: return null - return runCatching { - assets.open("steaminput/$fileName").use { stream -> - stream.readBytes().toString(Charsets.UTF_8) - } - }.getOrNull() - } - - private fun readDownloadedSteamInputTemplate(appId: Int): String? { - val configFile = File(getAppDirPath(appId), STEAM_CONTROLLER_CONFIG_FILENAME) - if (!configFile.exists()) return null - return configFile.readText(Charsets.UTF_8) - } - fun resolveSteamControllerVdfText(appId: Int): String? { val config = getAppInfoOf(appId)?.config ?: return null return when (config.steamControllerTemplateIndex) { @@ -2879,1801 +1816,128 @@ class SteamService : Service() { } } - fun downloadApp( - appId: Int, - downloadableDepots: Map, - userSelectedDlcAppIds: List, - branch: String, - includeInstalledDepots: Boolean, - enableVerify: Boolean, - allowPersistedProgress: Boolean = false, - hasPersistedResumeRow: Boolean = false, - customInstallPath: String? = null, - downloadTaskType: String = DownloadRecord.TASK_INSTALL, - targetDepotIds: Set? = null, - ): DownloadInfo? { - var appDirPath = getAppDirPath(appId) - Timber.i("downloadApp called for appId: $appId, customInstallPath: $customInstallPath") - Timber.i( - "Steam DLC selection: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + - "includeInstalledDepots=$includeInstalledDepots verify=$enableVerify allowResume=$allowPersistedProgress " + - "targetDepotIds=${targetDepotIds?.sorted().orEmpty()}", - ) - - activeDownloadRecordFor(appId)?.let { activeRecord -> - val requestedScopeIds = - if (downloadTaskType == DownloadRecord.TASK_UPDATE && targetDepotIds != null) { - targetDepotIds - } else { - userSelectedDlcAppIds.toSet() - } - val isSameCoordinatorDispatch = - customInstallPath == null && - activeRecord.taskType == downloadTaskType && - parseDownloadScopeIds(activeRecord.selectedDlcs) == requestedScopeIds - if (!isSameCoordinatorDispatch) { - return rejectConflictingDownloadRequest(appId, activeRecord) - } - } - - if (customInstallPath != null) { - val appInfo = getAppInfoOf(appId) - val folderName = getAppDirName(appInfo) - val safeFolderName = if (folderName.isNotEmpty()) folderName else appId.toString() - - val customFile = File(customInstallPath) - val finalPath = - if (customFile.name.equals(safeFolderName, ignoreCase = true)) { - // User selected the game folder itself - normalizeInstallPath(customFile.absolutePath) - } else { - // User selected parent folder, create/use subfolder - normalizeInstallPath(File(customInstallPath, safeFolderName).absolutePath) + fun getWindowsLaunchInfos(appId: Int): List = + getAppInfoOf(appId) + ?.let { appInfo -> + appInfo.config.launch.filter { launchInfo -> + // since configOS was unreliable and configArch was even more unreliable + launchInfo.executable.endsWith(".exe") } + }.orEmpty() - appDirPath = finalPath - Timber.i("Final custom appDirPath: $appDirPath") + suspend fun notifyRunningProcesses(vararg gameProcesses: GameProcessInfo) = + withContext(Dispatchers.IO) { + instance?.let { steamInstance -> + if (isConnected) { + val gamesPlayed = + gameProcesses.mapNotNull { gameProcess -> + getAppInfoOf(gameProcess.appId)?.let { appInfo -> + getPkgInfoOf(gameProcess.appId)?.let { pkgInfo -> + appInfo.branches[gameProcess.branch]?.let { branch -> + val processId = + gameProcess.processes + .firstOrNull { it.parentIsSteam } + ?.processId + ?: gameProcess.processes.firstOrNull()?.processId + ?: 0 - runBlocking { - if (appInfo != null) { - val updatedApp = appInfo.copy(installDir = finalPath) - instance?.appDao?.update(updatedApp) - Timber.i("Updated SteamApp installDir in DB to: $finalPath") - } - } - } + val userAccountId = userSteamId!!.accountID.toInt() + GamePlayedInfo( + gameId = gameProcess.appId.toLong(), + processId = processId, + ownerId = + if (pkgInfo.ownerAccountId.contains(userAccountId)) { + userAccountId + } else { + pkgInfo.ownerAccountId.first() + }, + // Unknown Steam launch source; keep observed value. + launchSource = 100, + gameBuildId = branch.buildId.toInt(), + processIdList = gameProcess.processes, + ) + } + } + } + } - val hasTrustedInstallAtStart = - customInstallPath == null && - getTrustedInstalledAppInfo(appId) != null - val isAddingDlcToTrustedInstall = - hasTrustedInstallAtStart && - !includeInstalledDepots && - userSelectedDlcAppIds.isNotEmpty() + Timber.i( + "GameProcessInfo:%s", + gamesPlayed.joinToString("\n") { game -> + """ + | processId: ${game.processId} + | gameId: ${game.gameId} + | processes: ${ + game.processIdList.joinToString("\n") { process -> + """ + | processId: ${process.processId} + | processIdParent: ${process.processIdParent} + | parentIsSteam: ${process.parentIsSteam} + """.trimMargin() + } + } + """.trimMargin() + }, + ) - // Ensure the download directory exists - try { - val dir = File(appDirPath) - if (!dir.exists()) { - if (dir.mkdirs()) { - Timber.i("Created download directory: $appDirPath") - } else { - Timber.e("Failed to create download directory (mkdirs returned false): $appDirPath") - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show( - service.applicationContext, - "Failed to create download directory. Check permissions.", - Toast.LENGTH_LONG, + // Report running games via the C++ WN-Steam-Client. + val gamesJson = JSONArray() + gamesPlayed.forEach { g -> + val procs = JSONArray() + g.processIdList.forEach { p -> + procs.put( + JSONObject() + .put("pid", p.processId) + .put("ppid", p.processIdParent) + .put("isSteam", p.parentIsSteam), + ) + } + gamesJson.put( + JSONObject() + .put("gameId", g.gameId) + .put("processId", g.processId) + .put("ownerId", g.ownerId) + .put("launchSource", g.launchSource) + .put("gameBuildId", g.gameBuildId) + .put("processes", procs), + ) + } + withWnSession { session -> + withContext(Dispatchers.IO) { + session.notifyGamesPlayed( + gamesJson.toString(), + EOSType.AndroidUnknown.code(), ) } } - return null } } - - if (!MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)) { - Timber.e("Failed to add DOWNLOAD_IN_PROGRESS_MARKER at $appDirPath") - } - - // Fresh installs reset completion state; when the base is already trusted, keep the marker while adding DLC so a cancelled DLC download doesn't make the base look missing. - if (downloadTaskType == DownloadRecord.TASK_UPDATE) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } else if (!includeInstalledDepots && !hasTrustedInstallAtStart) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } - } catch (e: Exception) { - Timber.e(e, "Error preparing download directory or markers: $appDirPath") } - // If a custom path is provided, we want to force a new download at that location - if (customInstallPath != null) { - Timber.i("Custom path provided, cancelling any existing job for appId: $appId") - downloadJobs[appId]?.cancel("Restarting download at custom path") - downloadJobs.remove(appId) - } else { - // Only return existing job if it's still active - val existingJob = downloadJobs[appId] - if (existingJob != null && existingJob.isActive()) { - Timber.i("Returning existing active download job for appId: $appId") - return existingJob + fun beginLaunchApp( + appId: Int, + parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + ignorePendingOperations: Boolean = false, + preferredSave: SaveLocation = SaveLocation.None, + prefixToPath: (String) -> String, + isOffline: Boolean = false, + onProgress: ((message: String, progress: Float) -> Unit)? = null, + ): Deferred = + parentScope.async { + if (isOffline || !isConnected) { + return@async PostSyncInfo(SyncResult.UpToDate) + } + if (!tryAcquireSync(appId)) { + Timber.w("Cannot launch app when sync already in progress for appId=$appId") + return@async PostSyncInfo(SyncResult.InProgress) } - } - Timber.d("Checking depots for appId: $appId. downloadableDepots count: ${downloadableDepots.size}") - if (downloadableDepots.isEmpty()) { - Timber.w("Download aborted: downloadableDepots is empty for appId: $appId") - return null - } - - val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id } - Timber.d("Indirect DLC app IDs for appId $appId: $indirectDlcAppIds") - - // Depots from Main game - val mainDepots = getMainAppDepots(appId) - val appInfoForDownload = getAppInfoOf(appId) - val groupedBaseDlcDepotIds = - appInfoForDownload - ?.let { getGroupedBaseAppDlcContentDepotIds(it) } - .orEmpty() - // Base-package-entitled depots are base content; never let positional DLC grouping drop them (e.g. DMC5 601151/2) — that zeroes the downloads-tab size. - val baseEntitledDepotIds = - appInfoForDownload?.packageId?.let { getEntitledDepotIds(it) }.orEmpty() - Timber.d("Main app depots count: ${mainDepots.size}") - val baseMainAppDepots = - if (isAddingDlcToTrustedInstall) { - Timber.i( - "Building DLC-only Steam download scope for installed appId=$appId " + - "selectedDlcAppIds=${userSelectedDlcAppIds.sorted()}", - ) - emptyMap() - } else { - mainDepots.filter { (depotId, depot) -> - depot.dlcAppId == INVALID_APP_ID && - (depotId !in groupedBaseDlcDepotIds || depotId in baseEntitledDepotIds) - } - } - val targetDepotIdSet = targetDepotIds?.takeIf { it.isNotEmpty() } - var originalMainAppDepots = - baseMainAppDepots + - mainDepots.filter { (_, depot) -> - userSelectedDlcAppIds.contains(depot.dlcAppId) && - resolveDepotManifestInfo(depot, branch) != null - } + - getSelectedBaseAppDlcContentDepots( - appId = appId, - selectedDlcAppIds = userSelectedDlcAppIds, - preferredLanguage = PrefManager.containerLanguage, - branch = branch, - ) - if (targetDepotIdSet != null) { - originalMainAppDepots = originalMainAppDepots.filterKeys { it in targetDepotIdSet } - } - var mainAppDepots = originalMainAppDepots - Timber.d("Filtered main app depots count: ${mainAppDepots.size}") - - // Depots from indirect DLC apps (reachable via findDownloadableDLCApps, which needs a cached license row). - val indirectDlcAppDepots = - downloadableDepots.filter { (_, depot) -> - !mainAppDepots.map { it.key }.contains(depot.depotId) && - userSelectedDlcAppIds.contains(depot.dlcAppId) && - indirectDlcAppIds.contains(depot.dlcAppId) && - resolveDepotManifestInfo(depot, branch) != null - } - Timber.d("Filtered indirect DLC app depots count: ${indirectDlcAppDepots.size}") - - // Selected DLCs whose depots aren't reachable via indirectDlcAppIds (stale license row, or DLC declared on the base game) — look them up by appId so the download matches getDlcOnlyManifestSizes and the estimate/downloads-tab stay in sync. - val coveredDlcAppIds = - (originalMainAppDepots.values.asSequence() + indirectDlcAppDepots.values.asSequence()) - .mapNotNull { d -> d.dlcAppId.takeIf { it != INVALID_APP_ID } } - .toSet() - val missingDlcAppIds = userSelectedDlcAppIds.filter { it !in coveredDlcAppIds } - val extraDlcAppDepots: Map = - if (missingDlcAppIds.isEmpty()) { - emptyMap() - } else { - val appInfoForArch = getAppInfoOf(appId) - val extraHas64Bit = - appInfoForArch?.depots?.values?.any { - it.osArch == OSArch.Arch64 && - (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) - } ?: false - val extraLanguage = PrefManager.containerLanguage - val coveredDepotIds = originalMainAppDepots.keys + indirectDlcAppDepots.keys - val collected = mutableMapOf() - for (dlcAppId in missingDlcAppIds) { - // Only recover depots for DLC the account owns; otherwise Steam denies the key. - val ownsDlc = - runBlocking(Dispatchers.IO) { - (instance?.licenseDao?.countLicensesForApp(dlcAppId) ?: 0) > 0 - } - if (!ownsDlc) { - Timber.i("Skipping recovery depots for unowned DLC appId=$dlcAppId") - continue - } - val dlcAppInfo = - runBlocking(Dispatchers.IO) { instance?.appDao?.findApp(dlcAppId) } - ?: continue - for ((depotId, depot) in dlcAppInfo.depots) { - if (depotId in coveredDepotIds || depotId in collected) continue - if (!filterForDownloadableDepots(depot, extraHas64Bit, extraLanguage, ownedDlc = null)) continue - if (resolveDepotManifestInfo(depot, branch) == null) continue - collected[depotId] = - DepotInfo( - depotId = depot.depotId, - dlcAppId = dlcAppId, - optionalDlcId = depot.optionalDlcId, - depotFromApp = depot.depotFromApp, - sharedInstall = depot.sharedInstall, - osList = depot.osList, - osArch = depot.osArch, - language = depot.language, - lowViolence = depot.lowViolence, - manifests = depot.manifests, - encryptedManifests = depot.encryptedManifests, - ) - } - } - collected - } - if (extraDlcAppDepots.isNotEmpty()) { - Timber.d("Recovered ${extraDlcAppDepots.size} extra DLC depots for selected DLCs ${missingDlcAppIds}") - } - // Single combined view of DLC depots (indirect + extras) used downstream for grouping, totals, and DownloadingAppInfo persistence — extras must be visible everywhere. - val dlcAppDepots = - (indirectDlcAppDepots + extraDlcAppDepots).let { depots -> - if (targetDepotIdSet == null) depots else depots.filterKeys { it in targetDepotIdSet } - } - - // Drop already-downloaded depots only when install metadata is trusted; a custom path re-checks/downloads everything at the new location. - var installedApp = getInstalledApp(appId) - val hasCompleteMarker = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - var hasTrustedInstalledState = installedApp?.isDownloaded == true && hasCompleteMarker - if (!includeInstalledDepots && installedApp != null && !hasTrustedInstalledState && customInstallPath == null) { - val hasStaleInstallMetadata = - installedApp.isDownloaded || - installedApp.downloadedDepots.isNotEmpty() || - installedApp.dlcDepots.isNotEmpty() - if (hasStaleInstallMetadata) { - Timber.w( - "Clearing stale install metadata for appId=$appId " + - "(isDownloaded=${installedApp.isDownloaded}, marker=$hasCompleteMarker)", - ) - runBlocking(Dispatchers.IO) { - instance?.appInfoDao?.deleteApp(appId) - } - installedApp = null - } - hasTrustedInstalledState = false - } - if (installedApp != null && !includeInstalledDepots && hasTrustedInstalledState && customInstallPath == null) { - val beforeCount = mainAppDepots.size - mainAppDepots = mainAppDepots.filter { it.key !in installedApp.downloadedDepots } - Timber.d("Removed already downloaded depots. Count before: $beforeCount, after: ${mainAppDepots.size}") - } - - // Resume support: .DepotDownloader/depot.config records depot state — finish_depot is written only after every file lands, so a "finished" entry always means a complete depot and is safe to keep across pause/resume (download() skips finished, write_depot() resumes the in-progress one chunk-by-chunk). Treat as "fresh" (discard depot.config) only for a brand-new install or a verify pass. - val depotConfigFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") - val isFreshDownload = - downloadTaskType == DownloadRecord.TASK_VERIFY || !depotConfigFile.exists() - Timber.i( - "Download fresh=$isFreshDownload for appId=$appId " + - "(task=$downloadTaskType, depotConfigExists=${depotConfigFile.exists()})", - ) - - val allDepots = originalMainAppDepots + dlcAppDepots - // Use install (uncompressed) size for progress; resolveDepotManifestInfo follows depot.depotFromApp and falls back to the public branch so shared/proxied DLC depots contribute their full size, not the 1L fallback. - val depotSizeById = - allDepots.mapValues { (_, depot) -> - val mInfo = resolveDepotManifestInfo(depot, branch) - (mInfo?.size ?: 1L).coerceAtLeast(1L) - } - - // Mutable so the safety check below can drop suspicious entries before they poison di.depotCumulativeUncompressedBytes during resume init. - var persistedDepotBytes: Map = - if (allowPersistedProgress) { - DownloadInfo.loadPersistedDepotBytes(appDirPath) - } else { - emptyMap() - } - - // Scope shrink (DLC de-selected, or Steam republished the depot list): drop orphan snapshot entries instead of refusing the resume; selectedDepots is built from current scope only, so partial-COMPLETE can't happen. - if (allowPersistedProgress && persistedDepotBytes.isNotEmpty()) { - val depotsInScope = allDepots.keys - val orphanSnapshotDepots = persistedDepotBytes.keys - depotsInScope - if (orphanSnapshotDepots.isNotEmpty()) { - Timber.w( - "Resume scope shrunk for appId=$appId: snapshot has depot(s) " + - "$orphanSnapshotDepots that are not in the current download scope " + - "(scope depots: $depotsInScope). Dropping orphan snapshot entries " + - "and continuing with in-scope depots.", - ) - persistedDepotBytes = persistedDepotBytes.filterKeys { it in depotsInScope } - DownloadInfo.persistDepotBytes(appDirPath, persistedDepotBytes) - } - } - - val fullyDownloadedDepotsFromSnapshot = mutableSetOf() - if (persistedDepotBytes.isNotEmpty()) { - for ((depotId, _) in allDepots) { - val depotSize = depotSizeById[depotId] ?: 1L - val downloadedBytes = persistedDepotBytes[depotId] ?: 0L - Timber.d( - "Resume snapshot for appId=$appId depot=$depotId: persisted=$downloadedBytes / size=$depotSize " + - (if (downloadedBytes >= depotSize) "-> SKIP-CANDIDATE" else "-> include"), - ) - if (downloadedBytes >= depotSize) { - fullyDownloadedDepotsFromSnapshot.add(depotId) - } - } - if (fullyDownloadedDepotsFromSnapshot.isNotEmpty()) { - // Trust the snapshot's "fully downloaded" claim only when the COMPLETE marker exists; without it the install was partial and snapshots have historically been corrupted to depotSize prematurely, so let per-file checksum validation handle resume instead. - if (hasCompleteMarker) { - Timber.i( - "Skipping ${fullyDownloadedDepotsFromSnapshot.size} fully downloaded depots from snapshot " + - "(COMPLETE marker present): $fullyDownloadedDepotsFromSnapshot", - ) - mainAppDepots = mainAppDepots.filter { it.key !in fullyDownloadedDepotsFromSnapshot } - } else { - Timber.w( - "REFUSING to skip ${fullyDownloadedDepotsFromSnapshot.size} depots claimed full by snapshot " + - "for appId=$appId because COMPLETE marker is absent. Depots will be re-validated " + - "by the downloader; persisted byte counts are kept so the progress bar stays at the " + - "user's last position while verification confirms files on disk: " + - "$fullyDownloadedDepotsFromSnapshot", - ) - // Keep persistedDepotBytes (the monotonic CAS in onProgress can't lower them, so the restored % shows during validation); just clear the skip set so the depots are re-validated. - fullyDownloadedDepotsFromSnapshot.clear() - } - } - } - - // Combine main app and DLC depots - val filteredDlcAppDepots = dlcAppDepots.filter { it.key !in fullyDownloadedDepotsFromSnapshot } - val selectedDepots = mainAppDepots + filteredDlcAppDepots - Timber.i("Total selected depots for download: ${selectedDepots.size}") - - logDepotScopeDiagnostics(appId, branch, selectedDepots) - - if (selectedDepots.isEmpty()) { - var preSnapshotMainAppDepots = originalMainAppDepots - if (installedApp != null && !includeInstalledDepots && hasTrustedInstalledState) { - preSnapshotMainAppDepots = preSnapshotMainAppDepots.filter { it.key !in installedApp.downloadedDepots } - } - val preSnapshotSelectedDepots = preSnapshotMainAppDepots + dlcAppDepots - - if (preSnapshotSelectedDepots.isEmpty()) { - // Zero depots resolved: either (1) nothing selected and base already installed (genuine no-op complete), or (2) selected DLC(s) with no downloadable content (entitlement/branch-access DLCs, e.g. appid 373300) — case (2) must not silently show "Complete / 0 B". - val selectedContentlessDlc = userSelectedDlcAppIds.isNotEmpty() - Timber.i( - "selectedDepots empty for appId=$appId — " + - if (selectedContentlessDlc) { - "selected DLC(s) $userSelectedDlcAppIds have no downloadable content" - } else { - "app already installed" - }, - ) - - // Instead of returning null, create a completed job so it shows in UI - val info = DownloadInfo(1, appId, CopyOnWriteArrayList(listOf(appId))) - info.updateStatus(DownloadPhase.COMPLETE) - info.setProgress(1f) - downloadJobs[appId] = info - - if (allowPersistedProgress) { - Timber.i("Resume became a no-op; clearing stale persisted resume state") - clearFailedResumeState(appId) - } - - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - - if (selectedContentlessDlc) { - // Record content-less DLC(s) as installed so the picker shows "Installed" — owned but with nothing to download. - runCatching { - runBlocking(Dispatchers.IO) { - val mainAppInfo = instance?.appInfoDao?.getInstalledApp(appId) - if (mainAppInfo != null) { - val updatedDlc = - (mainAppInfo.dlcDepots + userSelectedDlcAppIds) - .distinct() - .sorted() - instance?.appInfoDao?.update( - mainAppInfo.copy(dlcDepots = updatedDlc), - ) - Timber.i( - "Marked content-less DLC(s) installed for appId=$appId: dlcDepots=$updatedDlc", - ) - } - } - }.onFailure { e -> - Timber.w(e, "Failed to record content-less DLC(s) for appId=$appId") - } - } - - // Honest message — don't claim a download happened when the selected DLC had no content to fetch. - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - if (selectedContentlessDlc) { - WinToast.show( - service.applicationContext, - "Selected DLC requires no download — marked installed", - Toast.LENGTH_LONG, - ) - } else { - WinToast.show( - service.applicationContext, - "Download complete", - Toast.LENGTH_SHORT, - ) - } - } - } - - return info - } - - // Snapshot says all depots complete but marker missing — finalize metadata/markers directly instead of re-queuing depots. - val canFinalizeFromSnapshot = - allowPersistedProgress && - fullyDownloadedDepotsFromSnapshot.isNotEmpty() && - (hasCompleteMarker || hasPersistedResumeRow) - if (canFinalizeFromSnapshot) { - Timber.i("All resume depots appear complete from snapshot; finalizing without downloader") - val info = - finalizeSnapshotResumeAsComplete( - appId = appId, - appDirPath = appDirPath, - mainAppDepots = preSnapshotMainAppDepots, - dlcAppDepots = dlcAppDepots, - userSelectedDlcAppIds = userSelectedDlcAppIds, - ) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - return info - } else { - if (allowPersistedProgress) { - if (fullyDownloadedDepotsFromSnapshot.isNotEmpty()) { - Timber.w( - "Snapshot indicates completion for appId=$appId but state is untrusted " + - "(marker=$hasCompleteMarker, resumeRow=$hasPersistedResumeRow); clearing resume metadata", - ) - } else { - Timber.i("selectedDepots resolved empty on resume; clearing stale resume metadata") - } - clearFailedResumeState(appId) - } else { - Timber.i("selectedDepots resolved empty after filtering; skipping download start") - } - } - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - return null - } - - val downloadingAppIds = CopyOnWriteArrayList() - val calculatedDlcAppIds = CopyOnWriteArrayList() - val allDepotIdsByDlcAppId = - dlcAppDepots.values - .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) - .mapValues { (_, depotIds) -> depotIds.sorted() } - val selectedDlcDepotIdsByDlcAppId = - filteredDlcAppDepots.values - .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) - .mapValues { (_, depotIds) -> depotIds.sorted() } - - userSelectedDlcAppIds.forEach { dlcAppId -> - if (allDepotIdsByDlcAppId[dlcAppId]?.isNotEmpty() == true) { - downloadingAppIds.add(dlcAppId) - calculatedDlcAppIds.add(dlcAppId) - } - } - - if (mainAppDepots.isNotEmpty()) { - downloadingAppIds.add(appId) - } - - // Some apps put DLC content under the base app with no dlcAppId on every depot; persist only DLC metadata in the selected scope, else marker-only DLCs get falsely saved as installed when a sibling DLC is selected. - val selectedDlcAppIdSet = userSelectedDlcAppIds.toSet() - val mainAppDlcIds = - getMainAppDlcIdsWithoutProperDepotDlcIds(appId) - .filterTo(mutableListOf()) { it in selectedDlcAppIdSet } - mainAppDlcIds.addAll( - mainAppDepots.values - .map { it.dlcAppId } - .filter { it != INVALID_APP_ID && it in selectedDlcAppIdSet } - .distinct(), - ) - - // If there are no DLC depots, download the main app only - if (dlcAppDepots.isEmpty()) { - // Because all dlcIDs are coming from main depots, need to add the dlcID to main app in order to save it to db after finish download - mainAppDlcIds.addAll( - mainAppDepots - .filter { it.value.dlcAppId != INVALID_APP_ID && it.value.dlcAppId in selectedDlcAppIdSet } - .map { it.value.dlcAppId } - .distinct(), - ) - // Entitlement/config DLCs have no downloadable depot but must still be remembered as selected/installed so launch metadata can expose them later. - mainAppDlcIds.addAll(userSelectedDlcAppIds) - - // Refresh id List, so only main app is downloaded - calculatedDlcAppIds.clear() - downloadingAppIds.clear() - downloadingAppIds.add(appId) - } - - Timber.i("Starting download for $appId") - Timber.i("App contains ${mainAppDepots.size} depot(s): ${mainAppDepots.keys}") - Timber.i("DLC contains ${dlcAppDepots.size} depot(s): ${dlcAppDepots.keys}") - Timber.i("downloadingAppIds: $downloadingAppIds") - - val service = - instance ?: run { - Timber.e("SteamService instance is null, cannot start download job.") - return null - } - - val selectedDepotSizes = - selectedDepots.mapValues { (depotId, _) -> - depotSizeById[depotId] ?: 1L - } - val selectedTotalBytes = selectedDepotSizes.values.sum() - val totalBytes = selectedTotalBytes.coerceAtLeast(1L) - val selectedDisplayDownloadBytes = - selectedDepots.values - .sumOf { depot -> manifestDownloadBytes(resolveDepotManifestInfo(depot, branch)) } - .takeIf { it > 0L } - ?: totalBytes - Timber.i( - "Steam DLC selected download scope: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + - "calculatedDlcAppIds=${calculatedDlcAppIds.sorted()} mainDepotIds=${mainAppDepots.keys.sorted()} " + - "dlcDepotIdsByApp=$selectedDlcDepotIdsByDlcAppId totalBytes=$totalBytes " + - "displayDownloadBytes=$selectedDisplayDownloadBytes metadataDlcAppIds=${mainAppDlcIds.sorted()}", - ) - - runBlocking { - service.downloadingAppInfoDao.insert( - DownloadingAppInfo( - appId, - dlcAppIds = userSelectedDlcAppIds, - ), - ) - Unit - } - Timber.i( - "Steam DLC selection persisted: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + - "installPath=$appDirPath", - ) - - // Ask the global coordinator whether this can start now or must queue behind other stores' downloads; it persists the decision in DownloadRecord so the request survives an app restart. - val coordDecision = - runBlocking { - val title = getAppInfoOf(appId)?.name.orEmpty() - val persistedScope = - if (downloadTaskType == DownloadRecord.TASK_UPDATE && targetDepotIdSet != null) { - targetDepotIdSet.sorted().joinToString(",") - } else { - userSelectedDlcAppIds.joinToString(",") - } - DownloadCoordinator.requestSlot( - store = DownloadRecord.STORE_STEAM, - storeGameId = appId.toString(), - title = title, - installPath = appDirPath, - selectedDlcs = persistedScope, - taskType = downloadTaskType, - bytesTotal = selectedDisplayDownloadBytes, - ) - } - Timber.i( - "Steam DLC coordinator record: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + - "bytesTotal=$totalBytes displayDownloadBytes=$selectedDisplayDownloadBytes " + - "decision=${coordDecision::class.simpleName}", - ) - if (coordDecision is DownloadCoordinator.Decision.Queue) { - Timber.i("Coordinator queued appId: $appId") - if (downloadTaskType == DownloadRecord.TASK_UPDATE) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } - val info = - DownloadInfo(selectedDepots.size, appId, downloadingAppIds).also { di -> - di.setPersistencePath(appDirPath) - di.setTotalExpectedBytes(totalBytes) - di.setDisplayTotalExpectedBytes(selectedDisplayDownloadBytes) - di.updateStatus(DownloadPhase.QUEUED, "Queued") - di.setActive(false) - } - downloadJobs[appId] = info - notifyDownloadStarted(appId) - return info - } - - val info = - DownloadInfo(selectedDepots.size, appId, downloadingAppIds).also { di -> - di.setPersistencePath(appDirPath) - - // Set weights for each depot based on manifest sizes - selectedDepots.keys.forEachIndexed { index, depotId -> - di.setWeight(index, selectedDepotSizes[depotId] ?: 1L) - } - - // Track progress only for depots in this run so excluded/complete depots can't pre-fill progress at startup. - - // Total expected size (used for ETA based on recent download speed) - di.setTotalExpectedBytes(totalBytes) - di.setDisplayTotalExpectedBytes(selectedDisplayDownloadBytes) - - var resumedBytes = 0L - - if (allowPersistedProgress) { - for ((depotId, bytes) in persistedDepotBytes) { - // Depot excluded as fully downloaded still needs its bytes tracked so future snapshots retain this progress. - val depotSize = depotSizeById[depotId] ?: continue - val safeBytes = bytes.coerceIn(0L, depotSize) - di.depotCumulativeUncompressedBytes[depotId] = - java.util.concurrent.atomic - .AtomicLong(safeBytes) - // Count resumed bytes only for depots actively downloading in this run. - if (depotId in selectedDepots) { - resumedBytes += safeBytes - } - Timber.i( - "RESUME-INIT depot=$depotId loaded=$safeBytes (snapshot=$bytes, max=$depotSize, " + - "inSelected=${depotId in selectedDepots}, inSession=${depotId in selectedDepotSizes.keys})", - ) - } - } else { - // SYNC clear so a stale snapshot from a prior session can't poison this fresh download — async clear races new persists that could read/overwrite with stale byte counts. - di.clearPersistedBytesDownloaded(appDirPath, sync = true) - Timber.i("RESUME-INIT cleared persisted snapshot (sync) for fresh download appId=$appId") - } - resumedBytes = resumedBytes.coerceIn(0L, totalBytes) - - if (resumedBytes > 0L) { - di.initializeBytesDownloaded(resumedBytes) - Timber.i("Resumed download: initialized with $resumedBytes bytes") - } - - val downloadJob = - service.scope.launch { - // Worker-local session brought up when no logged-on wnSession exists; NOT promoted to the global field so a concurrent logOut()/relogin can't close it mid-download. Disconnected + closed in this worker's finally. - var workerWnSession: WnSteamSession? = null - // Wi-Fi + CPU keep-alive: without it Wi-Fi PSP drops radio power on screen-off and router NAT evicts chunk sockets, surfacing as spurious "WN download failed" on stable Wi-Fi. - val keepAliveTag = "steam-download-$appId" - val keepAliveCtx = service.applicationContext - runCatching { - SessionKeepAliveService.startDownload(keepAliveCtx, keepAliveTag) - }.onFailure { e -> - Timber.w(e, "Failed to acquire keep-alive for Steam download $appId") - } - try { - // Retry loop for transient Steam API failures (AsyncJobFailedException) or missing client - val maxRetries = 3 - - for (attempt in 1..maxRetries) { - try { - if (attempt > 1) { - Timber.i("Retry attempt $attempt/$maxRetries for appId: $appId") - di.updateStatusMessage("Retrying download (attempt $attempt/$maxRetries)") - withContext(Dispatchers.Main) { - WinToast.show( - instance?.applicationContext ?: return@withContext, - "Retrying download (attempt $attempt/$maxRetries)", - Toast.LENGTH_SHORT, - ) - } - kotlinx.coroutines.delay(3000L * attempt) // Exponential backoff - } - - // Ensure a logged-on session (state()==3) for the download: prefer the long-lived wnSession, but it may not be logged on (cached-token restore, idled CM), so bring one up here if needed. The downloader requests depot keys itself. - var wnReady = wnSession?.takeIf { it.state() == 3 } - if (wnReady == null) { - // Brief grace period: wnSession may be mid-logon. - var grace = 0 - while (grace < 8 && wnSession?.state() != 3) { - di.updateStatusMessage("Waiting for Steam connection") - delay(1000L) - grace++ - } - wnReady = wnSession?.takeIf { it.state() == 3 } - } - if (wnReady == null) { - // Reuse a session this worker brought up on a prior retry, if still logged on. - wnReady = workerWnSession?.takeIf { it.state() == 3 } - } - if (wnReady == null) { - Timber.i("downloadApp: no logged-on wnSession — bringing one up for the download") - di.updateStatusMessage("Connecting to Steam") - val svc = instance - ?: throw Exception("Steam service unavailable.") - val refreshTok = PrefManager.refreshToken - if (refreshTok.isBlank()) { - throw Exception( - "Not logged in to Steam (no refresh token). " + - "Please sign in and try again.", - ) - } - // Discard a worker session from a prior attempt that is no longer logged on. - workerWnSession?.let { stale -> - runCatching { stale.disconnect() } - runCatching { stale.close() } - } - workerWnSession = null - val brought = bringUpWnSession(svc) - ?: throw Exception( - "WN-Steam-Client: could not connect to Steam.", - ) - workerWnSession = brought - // Download-only session: skip the library-populate PICS crawl so it doesn't flood the CM while the download needs the channel for depot keys. - brought.setAutoPopulateLibrary(false) - di.updateStatusMessage("Logging in to Steam") - if (!brought.logonWithRefreshToken( - refreshTok, - PrefManager.username, - PrefManager.steamUserSteamId64, - ) - ) { - throw Exception("WN-Steam-Client: logon request failed.") - } - var logonWait = 0 - while (brought.state() != 3 && logonWait < 30) { - delay(1000L) - logonWait++ - } - if (brought.state() != 3) { - throw Exception( - "WN-Steam-Client: could not log on to Steam. " + - "Please check your connection.", - ) - } - wnReady = brought - Timber.i("downloadApp: WN-Steam session logged on for download") - } - - // Capture wnReady (a mutable var) once as a stable non-null handle for the download below. - val wnSessionForDownload = wnReady - ?: throw Exception("WN-Steam-Client session unavailable.") - - Timber.i("Initializing WN-Steam downloader for appId: $appId (attempt $attempt)") - di.updateStatusMessage("Initializing downloader") - - // CA bundle for HTTPS CDN verification (same one CaBundleExtractor provides for the CM session). - val caPath = CaBundleExtractor.ensureBundle( - instance?.applicationContext - ?: throw Exception("Steam service unavailable"), - ) - - // Total expected bytes (drives di.getProgress()): use ManifestInfo.size (DECOMPRESSED) since onProgress reports decompressed bytes — the compressed .download size would overshoot 1.0. - val grandTotalBytes = selectedDepots.values.sumOf { depot -> - resolveDepotManifestInfo(depot, branch)?.size ?: 0L - } - if (grandTotalBytes > 0L) di.setTotalExpectedBytes(grandTotalBytes) - - // Native downloadApp() takes one appId (depot-key entitlement), so split into one batch per app — main app then each owned DLC. Triple = (appId, depotIds, manifestIds). - val wnBatches: List> = buildList { - if (mainAppDepots.isNotEmpty()) { - // Drop unresolvable depots (gid 0); sending manifest 0 aborts the native batch. - val resolved = mainAppDepots.keys.sorted().mapNotNull { id -> - val gid = resolveDepotManifestInfo(mainAppDepots[id]!!, branch)?.gid ?: 0L - if (gid > 0L) { - id to gid - } else { - Timber.w("Skipping main depot $id: unresolved manifest gid (branch=$branch)") - null - } - } - if (resolved.isNotEmpty()) { - add(Triple( - appId, - resolved.map { it.first }.toIntArray(), - resolved.map { it.second }.toLongArray(), - )) - } - } - calculatedDlcAppIds.forEach { dlcAppId -> - val dlcDepotIds = selectedDlcDepotIdsByDlcAppId[dlcAppId].orEmpty() - if (dlcDepotIds.isEmpty()) return@forEach - val resolved = dlcDepotIds.mapNotNull { depotId -> - val gid = selectedDepots[depotId]?.let { resolveDepotManifestInfo(it, branch)?.gid } ?: 0L - if (gid > 0L) { - depotId to gid - } else { - Timber.w("Skipping DLC depot $depotId (dlcAppId=$dlcAppId): unresolved manifest gid (branch=$branch)") - null - } - } - if (resolved.isEmpty()) return@forEach - Timber.i("Steam DLC batch queued: dlcAppId=$dlcAppId depotIds=${resolved.map { it.first }}") - add(Triple( - dlcAppId, - resolved.map { it.first }.toIntArray(), - resolved.map { it.second }.toLongArray(), - )) - } - } - if (wnBatches.isEmpty()) { - throw Exception("No depots resolved for download.") - } - Timber.i("WN download: ${wnBatches.size} app batch(es) to $appDirPath") - - // Steam Controller Config download - val appConfig = getAppInfoOf(appId)?.config - if (appConfig?.steamControllerTemplateIndex == 1) { - val controllerConfig = - appConfig.steamControllerConfigDetails - .let { selectSteamControllerConfig(it) } - - if (controllerConfig != null) { - val publishedFileId = controllerConfig.publishedFileId - runCatching { - val requestBody = - FormBody - .Builder() - .add( - "itemcount", - "1", - ).add("publishedfileids[0]", publishedFileId.toString()) - .build() - val request = - Request - .Builder() - .url( - "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1", - ).post(requestBody) - .build() - Net.http.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val responseBody = response.body?.string() - if (!responseBody.isNullOrEmpty()) { - val responseJson = JSONObject(responseBody) - val responseData = responseJson.optJSONObject("response") - val fileUrl = - responseData - ?.optJSONArray( - "publishedfiledetails", - )?.optJSONObject(0) - ?.optString("file_url", "") - ?.trim() - if (!fileUrl.isNullOrEmpty()) { - val configFile = File(appDirPath, STEAM_CONTROLLER_CONFIG_FILENAME) - val downloadRequest = - Request - .Builder() - .url(fileUrl) - .get() - .build() - Net.http.newCall(downloadRequest).execute().use { downloadResponse -> - if (downloadResponse.isSuccessful) { - downloadResponse.body?.byteStream()?.use { input -> - configFile.outputStream().use { output -> - input.copyTo(output) - } - } - } - } - } - } - } - } - } - } - } - - // Run each app batch through the native downloader (downloadApp() runs on a native worker thread); suspendCancellableCoroutine bridges WnDownloadListener.onComplete back here. Progress sets di's absolute byte count from the sum of every depot's cumulative bytes. - Timber.i("Downloading game to $appDirPath (attempt $attempt)") - val wnDepotBytes = java.util.concurrent.ConcurrentHashMap() - for ((depotId, bytes) in di.depotCumulativeUncompressedBytes) { - if (depotId in selectedDepots) { - val initialBytes = bytes.get().coerceAtLeast(0L) - if (initialBytes > 0L) { - wnDepotBytes[depotId] = initialBytes - } - } - } - val wnGlobalPrev = - java.util.concurrent.atomic.AtomicLong(wnDepotBytes.values.sum()) - // Throttle DownloadRecord progress persistence. - val wnLastPersistMs = java.util.concurrent.atomic.AtomicLong(0L) - for (batch in wnBatches) { - val (batchAppId, batchDepotIds, batchManifestIds) = batch - kotlinx.coroutines.suspendCancellableCoroutine { cont -> - wnSessionForDownload.downloadApp( - batchAppId, - batchDepotIds, - batchManifestIds, - branch, - appDirPath, - isFreshDownload, - caPath, - // "Download Speed" setting → parallel chunk-download worker count. - PrefManager.downloadSpeed, - object : WnDownloadListener { - override fun onProgress( - depotId: Int, - depotDone: Long, - depotTotal: Long, - depotsDone: Int, - depotsTotal: Int, - verifying: Boolean, - ) { - // The native worker may fire late callbacks after a pause/cancel (before it unwinds); ignore them or they'd overwrite PAUSED back to DOWNLOADING. - if (!di.isActive()) return - // Record per-depot cumulative bytes so the throttled snapshot (depot_bytes.json) restores the real % on resume; verification reports bytes from 0 each resume, so never let it lower a previously persisted count (quick pause/resume during VERIFYING would rewrite the snapshot to a partial scan). - val depotBytes = - di.depotCumulativeUncompressedBytes - .getOrPut(depotId) { - java.util.concurrent.atomic.AtomicLong(0L) - } - val observedDepotDone = depotDone.coerceAtLeast(0L) - var monotonicDepotDone: Long - while (true) { - val currentDepotDone = depotBytes.get() - monotonicDepotDone = maxOf(currentDepotDone, observedDepotDone) - if (monotonicDepotDone == currentDepotDone || - depotBytes.compareAndSet(currentDepotDone, monotonicDepotDone) - ) { - break - } - } - wnDepotBytes[depotId] = monotonicDepotDone - di.markProgressSnapshotDirty() - val g = wnDepotBytes.values.sum() - val delta = g - wnGlobalPrev.getAndSet(g) - if (delta > 0L) di.updateBytesDownloaded(delta) - val statusTick = - if (verifying && observedDepotDone < monotonicDepotDone) { - "$g/$observedDepotDone" - } else { - g.toString() - } - // Phase from the native verifying flag: VERIFYING while validating on-disk content, DOWNLOADING while fetching. The status carries a unique suffix (g) each tick because a StateFlow dedups equal values, so a constant message would freeze the live byte count/speed — a changing one forces recomposition. - di.updateStatus( - if (verifying) { - DownloadPhase.VERIFYING - } else { - DownloadPhase.DOWNLOADING - }, - if (verifying) { - "Verifying depot $depotId ($statusTick)" - } else { - "Downloading depot $depotId ($statusTick)" - }, - ) - di.emitProgressChange() - // Persist progress to the DownloadRecord (throttled 3s) so an app restart restores the real % instead of 0. - val nowMs = System.currentTimeMillis() - if (nowMs - wnLastPersistMs.get() >= 3000L) { - wnLastPersistMs.set(nowMs) - val (dispDone, dispTotal) = - di.getDisplayBytesProgress() - DownloadCoordinator.updateProgress( - DownloadRecord.STORE_STEAM, - appId.toString(), - dispDone, - dispTotal, - ) - } - } - - override fun onComplete( - success: Boolean, - error: String, - bytesWritten: Long, - depotsCompleted: Int, - depotsSkipped: Int, - ) { - if (!cont.isActive) return - if (success) { - cont.resumeWith(Result.success(Unit)) - } else if (!di.isActive() || di.isCancelling) { - // Pause/cancel aborted the native download — resume normally; the post-await barrier classifies it as PAUSED/CANCELLED, not a spurious FAILED. - cont.resumeWith(Result.success(Unit)) - } else { - cont.resumeWith( - Result.failure( - WnDownloadTransientException( - "WN download failed (app $batchAppId): $error", - ), - ), - ) - } - } - }, - ) - // Pause/cancel cancels this coroutine — abort the native worker so it stops promptly instead of running on in the background. - cont.invokeOnCancellation { - runCatching { wnSessionForDownload.cancelDownload() } - } - } - } - - // Hard barrier: re-check cancellation even when the await returned cleanly — completion can fire as a side-effect of pending chunks being cancelled, and in that race we must NOT run completeAppDownload (it would set COMPLETE for a paused/partial install). - coroutineContext.ensureActive() - if (!di.isActive() || di.isCancelling) { - Timber.i( - "DepotDownloader completion returned but DownloadInfo is no longer active " + - "(isActive=${di.isActive()}, isCancelling=${di.isCancelling}). " + - "Skipping completeAppDownload — the user paused or cancelled.", - ) - throw CancellationException( - if (di.isCancelling) "Cancelled by user" else "Paused by user", - ) - } - - Timber.i("DepotDownloader finished for appId: $appId") - - // If it was extremely fast (e.g. already downloaded), ensure some visibility in UI - if (di.getProgress() >= 1.0f) { - delay(1000) - } - - // If we got here without exception, download succeeded - break - } catch (e: AsyncJobFailedException) { - Timber.w(e, "AsyncJobFailedException on attempt $attempt/$maxRetries for appId: $appId") - if (attempt >= maxRetries) { - Timber.e("All $maxRetries retry attempts failed for appId: $appId") - throw e - } - di.setActive(true) - continue - } catch (e: Exception) { - if (e is CancellationException) throw e - if (!di.isActive() || di.isCancelling) throw e - // Only retry the depot-transfer phase; entitlement/manifest/session errors won't fix themselves — fail fast. - if (e !is WnDownloadTransientException) throw e - Timber.w(e, "Transient WN download failure on attempt $attempt/$maxRetries for appId: $appId") - if (attempt >= maxRetries) { - Timber.e("All $maxRetries retry attempts failed for appId: $appId") - throw e - } - // Force-flush the byte snapshot so the next attempt resumes from the same offset instead of re-validating. - runCatching { di.persistProgressSnapshot(force = true) } - runCatching { updateCoordinatorDownloadProgress(di) } - // Failed batch's listener sets isActive=false; restore it so the next attempt's onProgress doesn't bail. - di.setActive(true) - continue - } - } - - // Complete app download - Wrap in try-catch to ensure we don't crash at the finish line - try { - di.updateStatusMessage("Finalizing installation") - Timber.i("Finalizing installation at path: $appDirPath") - - // Refuse to mark COMPLETE unless every depot fetched this run is recorded as finished at the expected manifest id in depot.config. - val deniedDepots = readDeniedDepots(appDirPath) - if (deniedDepots.isNotEmpty()) { - Timber.w( - "Completeness gate excluding ${deniedDepots.size} depot(s) Steam denied " + - "a key for appId=$appId: ${deniedDepots.sorted()}", - ) - } - val expectedManifestByDepot = - selectedDepots.mapNotNull { (depotId, depot) -> - // Steam-denied depots aren't part of this account's install. - if (depotId in deniedDepots) return@mapNotNull null - val gid = resolveDepotManifestInfo(depot, branch)?.gid ?: 0L - if (gid > 0L) depotId to gid else null - }.toMap() - val completenessFailures = - verifyDepotConfigComplete(appDirPath, expectedManifestByDepot) - if (completenessFailures.isNotEmpty()) { - Timber.e( - "COMPLETENESS GATE FAILED for appId=$appId task=$downloadTaskType at $appDirPath: " + - "${completenessFailures.size}/${expectedManifestByDepot.size} depot(s) not fully " + - "installed — refusing to mark COMPLETE. Details: ${completenessFailures.take(30)}", - ) - // Keep resume state so a resume re-fetches the missing depots. - runCatching { di.persistProgressSnapshot(force = true) } - di.updateStatus( - DownloadPhase.FAILED, - "Install incomplete: ${completenessFailures.size} depot(s) missing — resume to finish", - ) - di.setActive(false) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - if (downloadTaskType == DownloadRecord.TASK_UPDATE) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - appId.toString(), - DownloadRecord.STATUS_FAILED, - "incomplete: ${completenessFailures.size} depot(s) missing", - ) - } - removeDownloadJob(appId) - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show( - service.applicationContext, - "Download incomplete — some files are missing. Resume to finish.", - Toast.LENGTH_LONG, - ) - } - } - PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) - return@launch - } - - if (originalMainAppDepots.isNotEmpty()) { - val mainAppDepotIds = originalMainAppDepots.keys.sorted() - completeAppDownload(di, appId, mainAppDepotIds, mainAppDlcIds, appDirPath) - } - - calculatedDlcAppIds.forEach { dlcAppId -> - val dlcDepotIds = selectedDlcDepotIdsByDlcAppId[dlcAppId].orEmpty() - completeAppDownload(di, dlcAppId, dlcDepotIds, emptyList(), appDirPath) - } - Timber.i("Installation finalized for appId: $appId") - - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show(service.applicationContext, "Download complete", Toast.LENGTH_SHORT) - Unit - } - } - } catch (e: Exception) { - Timber.e(e, "Error during finalize/database update for appId: $appId") - throw e - } - - removeDownloadJob(appId) - - runBlocking { - instance?.downloadingAppInfoDao?.deleteApp(appId) - Unit - } - Unit - } catch (e: DownloadFailedException) { - Timber.d(e, "Download failed for app $appId via cancellation") - clearFailedResumeState(appId) - di.updateStatus(DownloadPhase.FAILED) - di.setActive(false) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - if (downloadTaskType == DownloadRecord.TASK_UPDATE) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - appId.toString(), - DownloadRecord.STATUS_FAILED, - e.message, - ) - } - removeDownloadJob(appId) - return@launch - } catch (e: CancellationException) { - if (di.isDeleting) { - Timber.d("Download cancelled for deletion for app $appId") - return@launch - } - - if (di.isCancelling) { - Timber.d("Download cancelled by user for app $appId") - di.persistProgressSnapshot(force = true) - di.updateStatus(DownloadPhase.CANCELLED) - di.setActive(false) - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - appId.toString(), - DownloadRecord.STATUS_CANCELLED, - ) - } - throw e - } - - Timber.d(e, "Download paused for app $appId") - // Keep downloadingAppInfo on cancellation so resume does not fall into verify mode. - di.persistProgressSnapshot(force = true) - di.updateStatus(DownloadPhase.PAUSED) - di.setActive(false) - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - appId.toString(), - DownloadRecord.STATUS_PAUSED, - ) - } - throw e - } catch (e: Exception) { - Timber.e(e, "Download failed for app $appId") - // Transient failures keep resume state so Retry continues from the same offset. - val isTransientFailure = e is WnDownloadTransientException - if (isTransientFailure) { - runCatching { di.persistProgressSnapshot(force = true) } - } else { - clearFailedResumeState(appId) - } - - val errorMsg = - when (e) { - is ClassCastException -> "Casting error: ${e.message}" - is NullPointerException -> "Null reference: ${e.message}" - else -> e.localizedMessage ?: e.message ?: e.javaClass.simpleName - } - - di.updateStatus(DownloadPhase.FAILED, errorMsg) - di.setActive(false) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - if (downloadTaskType == DownloadRecord.TASK_UPDATE) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - } - if (!isTransientFailure) { - runBlocking { - instance?.downloadingAppInfoDao?.deleteApp(appId) - Unit - } - } - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - appId.toString(), - DownloadRecord.STATUS_FAILED, - errorMsg, - ) - } - removeDownloadJob(appId) - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show(service.applicationContext, "Download failed: $errorMsg", Toast.LENGTH_LONG) - Unit - } - } - PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) - Unit - } finally { - // Tear down a session this worker brought up itself. - workerWnSession?.let { ws -> - runCatching { ws.disconnect() } - runCatching { ws.close() } - Timber.i("downloadApp: closed worker WN-Steam session for app $appId") - } - workerWnSession = null - runCatching { - SessionKeepAliveService.stopDownload(keepAliveCtx, keepAliveTag) - }.onFailure { e -> - Timber.w(e, "Failed to release keep-alive for Steam download $appId") - } - Unit - } - Unit - } - downloadJob.invokeOnCompletion { throwable -> - if (throwable is CancellationException && throwable !is DownloadFailedException) { - if (di.isDeleting) { - // Deletion handled externally - } else if (di.isCancelling) { - // Keep in downloadJobs for UI visibility, but still check queue - checkQueue() - } else { - Timber.d(throwable, "Download paused for app $appId") - removeDownloadJob(appId) - } - } - } - di.setDownloadJob(downloadJob) - } - - downloadJobs[appId] = info - info.updateStatus(DownloadPhase.PREPARING) - notifyDownloadStarted(appId) - return info - } - - private fun finalizeSnapshotResumeAsComplete( - appId: Int, - appDirPath: String, - mainAppDepots: Map, - dlcAppDepots: Map, - userSelectedDlcAppIds: List, - ): DownloadInfo { - val downloadingAppIds = CopyOnWriteArrayList() - val calculatedDlcAppIds = CopyOnWriteArrayList() - val allDepotIdsByDlcAppId = - dlcAppDepots.values - .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) - .mapValues { (_, depotIds) -> depotIds.sorted() } - - userSelectedDlcAppIds.forEach { dlcAppId -> - if (allDepotIdsByDlcAppId[dlcAppId]?.isNotEmpty() == true) { - downloadingAppIds.add(dlcAppId) - calculatedDlcAppIds.add(dlcAppId) - } - } - - if (mainAppDepots.isNotEmpty() && !downloadingAppIds.contains(appId)) { - downloadingAppIds.add(appId) - } - - val info = DownloadInfo(1, appId, downloadingAppIds) - info.setPersistencePath(appDirPath) - info.updateStatus(DownloadPhase.COMPLETE) - info.setProgress(1f) - downloadJobs[appId] = info - notifyDownloadStarted(appId) - - val selectedDlcAppIdSet = userSelectedDlcAppIds.toSet() - val mainAppDlcIds = - getMainAppDlcIdsWithoutProperDepotDlcIds(appId) - .filterTo(mutableListOf()) { it in selectedDlcAppIdSet } - mainAppDlcIds.addAll( - mainAppDepots.values - .map { it.dlcAppId } - .filter { it != INVALID_APP_ID && it in selectedDlcAppIdSet } - .distinct(), - ) - if (dlcAppDepots.isEmpty()) { - mainAppDlcIds.addAll( - mainAppDepots - .filter { it.value.dlcAppId != INVALID_APP_ID && it.value.dlcAppId in selectedDlcAppIdSet } - .map { it.value.dlcAppId } - .distinct(), - ) - } - mainAppDlcIds.addAll(calculatedDlcAppIds.filter { it !in mainAppDlcIds }) - - runBlocking(Dispatchers.IO) { - if (mainAppDepots.isNotEmpty()) { - completeAppDownload( - downloadInfo = info, - downloadingAppId = appId, - entitledDepotIds = mainAppDepots.keys.sorted(), - selectedDlcAppIds = mainAppDlcIds, - appDirPath = appDirPath, - ) - } - - calculatedDlcAppIds.forEach { dlcAppId -> - val dlcDepotIds = allDepotIdsByDlcAppId[dlcAppId].orEmpty() - completeAppDownload( - downloadInfo = info, - downloadingAppId = dlcAppId, - entitledDepotIds = dlcDepotIds, - selectedDlcAppIds = emptyList(), - appDirPath = appDirPath, - ) - } - - instance?.downloadingAppInfoDao?.deleteApp(appId) - Unit - } - - // Show success message to user for no-op/resume completion - instance?.let { service -> - service.scope.launch(Dispatchers.Main) { - WinToast.show(service.applicationContext, "Download complete", Toast.LENGTH_SHORT) - Unit - } - } - return info - } - - /** Returns one description per [expectedManifestByDepot] entry not finished at the expected manifest id in depot.config (missing, in-progress, or wrong manifest); an absent/unreadable config passes (legacy installs predate it). */ - /** Depot ids Steam denied a key for on the last native run (.DepotDownloader/denied.depots). */ - private fun readDeniedDepots(appDirPath: String): Set = - runCatching { - val file = File(File(appDirPath, ".DepotDownloader"), "denied.depots") - if (!file.isFile) return@runCatching emptySet() - file.readLines().mapNotNull { it.trim().toIntOrNull() }.toSet() - }.getOrDefault(emptySet()) - - private fun verifyDepotConfigComplete( - appDirPath: String, - expectedManifestByDepot: Map, - ): List { - if (expectedManifestByDepot.isEmpty()) return emptyList() - val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") - val installed: Map? = - runCatching { - if (!configFile.isFile) return@runCatching null - val ids = JSONObject(configFile.readText()).optJSONObject("installedManifestIDs") - ?: return@runCatching emptyMap() - buildMap { - for (key in ids.keys()) { - val depotId = key.toIntOrNull() ?: continue - put(depotId, ids.optLong(key, 0L)) - } - } - }.getOrNull() - - if (installed == null) { - Timber.w( - "Completeness gate: depot.config missing/unreadable at $configFile for " + - "${expectedManifestByDepot.size} expected depot(s); treating as pass (legacy install?)", - ) - return emptyList() - } - - val invalidManifestId = 0x7fffffffffffffffL - val failures = mutableListOf() - for ((depotId, expectedGid) in expectedManifestByDepot.toSortedMap()) { - when (val recorded = installed[depotId]) { - null -> failures.add("depot $depotId missing (expected manifest $expectedGid)") - invalidManifestId -> failures.add("depot $depotId still in-progress (expected $expectedGid)") - expectedGid -> Unit - else -> failures.add("depot $depotId recorded at manifest $recorded, expected $expectedGid") - } - } - if (failures.isEmpty()) { - Timber.i( - "Completeness gate passed: ${expectedManifestByDepot.size} depot(s) fully recorded in depot.config at $appDirPath", - ) - } - return failures - } - - /** Read-only diagnostic: logs each base depot of [appId], whether it reached [selectedDepots] and the first rule that dropped it, warning when a base content depot is dropped. */ - private fun logDepotScopeDiagnostics( - appId: Int, - branch: String, - selectedDepots: Map, - ) { - runCatching { - val appInfo = getAppInfoOf(appId) ?: return - val preferredLanguage = PrefManager.containerLanguage - val entitledDepotIds = getEntitledDepotIds(appInfo.packageId) - val has64Bit = - appInfo.depots.values.any { - it.osArch == OSArch.Arch64 && - (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) - } - val groupedBaseDlcDepotIds = getGroupedBaseAppDlcContentDepotIds(appInfo) - val baseEntitledDepotIds = getEntitledDepotIds(appInfo.packageId).orEmpty() - - fun exclusionReason(depotId: Int, depot: DepotInfo): String { - if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) return "encrypted-only-manifest" - if (depot.manifests.isEmpty() && !depot.sharedInstall) return "no-manifest" - if (resolveDepotManifestInfo(depot, branch) == null) return "manifest-unresolved(branch=$branch)" - val osOk = - depot.osList.contains(OS.windows) || - (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)) - if (!osOk) return "os-excluded(osList=${depot.osList})" - val archOk = - when (depot.osArch) { - OSArch.Arch64, OSArch.Unknown -> true - OSArch.Arch32 -> !has64Bit - else -> false - } - if (!archOk) return "arch-excluded(osArch=${depot.osArch},has64Bit=$has64Bit)" - if (depot.language.isNotEmpty() && !depot.language.equals(preferredLanguage, ignoreCase = true)) { - return "language-mismatch(depot='${depot.language}',preferred='$preferredLanguage')" - } - if (!isDepotEntitled(depotId, depot, entitledDepotIds)) return "not-entitled" - if (depotId in groupedBaseDlcDepotIds && depotId !in baseEntitledDepotIds) return "grouped-as-dlc-content" - return "excluded-outside-base-filters" - } - - val baseDepots = appInfo.depots.filter { it.value.dlcAppId == INVALID_APP_ID } - var maxBaseContentBytes = 0L - var selectedBaseBytes = 0L - val droppedBaseContent = mutableListOf() - Timber.i( - "DEPOT-DIAG appId=$appId branch=$branch baseDepots=${baseDepots.size} " + - "selected=${selectedDepots.size} has64Bit=$has64Bit preferredLang='$preferredLanguage' " + - "entitled=${entitledDepotIds?.sorted()} groupedAsDlc=${groupedBaseDlcDepotIds.sorted()}", - ) - for ((depotId, depot) in baseDepots) { - val manifest = resolveDepotManifestInfo(depot, branch) - val size = manifest?.size ?: 0L - val included = depotId in selectedDepots - val reason = if (included) null else exclusionReason(depotId, depot) - if (manifest != null) maxBaseContentBytes += size - if (included) { - selectedBaseBytes += size - } else if (manifest != null && size > 0L) { - droppedBaseContent.add("depot=$depotId size=$size reason=$reason") - } - Timber.i( - "DEPOT-DIAG base depot=$depotId included=$included size=$size gid=${manifest?.gid} " + - "osList=${depot.osList} osArch=${depot.osArch} lang='${depot.language}' " + - "shared=${depot.sharedInstall} fromApp=${depot.depotFromApp}" + - (if (reason != null) " DROP=$reason" else ""), - ) - } - if (droppedBaseContent.isNotEmpty()) { - Timber.w( - "DEPOT-DIAG appId=$appId DROPPED ${droppedBaseContent.size} base content depot(s): " + - "selectedBaseBytes=$selectedBaseBytes maxBaseContentBytes=$maxBaseContentBytes " + - "(maxBase double-counts redundant 32/64-bit variants) -> $droppedBaseContent", - ) - } - }.onFailure { e -> Timber.w(e, "DEPOT-DIAG failed for appId=$appId") } - } - - private suspend fun completeAppDownload( - downloadInfo: DownloadInfo, - downloadingAppId: Int, - entitledDepotIds: List, - selectedDlcAppIds: List, - appDirPath: String, - ) { - Timber.i("Item $downloadingAppId download completed, saving database") - Timber.i( - "Steam DLC downloaded item: baseAppId=${downloadInfo.gameId} completedAppId=$downloadingAppId " + - "entitledDepotIds=${entitledDepotIds.sorted()} selectedDlcAppIds=${selectedDlcAppIds.sorted()} " + - "remainingAppIds=${downloadInfo.downloadingAppIds.sorted()}", - ) - - // runCatching: a transient Room failure on one DLC row shouldn't FAIL the whole download — bytes are on disk; stale-metadata recovery fixes the row on next launch. - runCatching { - val appInfo = instance?.appInfoDao?.getInstalledApp(downloadingAppId) - if (appInfo != null) { - val updatedDownloadedDepots = (appInfo.downloadedDepots + entitledDepotIds).distinct() - val updatedDlcDepots = (appInfo.dlcDepots + selectedDlcAppIds).distinct() - - instance?.appInfoDao?.update( - AppInfo( - downloadingAppId, - isDownloaded = true, - downloadedDepots = updatedDownloadedDepots.sorted(), - dlcDepots = updatedDlcDepots.sorted(), - ), - ) - } else { - instance?.appInfoDao?.insert( - AppInfo( - downloadingAppId, - isDownloaded = true, - downloadedDepots = entitledDepotIds.sorted(), - dlcDepots = selectedDlcAppIds.sorted(), - ), - ) - } - }.onFailure { e -> - Timber.e( - e, - "DB write failed for completed item $downloadingAppId (baseApp=${downloadInfo.gameId}); " + - "files are on disk, continuing finalize anyway.", - ) - } - - // Remove completed appId from downloadInfo.dlcAppIds and check if it was actually removed - val wasRemoved = downloadInfo.downloadingAppIds.remove(downloadingAppId) - if (!wasRemoved) { - Timber.d("Item $downloadingAppId was already removed from downloading list, skipping redundant completion.") - return - } - - // All downloading appIds are removed - if (downloadInfo.downloadingAppIds.isEmpty()) { - Timber.i("All items for game ${downloadInfo.gameId} completed, running final completion logic.") - Timber.i( - "Steam DLC download complete: appId=${downloadInfo.gameId} " + - "downloadedBytes=${downloadInfo.getBytesDownloaded()} totalBytes=${downloadInfo.getTotalExpectedBytes()}", - ) - // Settle remaining bytes at the end so progress doesn't sit under 100% when complete (e.g. dedup-skipped chunks that never reported via onChunkCompleted). - val totalExpectedBytes = downloadInfo.getTotalExpectedBytes() - if (totalExpectedBytes > 0L) { - val downloadedBytes = downloadInfo.getBytesDownloaded() - val remainingBytes = (totalExpectedBytes - downloadedBytes).coerceAtLeast(0L) - if (remainingBytes > 0L) { - downloadInfo.updateBytesDownloaded(remainingBytes, System.currentTimeMillis()) - downloadInfo.emitProgressChange() - updateCoordinatorDownloadProgress(downloadInfo) - } - } - - // Defensive wrapping per marker — bytes are on disk, a single marker/DB write failing shouldn't flip the game to FAILED. - withContext(Dispatchers.IO) { - val markerAdded = - runCatching { MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) } - .getOrElse { e -> - Timber.e(e, "Failed to add DOWNLOAD_COMPLETE_MARKER at $appDirPath") - false - } - if (!markerAdded) { - Timber.e( - "DOWNLOAD_COMPLETE_MARKER write returned false for appId=${downloadInfo.gameId} at $appDirPath " + - "(disk full / permissions?). Game files are on disk but next launch may re-validate.", - ) - } - runCatching { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) } - runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } - runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } - runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } - runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } - - // Same reason as above: a Room exception here used to FAIL a fully-downloaded game with the COMPLETE marker already on disk. - val mainAppId = downloadInfo.gameId - val service = instance - if (service != null) { - runCatching { - val mainAppInfo = service.appInfoDao.getInstalledApp(mainAppId) - if (mainAppInfo != null) { - val updatedMainDlcDepots = - (mainAppInfo.dlcDepots + selectedDlcAppIds).distinct().sorted() - service.appInfoDao.update( - mainAppInfo.copy( - isDownloaded = true, - dlcDepots = updatedMainDlcDepots, - ), - ) - Timber.i( - "Marked main app $mainAppId as downloaded in DB with dlcDepots=$updatedMainDlcDepots", - ) - } else { - service.appInfoDao.insert( - AppInfo( - mainAppId, - isDownloaded = true, - dlcDepots = selectedDlcAppIds.distinct().sorted(), - ), - ) - Timber.i( - "Inserted main app $mainAppId as downloaded in DB with dlcDepots=${selectedDlcAppIds.distinct().sorted()}", - ) - } - }.onFailure { e -> - Timber.e( - e, - "Database write failed during finalize for appId=$mainAppId — bytes are on disk, " + - "marker write ${if (markerAdded) "succeeded" else "FAILED"}; download will still be marked COMPLETE.", - ) - } - } - Unit - } - - val service = instance - if (service != null) { - createSteamShortcut(service, downloadInfo.gameId) - } - - // Mark inactive BEFORE updating status so checkQueue() frees this slot — else isActive() stays true and blocks the queue until manually cleared. - downloadInfo.setActive(false) - downloadInfo.updateStatus(DownloadPhase.COMPLETE) - PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(downloadInfo.gameId)) - - downloadInfo.clearPersistedBytesDownloaded(appDirPath, sync = true) - // Notify the coordinator to advance the cross-store queue and persist COMPLETE. - runBlocking { - DownloadCoordinator.notifyFinished( - DownloadRecord.STORE_STEAM, - downloadInfo.gameId.toString(), - DownloadRecord.STATUS_COMPLETE, - ) - } - checkQueue() - } - Unit - } - - private fun updateCoordinatorDownloadProgress(downloadInfo: DownloadInfo) { - val (displayDownloadedBytes, displayTotalBytes) = downloadInfo.getDisplayBytesProgress() - DownloadCoordinator.updateProgress( - DownloadRecord.STORE_STEAM, - downloadInfo.gameId.toString(), - displayDownloadedBytes, - displayTotalBytes, - ) - } - - fun getWindowsLaunchInfos(appId: Int): List = - getAppInfoOf(appId) - ?.let { appInfo -> - appInfo.config.launch.filter { launchInfo -> - // since configOS was unreliable and configArch was even more unreliable - launchInfo.executable.endsWith(".exe") - } - }.orEmpty() - - suspend fun notifyRunningProcesses(vararg gameProcesses: GameProcessInfo) = - withContext(Dispatchers.IO) { - instance?.let { steamInstance -> - if (isConnected) { - val gamesPlayed = - gameProcesses.mapNotNull { gameProcess -> - getAppInfoOf(gameProcess.appId)?.let { appInfo -> - getPkgInfoOf(gameProcess.appId)?.let { pkgInfo -> - appInfo.branches[gameProcess.branch]?.let { branch -> - val processId = - gameProcess.processes - .firstOrNull { it.parentIsSteam } - ?.processId - ?: gameProcess.processes.firstOrNull()?.processId - ?: 0 - - val userAccountId = userSteamId!!.accountID.toInt() - GamePlayedInfo( - gameId = gameProcess.appId.toLong(), - processId = processId, - ownerId = - if (pkgInfo.ownerAccountId.contains(userAccountId)) { - userAccountId - } else { - pkgInfo.ownerAccountId.first() - }, - // Unknown Steam launch source; keep observed value. - launchSource = 100, - gameBuildId = branch.buildId.toInt(), - processIdList = gameProcess.processes, - ) - } - } - } - } - - Timber.i( - "GameProcessInfo:%s", - gamesPlayed.joinToString("\n") { game -> - """ - | processId: ${game.processId} - | gameId: ${game.gameId} - | processes: ${ - game.processIdList.joinToString("\n") { process -> - """ - | processId: ${process.processId} - | processIdParent: ${process.processIdParent} - | parentIsSteam: ${process.parentIsSteam} - """.trimMargin() - } - } - """.trimMargin() - }, - ) - - // Report running games via the C++ WN-Steam-Client. - val gamesJson = JSONArray() - gamesPlayed.forEach { g -> - val procs = JSONArray() - g.processIdList.forEach { p -> - procs.put( - JSONObject() - .put("pid", p.processId) - .put("ppid", p.processIdParent) - .put("isSteam", p.parentIsSteam), - ) - } - gamesJson.put( - JSONObject() - .put("gameId", g.gameId) - .put("processId", g.processId) - .put("ownerId", g.ownerId) - .put("launchSource", g.launchSource) - .put("gameBuildId", g.gameBuildId) - .put("processes", procs), - ) - } - withWnSession { session -> - withContext(Dispatchers.IO) { - session.notifyGamesPlayed( - gamesJson.toString(), - EOSType.AndroidUnknown.code(), - ) - } - } - } - } - } - - fun beginLaunchApp( - appId: Int, - parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), - ignorePendingOperations: Boolean = false, - preferredSave: SaveLocation = SaveLocation.None, - prefixToPath: (String) -> String, - isOffline: Boolean = false, - onProgress: ((message: String, progress: Float) -> Unit)? = null, - ): Deferred = - parentScope.async { - if (isOffline || !isConnected) { - return@async PostSyncInfo(SyncResult.UpToDate) - } - if (!tryAcquireSync(appId)) { - Timber.w("Cannot launch app when sync already in progress for appId=$appId") - return@async PostSyncInfo(SyncResult.InProgress) - } - - try { - val progressWrapper: (String, Float) -> Unit = { msg, prog -> - cloudSyncStatus.value = CloudSyncMessage(appId, false, msg, prog) - onProgress?.invoke(msg, prog) - } - var syncResult = PostSyncInfo(SyncResult.UnknownFail) + try { + val progressWrapper: (String, Float) -> Unit = { msg, prog -> + cloudSyncStatus.value = CloudSyncMessage(appId, false, msg, prog) + onProgress?.invoke(msg, prog) + } + var syncResult = PostSyncInfo(SyncResult.UnknownFail) val maxAttempts = 3 for (attempt in 1..maxAttempts) { @@ -5008,60 +2272,6 @@ class SteamService : Service() { Timber.w(e, "Failed to generate achievements for appId=$appId") } - private fun resolvePreferredLaunchBuildId( - app: SteamApp?, - branch: String, - ): Int { - val buildId = - app?.branches?.get(branch)?.buildId - ?: app?.branches?.get("public")?.buildId - ?: app?.branches?.values?.firstOrNull()?.buildId - ?: 0L - return buildId.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() - } - - private fun resolvePreferredLaunchDepotIds( - appId: Int, - branch: String, - preferredLanguage: String = PrefManager.containerLanguage, - ): IntArray { - val trustedInstalledDepots = - getInstalledApp(appId) - ?.downloadedDepots - .orEmpty() - .asSequence() - .filter { it > 0 } - .distinct() - .sorted() - .toList() - if (trustedInstalledDepots.isNotEmpty()) { - return trustedInstalledDepots.toIntArray() - } - - val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty() - val fallbackSelectedDepots = - getSelectedDownloadDepots( - appId = appId, - userSelectedDlcAppIds = installedDlcAppIds, - preferredLanguage = preferredLanguage, - branch = branch, - ).keys - .asSequence() - .filter { it > 0 } - .distinct() - .sorted() - .toList() - if (fallbackSelectedDepots.isNotEmpty()) { - Timber.w( - "resolvePreferredLaunchDepotIds: appId=$appId branch=$branch " + - "had no trusted depot snapshot; using ${fallbackSelectedDepots.size} selected depot(s)", - ) - return fallbackSelectedDepots.toIntArray() - } - - return IntArray(0) - } - fun pushAppInstalledDepotsToLibSteamClient(appId: Int) = runCatching { if (appId <= 0) return@runCatching val depots = resolvePreferredLaunchDepotIds( @@ -5131,90 +2341,6 @@ class SteamService : Service() { Timber.w(e, "pushAppDlcsToLibSteamClient failed (appId=$appId)") } - private suspend fun primeLibSteamClientLaunchState( - appId: Int, - selectedBranch: String, - ): Boolean { - val svc = instance ?: return false - val ctx = svc.applicationContext - val libSteamClient = com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - if (!libSteamClient.ensureLoaded(ctx)) { - Timber.w("primeLibSteamClientLaunchState: failed to load bridge library for appId=$appId") - return false - } - libSteamClient.seedFromPrefManager(ctx) - - val manifestBranch = selectedBranch.ifBlank { "public" } - val app = withContext(Dispatchers.IO) { svc.appDao.findApp(appId) } - val rawInstalledApp = withContext(Dispatchers.IO) { svc.appInfoDao.getInstalledApp(appId) } - val ownedIds = withContext(Dispatchers.IO) { svc.appDao.getAllAppIds() } - val installedIds = - withContext(Dispatchers.IO) { svc.appInfoDao.getAllInstalledAppIds().toMutableSet() } - .apply { - if (rawInstalledApp?.isDownloaded == true) { - add(appId) - } - } - - libSteamClient.setAppId(appId) - if (ownedIds.isNotEmpty()) { - libSteamClient.setOwnedApps(ownedIds.toIntArray()) - } - if (installedIds.isNotEmpty()) { - libSteamClient.setInstalledApps(installedIds.sorted().toIntArray()) - } - - app?.name?.takeIf { it.isNotBlank() }?.let { appName -> - libSteamClient.setAppNames(intArrayOf(appId), arrayOf(appName)) - } - - val installDir = runCatching { getAppDirPath(appId) }.getOrNull() - if (!installDir.isNullOrEmpty()) { - libSteamClient.setAppInstallDir(appId, installDir) - } - - val buildId = resolvePreferredLaunchBuildId(app, manifestBranch) - if (buildId > 0) { - libSteamClient.setAppBuildId(appId, buildId) - } - - val depotIds = resolvePreferredLaunchDepotIds(appId, manifestBranch) - if (depotIds.isNotEmpty()) { - libSteamClient.setAppInstalledDepots(appId, depotIds) - } - - app?.packageId - ?.takeIf { it != INVALID_PKG_ID } - ?.let { packageId -> - libSteamClient.setAppSourcePackages(appId, intArrayOf(packageId)) - } - - val accountId = runCatching { - com.winlator.cmod.feature.stores.steam.utils - .SteamUtils.getSteam3AccountId().toLong() - }.getOrNull() ?: 0L - if (accountId > 0L) { - val remoteDir = runCatching { - com.winlator.cmod.feature.stores.steam.enums - .PathType.SteamUserData.toAbsPath( - svc, - appId, - accountId, - ) - }.getOrNull() - if (!remoteDir.isNullOrEmpty()) { - libSteamClient.setAppCloudRemoteDir(appId, remoteDir) - } - } - - libSteamClient.setAppCurrentBeta(appId, selectedBranch) - Timber.i( - "primeLibSteamClientLaunchState: app=$appId branch=$manifestBranch " + - "buildId=$buildId depots=${depotIds.size} owned=${ownedIds.size} installed=${installedIds.size}", - ) - return true - } - fun pushAppWorkshopItemsToLibSteamClient(appId: Int) = runCatching { if (appId <= 0) return@runCatching val ctx = instance?.applicationContext ?: return@runCatching @@ -5506,173 +2632,6 @@ class SteamService : Service() { Timber.w(e, "refreshCloudQuotaForLibSteamClient failed") } - private fun pushAchievementSchemaToLibSteamClient( - appId: Int, - achievements: List, - stats: List, - nameToBlockBit: Map> = emptyMap(), - ) { - val locale = PrefManager.containerLanguage.ifBlank { "english" } - fun pick(map: Map?): String = - map?.get(locale) ?: map?.get("english") ?: map?.values?.firstOrNull() ?: "" - - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppId(appId) - - val n = achievements.size - if (n == 0) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAchievementSchema(emptyArray(), emptyArray(), emptyArray(), emptyArray(), BooleanArray(0)) - Timber.i("Pushed empty achievement schema to libsteamclient.so (app $appId)") - return - } - - val apiNames = Array(n) { i -> achievements[i].name } - val displayNames = Array(n) { i -> pick(achievements[i].displayName) } - val descriptions = Array(n) { i -> pick(achievements[i].description) } - val icons = Array(n) { i -> achievements[i].icon ?: "" } - val hidden = BooleanArray(n) { i -> achievements[i].hidden != 0 } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAchievementSchema(apiNames, displayNames, descriptions, icons, hidden) - - val statNames = mutableListOf() - val statIds = mutableListOf() - for (s in stats) { - val id = s.id.toIntOrNull() ?: continue - if (s.name.isEmpty() || id < 0) continue - statNames.add(s.name) - statIds.add(id) - } - if (statNames.isNotEmpty()) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setStatIds(statNames.toTypedArray(), statIds.toIntArray()) - Timber.i("Pushed stat name→id map for app $appId: ${statNames.size} entries") - } - - if (nameToBlockBit.isNotEmpty()) { - val mappedNames = mutableListOf() - val mappedBlocks = mutableListOf() - val mappedBits = mutableListOf() - for (a in achievements) { - val pair = nameToBlockBit[a.name] ?: continue - mappedNames.add(a.name) - mappedBlocks.add(pair.first) - mappedBits.add(pair.second) - } - if (mappedNames.isNotEmpty()) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAchievementBlockBits( - mappedNames.toTypedArray(), - mappedBlocks.toIntArray(), - mappedBits.toIntArray(), - ) - Timber.i("Pushed achievement bit-pack mapping for app $appId: ${mappedNames.size} entries") - } - } - - var localeAddsPushed = 0 - for (a in achievements) { - val dnByLocale = a.displayName ?: emptyMap() - val dsByLocale = a.description ?: emptyMap() - val locales = dnByLocale.keys union dsByLocale.keys - for (loc in locales) { - if (loc.equals("english", ignoreCase = true)) continue - val dn = dnByLocale[loc] - val ds = dsByLocale[loc] - if (dn.isNullOrEmpty() && ds.isNullOrEmpty()) continue - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .addAchievementLocale(a.name, loc, dn, ds) - ++localeAddsPushed - } - } - - var unlocksPushed = 0 - for (a in achievements) { - val unlocked = a.unlocked == true - val ts = a.unlockTimestamp ?: 0 - if (unlocked || ts > 0) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAchievementProgress(a.name, unlocked, ts) - ++unlocksPushed - } - } - for (s in stats) { - when (s.type) { - "int" -> com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setStatInt(s.name, s.default.toIntOrNull() ?: 0) - "float" -> com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setStatFloat(s.name, s.default.toFloatOrNull() ?: 0f) - } - } - Timber.i("Pushed achievement schema to libsteamclient.so: app=$appId ach=$n unlocks=$unlocksPushed stats=${stats.size} localeAdds=$localeAddsPushed") - - cacheAchievementSchemaJson(appId, achievements, stats, nameToBlockBit) - } - - internal fun cacheAchievementSchemaJson( - appId: Int, - achievements: List, - stats: List, - nameToBlockBit: Map> = emptyMap(), - ) { - if (appId <= 0) return - val ctx = instance?.applicationContext ?: return - try { - val dir = File(ctx.filesDir, "wn_lsteam_schemas") - if (!dir.exists()) dir.mkdirs() - val root = JSONObject() - root.put("v", 1) - root.put("ts", System.currentTimeMillis() / 1000) - val achArr = JSONArray() - for (a in achievements) { - val o = JSONObject() - o.put("name", a.name) - a.displayName?.takeIf { it.isNotEmpty() }?.let { m -> - val mo = JSONObject() - m.forEach { (k, v) -> mo.put(k, v) } - o.put("displayName", mo) - } - a.description?.takeIf { it.isNotEmpty() }?.let { m -> - val mo = JSONObject() - m.forEach { (k, v) -> mo.put(k, v) } - o.put("description", mo) - } - a.icon?.takeIf { it.isNotEmpty() }?.let { o.put("icon", it) } - if (a.hidden != 0) o.put("hidden", a.hidden) - a.unlocked?.let { o.put("unlocked", it) } - a.unlockTimestamp?.let { o.put("unlockTimestamp", it) } - achArr.put(o) - } - root.put("achievements", achArr) - val statArr = JSONArray() - for (s in stats) { - val o = JSONObject() - o.put("id", s.id) - o.put("name", s.name) - o.put("type", s.type) - o.put("default", s.default) - statArr.put(o) - } - root.put("stats", statArr) - if (nameToBlockBit.isNotEmpty()) { - val bitsArr = JSONArray() - for ((name, pair) in nameToBlockBit) { - val o = JSONObject() - o.put("name", name) - o.put("block", pair.first) - o.put("bit", pair.second) - bitsArr.put(o) - } - root.put("nameToBlockBit", bitsArr) - } - File(dir, "$appId.json").writeText(root.toString(), Charsets.UTF_8) - Timber.i("Cached schema for app $appId: ${achievements.size} ach, " + - "${stats.size} stats, ${nameToBlockBit.size} bit-mappings") - } catch (t: Throwable) { - Timber.w(t, "cacheAchievementSchemaJson failed appId=$appId") - } - } - internal fun warmAchievementSchemaFromCache(appId: Int): Boolean { if (appId <= 0) return false val ctx = instance?.applicationContext ?: return false @@ -5841,38 +2800,6 @@ class SteamService : Service() { } } - private fun downloadWorkshopPreview(url: String, dest: File) { - val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection - conn.connectTimeout = 15_000 - conn.readTimeout = 30_000 - conn.instanceFollowRedirects = true - try { - if (conn.responseCode !in 200..299) return - val type = conn.contentType.orEmpty() - if (type.isNotEmpty() && !type.startsWith("image/")) return - dest.parentFile?.mkdirs() - val maxBytes = 16L * 1024 * 1024 // cap — a preview image is never this large - var over = false - var total = 0L - conn.inputStream.use { input -> - dest.outputStream().use { out -> - val buf = ByteArray(64 * 1024) - while (true) { - val n = input.read(buf) - if (n < 0) break - total += n - if (total > maxBytes) { over = true; break } - out.write(buf, 0, n) - } - } - } - // Discard an over-cap (truncated) or empty download — never let mods.json reference a corrupt preview. - if (over || total == 0L) dest.delete() - } finally { - conn.disconnect() - } - } - fun getGseSaveDirs(appId: Int): List { val context = instance?.applicationContext ?: return emptyList() val imageFs = ImageFs.find(context) @@ -5949,29 +2876,6 @@ class SteamService : Service() { } } - private fun findSteamSettingsDir( - context: Context, - appId: Int, - ): String? { - val appDirPath = getAppDirPath(appId) - val appDirSettings = File(appDirPath, "steam_settings") - if (appDirSettings.isDirectory) { - return appDirSettings.absolutePath - } - - val container = ContainerUtils.getContainer(context, "STEAM_$appId") ?: return null - val coldclientSettings = - File( - container.rootDir, - ".wine/drive_c/Program Files (x86)/Steam/steam_settings", - ) - if (coldclientSettings.isDirectory) { - return coldclientSettings.absolutePath - } - - return null - } - suspend fun storeAchievementUnlocks( appId: Int, configDirectory: String, @@ -6074,43 +2978,6 @@ class SteamService : Service() { sendStoreUserStats(appId, allStats, mySteamId.convertToUInt64(), crcStats) } - /** Decode a hex string (from the native JNI layer) to bytes; empty array for too-short/empty input. */ - private fun hexToBytes(hex: String): ByteArray { - if (hex.length < 2) return ByteArray(0) - val n = hex.length / 2 - val out = ByteArray(n) - for (i in 0 until n) { - out[i] = ((Character.digit(hex[i * 2], 16) shl 4) or - Character.digit(hex[i * 2 + 1], 16)).toByte() - } - return out - } - - /** Write achievement/stat values back to Steam (CMsgClientStoreUserStats2). Fire-and-forget. */ - private suspend fun sendStoreUserStats( - appId: Int, - stats: Map, - steamId: Long, - crcStats: Int, - ) { - if (stats.isEmpty()) return - val statIds = IntArray(stats.size) - val statValues = IntArray(stats.size) - var i = 0 - for ((id, value) in stats) { - statIds[i] = id - statValues[i] = value - i++ - } - val sent = withWnSession { session -> - session.storeUserStats(appId, steamId, crcStats, statIds, statValues) - true - } - if (sent != true) { - Timber.e("Failed to send storeUserStats for appId=$appId — no session") - } - } - data class CloudSyncOutcome( val success: Boolean, val message: String = "", @@ -6404,89 +3271,6 @@ class SteamService : Service() { return vdf } - /** Persist native-client auth credentials for cold-start auto-logon. */ - private fun persistLoginTokens( - username: String, - accessToken: String?, - refreshToken: String?, - clientId: Long? = null, - ) { - isLoggingOut = false - PrefManager.username = username - if (accessToken != null) PrefManager.accessToken = accessToken - if (refreshToken != null) PrefManager.refreshToken = refreshToken - if (clientId != null) PrefManager.clientId = clientId - val tokenForSid = refreshToken ?: accessToken - var newSid: Long = 0L - var accountSwitched: Boolean = false - if (tokenForSid != null) { - runCatching { - val sub = JWT(tokenForSid).subject - if (!sub.isNullOrBlank()) { - val sid64 = sub.toLongOrNull() - if (sid64 != null && sid64 != 0L) { - newSid = sid64 - val prev = PrefManager.steamUserSteamId64 - if (prev != sid64) { - accountSwitched = (prev != 0L) - PrefManager.steamUserSteamId64 = sid64 - PrefManager.steamUserAccountId = - (sid64 and 0xFFFFFFFFL).toInt() - Timber.i("persistLoginTokens: cached steamId64=$sid64" + - if (accountSwitched) " (account switch from $prev)" else "") - } - } - } - }.onFailure { e -> - Timber.w(e, "persistLoginTokens: JWT decode failed") - } - } - if (newSid != 0L) { - runCatching { - if (accountSwitched) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setPersonaName("") - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setFriendsList(LongArray(0)) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setCloudFiles(emptyArray(), IntArray(0), LongArray(0)) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setCloudEnabledForApp(false) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppId(0) - Timber.i("persistLoginTokens: cleared libsteamclient mirror on account switch") - instance?.let { svc -> - svc.scope.launch(Dispatchers.IO) { - runCatching { svc.encryptedAppTicketDao.deleteAll() } - .onFailure { - Timber.w(it, - "Failed to clear encrypted-app-ticket cache on account switch") - } - runCatching { svc.db.steamAppDao().deleteAll() } - .onFailure { - Timber.w(it, - "Failed to clear steam_app catalog on account switch") - } - runCatching { svc.licenseDao.deleteAll() } - .onFailure { - Timber.w(it, - "Failed to clear steam_license on account switch") - } - } - } - } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setSteamId(newSid) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setLoggedOn(true) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setCloudEnabledForAccount(true) - }.onFailure { e -> - Timber.w(e, "persistLoginTokens: libsteamclient identity push failed") - } - } - } - suspend fun startLoginWithCredentials( username: String, password: String, @@ -6595,124 +3379,6 @@ class SteamService : Service() { } } - /** Orchestrator observer on the long-lived [WnSteamSession]; drives the connection lifecycle off the channel state — state 2 (Connected): mark connected; state 3 (LoggedOn): mark connected+logged-in then run [onWnLoggedOn] once; state 0 (Disconnected): clear flows and, if still the shared session, hand off to [onWnDisconnected]. */ - private fun installWnLogonObserver(session: WnSteamSession) { - // A fresh session begins a fresh logon — let onWnLoggedOn re-run. - wnLoggedOnHandled = false - session.setStateObserver(object : WnSteamStateObserver { - override fun onStateChanged(state: Int) { - val name = when (state) { - 0 -> "Disconnected"; 1 -> "Connecting" - 2 -> "Connected"; 3 -> "LoggedOn" - else -> "?($state)" - } - Timber.i("WnSteam(logon) state -> %s", name) - when (state) { - 2 -> { - isConnected = true - } - 3 -> { - isConnected = true - _isLoggedInFlow.value = true - recordLogonSuccess() - if (!wnLoggedOnHandled) { - wnLoggedOnHandled = true - instance?.onWnLoggedOn(session) - } - } - 0 -> { - if (wnSession === session) { - isConnected = false - if (PrefManager.refreshToken.isBlank()) { - _isLoggedInFlow.value = false - } - wnSession = null - wnLoggedOnHandled = false - instance?.onWnDisconnected() - } - } - } - } - override fun onClientMessage(emsg: Int, eresult: Int, body: ByteArray) { - Timber.d("WnSteam(logon) inbound emsg=%d eresult=%d body=%d bytes", - emsg, eresult, body.size) - if (emsg == 751 && eresult != 1) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .reportLogonFailure(eresult = eresult, stillRetrying = true) - recordLogonFailure(eresult) - val nonRecoverable = eresult == 5 || eresult == 15 || - eresult == 18 || eresult == 65 - if (nonRecoverable && PrefManager.refreshToken.isNotBlank()) { - Timber.w("WnSteam: non-recoverable EResult=$eresult on logon — " + - "clearing refresh-token, will require re-sign-in") - PrefManager.clearAuthTokens() - _isLoggedInFlow.value = false - runCatching { - PluviaApp.events.emit( - SteamEvent.LogonEnded( - PrefManager.username, - LoginResult.Failed, - "Steam refused the cached session (EResult=$eresult). Please sign in again.")) - } - runCatching { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setLoggedOn(false) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setSteamId(0L) - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setPersonaName("") - } - } - } - } - }) - // Wire the Kotlin library facade now so it's ready for the populate-complete observer fire that lands a couple seconds after the ClientLicenseList push. - wnLibraryMirrorJob?.cancel() - wnLibrary?.stopObserving() - val library = WnLibraryStore(session) - wnLibrary = library - library.startObserving() - // Log every snapshot transition to see when the populate pipeline completes; the flow is hot + replay=1 so late collectors get the latest snapshot. - wnLibraryMirrorJob = instance?.scope?.launch(Dispatchers.Default) { - library.snapshots.collect { snap -> - Timber.i( - "WnLibrary snapshot: %d packages, %d owned apps (of %d tracked)", - snap.packages.size, snap.ownedApps.size, snap.allAppsCount, - ) - val nameIds = mutableListOf() - val nameStrs = mutableListOf() - var buildIdsPushed = 0 - var sourcePackagesPushed = 0 - for (a in snap.ownedApps) { - if (a.name.isNotEmpty()) { - nameIds.add(a.id) - nameStrs.add(a.name) - } - if (a.buildId > 0) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppBuildId(a.id, a.buildId) - ++buildIdsPushed - } - if (a.sourcePackageIds.isNotEmpty()) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppSourcePackages( - a.id, a.sourcePackageIds.toIntArray()) - ++sourcePackagesPushed - } - } - if (nameIds.isNotEmpty()) { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppNames(nameIds.toIntArray(), nameStrs.toTypedArray()) - } - if (nameIds.isNotEmpty() || buildIdsPushed > 0 || sourcePackagesPushed > 0) { - Timber.d("WnLibrary mirror → libsteamclient.so: " + - "names=${nameIds.size} buildIds=$buildIdsPushed " + - "sourcePackages=$sourcePackagesPushed") - } - } - } - } - /** Creates a fresh [WnSteamSession], connects, and waits for the encrypted channel to reach Connected (state 2). Caller owns the returned session (disconnect/close); null on failure. */ /** Run [block] with a logged-on session: reuse the global [wnSession] if logged on (state 3), else bring up a temporary one, log on with the stored refresh token, run [block], and tear it down. Null if no logged-on session could be obtained. */ internal suspend fun withWnSession( @@ -6798,51 +3464,6 @@ class SteamService : Service() { } } - private suspend fun bringUpWnSession(svc: SteamService): WnSteamSession? { - val caPath = CaBundleExtractor.ensureBundle(svc) - if (caPath.isEmpty()) { - Timber.e("Cannot start WnSteam session: CA bundle unavailable") - return null - } - val cmUrl = withContext(Dispatchers.IO) { - WnSteamSession.pickCmUrl(caPath) - } - if (cmUrl.isEmpty()) { - Timber.e("Cannot start WnSteam session: no CM URL") - return null - } - Timber.i("WnSteam: connecting to %s", cmUrl) - - val session = WnSteamSession() - var ok = false - try { - session.setCaBundlePath(caPath) - val connected = suspendCancellableCoroutine { cont -> - session.setStateObserver(object : WnSteamStateObserver { - override fun onStateChanged(state: Int) { - if (!cont.isActive) return - if (state == 2) cont.resume(true) - else if (state == 0) cont.resume(false) - } - override fun onClientMessage(emsg: Int, eresult: Int, body: ByteArray) {} - }) - if (!session.connect(cmUrl)) cont.resume(false) - cont.invokeOnCancellation { session.disconnect() } - } - if (!connected) { - Timber.e("WnSteam channel did not reach Connected state") - return null - } - ok = true - return session - } finally { - if (!ok) { - try { session.disconnect() } catch (_: Throwable) {} - try { session.close() } catch (_: Throwable) {} - } - } - } - suspend fun startLoginWithQr() = withContext(Dispatchers.IO) { val svc = instance ?: run { PluviaApp.events.emit( @@ -7116,59 +3737,6 @@ class SteamService : Service() { overlayPollJob = null } - private fun dispatchOverlayRequest(serialized: String) { - val parts = serialized.split('\u0001') - if (parts.size < 4) { - Timber.w("dispatchOverlayRequest: malformed payload (parts=${parts.size})") - return - } - val kind = parts[0] - val arg1 = parts[1] - val sid = parts[2].toLongOrNull() ?: 0L - val appId = parts[3].toIntOrNull() ?: 0 - val url = when (kind) { - "webpage" -> if (arg1.startsWith("http://") || arg1.startsWith("https://")) arg1 - else "https://${arg1}" - "store" -> "https://store.steampowered.com/app/${appId}/" - "user" -> "https://steamcommunity.com/profiles/${sid}" - "invite" -> { - Timber.i("overlay: invite dialog requested (lobby=0x${sid.toString(16)})") - return - } - "dialog" -> when (arg1) { - "Achievements" -> "https://steamcommunity.com/stats/${appId}/achievements/" - "Players" -> "https://steamcommunity.com/profiles/${ - com.winlator.cmod.feature.stores.steam.utils.PrefManager.steamUserSteamId64 - }" - else -> "https://steamcommunity.com/profiles/${ - com.winlator.cmod.feature.stores.steam.utils.PrefManager.steamUserSteamId64 - }" - } - else -> { - Timber.w("dispatchOverlayRequest: unknown kind '$kind'") - return - } - } - val svc = instance ?: return - runCatching { - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url) - ).apply { - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - } - svc.applicationContext.startActivity(intent) - Timber.i("overlay: dispatched $kind → $url") - }.onFailure { e -> - Timber.w(e, "overlay: startActivity failed for $url") - } - } - - private fun clearUserData() { - PrefManager.clearAuthTokens() - - clearDatabase() - } - fun clearDatabase() { with(instance!!) { scope.launch { @@ -7184,18 +3752,6 @@ class SteamService : Service() { } } - private fun clearCloudSyncCaches() { - instance?.let { svc -> - svc.scope.launch { - svc.db.withTransaction { - svc.changeNumbersDao.deleteAll() - svc.fileChangeListsDao.deleteAll() - } - Timber.i("Cleared cloud sync caches (change numbers + file lists)") - } - } - } - suspend fun getOwnedGames(friendID: Long): List = withContext(Dispatchers.IO) { instance?._unifiedFriends!!.getOwnedGames(friendID) @@ -7288,154 +3844,9 @@ class SteamService : Service() { /** Transitional bridge: converts a [KeyValue] tree into the nested Map [WnKeyValue] consumes. */ - private suspend fun fetchLatestSteamAppInfo(appId: Int): SteamApp? { - // getPicsAppInfo returns {"changeNumber":N,"appinfo":{...}}; the native side parses appinfo VDF and WnKeyValue decodes it. - val wnApp = - withWnSession { session -> - withContext(Dispatchers.IO) { - // Fetch the app token first; token-gated apps omit depots without it. - val token = - runCatching { - session.getPicsAccessTokens(listOf(appId), emptyList())?.let { tj -> - JSONObject(tj) - .optJSONObject("appTokens") - ?.optString(appId.toString()) - ?.toLongOrNull() - } - }.getOrNull() ?: 0L - session.getPicsAppInfo(appId, token)?.let { json -> - try { - val obj = JSONObject(json) - val appinfo = obj.optJSONObject("appinfo") ?: return@let null - val app = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() - if (app.id == INVALID_APP_ID) { - null - } else { - app.copy( - receivedPICS = true, - lastChangeNumber = obj.optInt("changeNumber", 0), - ) - } - } catch (e: Exception) { - Timber.w(e, "wn-steam-client appinfo parse failed for appId=$appId") - null - } - } - } - } - if (wnApp != null) { - Timber.i("app info via wn-steam-client: appId=$appId name='${wnApp.name}'") - return wnApp - } - Timber.w("wn-steam-client app info unavailable for appId=$appId") - return null - } - - private suspend fun persistLatestSteamAppInfo( - appId: Int, - remoteSteamApp: SteamApp, - ) { - val service = instance ?: return - val appFromDb = service.appDao.findApp(appId) - val packageId = appFromDb?.packageId ?: remoteSteamApp.packageId - val packageFromDb = if (packageId != INVALID_PKG_ID) service.licenseDao.findLicense(packageId) else null - val existingInstallDir = appFromDb?.installDir.orEmpty() - val preserveInstallDir = - existingInstallDir.isNotEmpty() && - (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) - - service.appDao.insert( - remoteSteamApp.copy( - packageId = packageId, - ownerAccountId = packageFromDb?.ownerAccountId ?: appFromDb?.ownerAccountId.orEmpty(), - licenseFlags = - packageFromDb?.licenseFlags - ?: appFromDb?.licenseFlags - ?: EnumSet.noneOf(ELicenseFlags::class.java), - installDir = if (preserveInstallDir) existingInstallDir else remoteSteamApp.installDir, - ), - ) - } - - private val picsRefreshedAppsThisSession = + internal val picsRefreshedAppsThisSession = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) - /** Force-refreshes [appId]'s PICS depot data once per session; no-op on the main thread. */ - private fun ensureFreshDepotData(appId: Int) { - if (appId <= 0 || instance == null) return - if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) return - if (!picsRefreshedAppsThisSession.add(appId)) return - val refreshed = - runCatching { - runBlocking(Dispatchers.IO) { - val fresh = fetchLatestSteamAppInfo(appId) ?: return@runBlocking false - persistLatestSteamAppInfo(appId, fresh) - true - } - }.getOrDefault(false) - if (refreshed) { - Timber.i("Refreshed PICS depot data for appId=$appId before depot selection") - } else { - picsRefreshedAppsThisSession.remove(appId) - } - } - - private fun readInstalledDepotManifestIds(appDirPath: String): Map = - runCatching { - val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") - if (!configFile.exists() || !configFile.canRead()) return@runCatching emptyMap() - val json = JSONObject(configFile.readText()) - val manifests = json.optJSONObject("installedManifestIDs") ?: return@runCatching emptyMap() - val result = mutableMapOf() - for (key in manifests.keys()) { - val depotId = key.toIntOrNull() ?: continue - // Missing → INVALID_MANIFEST_ID (Long.MAX_VALUE). - result[depotId] = manifests.optLong(key, Long.MAX_VALUE) - } - result - }.getOrElse { - Timber.w(it, "Failed to read Steam depot.config for $appDirPath") - emptyMap() - } - - private fun cleanupCancelledUpdate(appDirPath: String) { - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) - MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) - clearPersistedProgressSnapshot(appDirPath) - - val stagingDir = File(File(appDirPath, ".DepotDownloader"), "staging") - if (!stagingDir.exists()) return - - stagingDir - .walkBottomUp() - .forEach { staged -> - if (staged == stagingDir) return@forEach - if (staged.isDirectory) { - if (staged.list().isNullOrEmpty()) staged.delete() - return@forEach - } - - val relative = staged.relativeTo(stagingDir) - val finalFile = File(appDirPath, relative.path) - runCatching { - finalFile.parentFile?.mkdirs() - if (finalFile.exists()) { - finalFile.delete() - } - if (!staged.renameTo(finalFile)) { - staged.copyTo(finalFile, overwrite = true) - staged.delete() - } - }.onFailure { - Timber.w(it, "Failed to restore staged Steam update file ${staged.absolutePath}") - } - } - - if (stagingDir.exists() && stagingDir.list().isNullOrEmpty()) { - stagingDir.delete() - } - } - suspend fun checkDlcOwnershipViaPICSBatch(dlcAppIds: Set): Set { if (dlcAppIds.isEmpty()) return emptySet() @@ -7749,342 +4160,49 @@ class SteamService : Service() { downloadInfo.persistProgressSnapshot(force = true) } - stopForeground(STOP_FOREGROUND_REMOVE) - notificationHelper.cancel() - notificationHelper.cancelBackgroundRunning() - - if (!isStopping) { - scope.launch { stop() } - } - } - - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - Timber.i("Task removed; stopping managed app services") - AppTerminationHelper.stopManagedServices(applicationContext, "steam_task_removed") - } - - override fun onBind(intent: Intent?): IBinder? = null - - /** Brings up (or reuses) the long-lived session and logs it on with the stored refresh token; [withWnSession] promotes it and installs the orchestrator observer (fires [onWnLoggedOn]). Retries with backoff until logged on or the service stops. Cold-start auto-logon and reconnect. */ - private fun connectAndLogon() { - if (connectJob?.isActive == true) return - connectJob = - scope.launch { - PluviaApp.events.emit(SteamEvent.Connected(true)) - var attempt = 0 - while (isRunning && !isStopping && PrefManager.refreshToken.isNotBlank()) { - if (wnSession?.state() == 3) break - Timber.d("connectAndLogon: bringing up WN-Steam-Client session") - val state = withWnSession { it.state() } - if (state == 3) break - attempt++ - if (attempt >= CONNECT_LOGON_MAX_ATTEMPTS) { - // Logon failed this many times — likely an expired/revoked token or sustained outage. Stop instead of spinning a full bring-up + logon forever (battery drain); a foreground wake or explicit re-login re-triggers connectAndLogon. - Timber.w("connectAndLogon: giving up after $attempt failed attempts") - break - } - val backoffMs = reconnectBackoffMs(attempt) - Timber.w("connectAndLogon: not logged on — retry $attempt in ${backoffMs}ms") - delay(backoffMs) - } - } - } - - /** Applies the master chat switch to a live session: enable → restart the message poller, go online (stored persona), refresh friends; disable → stop the poller and go offline. No-op until logged on (onWnLoggedOn handles cold start). */ - private fun applyChatServiceState(enabled: Boolean) { - if (!isLoggedIn) return - if (enabled) { - messagePollerJob?.cancel() - messagePollerJob = continuousIncomingMessagePoller() - scope.launch { - val effectiveState = - (EPersonaState.from(PrefManager.personaState) ?: EPersonaState.Online).code() - withWnSession { s -> s.setPersonaState(effectiveState) } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setPersonaState(effectiveState) - } - scope.launch { runCatching { refreshFriends() } } - } else { - messagePollerJob?.cancel() - messagePollerJob = null - scope.launch { - val offline = EPersonaState.Offline.code() - withWnSession { s -> s.setPersonaState(offline) } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setPersonaState(offline) - } - } - } - - private fun ensureHealthySessionImpl() { - if (!isRunning || isStopping || isLoggingOut) return - if (PrefManager.refreshToken.isBlank()) return - if (PluviaApp.isGameSessionActive()) return - if (suspendedForBionic) { - Timber.w("ensureHealthySession: clearing stale Bionic hand-off (no game running)") - bionicHandoffReleaseImpl() - return - } - if (wnSession?.state() == 3) return - Timber.i("ensureHealthySession: session not logged on — re-driving connectAndLogon") - retryAttempt = 0 - connectAndLogon() - } - - private fun handleAppForegrounded() { - appInForeground = true - // Cancel any pending suspend timer — the app is back, so the session must stay up regardless of how long it was minimized. - backgroundIdleJob?.cancel() - backgroundIdleJob = null - // Restore the quiet foreground notification and drop the background-chat one. - if (isRunning && !isStopping) { - runCatching { - startForeground(1, notificationHelper.createForegroundNotification("Steam Service is running")) - notificationHelper.cancelBackgroundRunning() - }.onFailure { Timber.w(it, "Failed to restore SteamService foreground notification") } - } - if (!suspendedForBackground) return - suspendedForBackground = false - Timber.i("App foregrounded — waking the WN-Steam-Client session") - retryAttempt = 0 - if (isRunning && !isStopping && PrefManager.refreshToken.isNotBlank()) { - runCatching { - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setLoggedOn(true) - } - connectAndLogon() - } - } - - /** App went to the background — arm the deferred suspend check. */ - private fun handleAppBackgrounded() { - appInForeground = false - if (PrefManager.chatStayRunningOnExit && isRunning && !isStopping) { - runCatching { - startForeground( - NotificationHelper.BACKGROUND_RUNNING_NOTIFICATION_ID, - notificationHelper.createBackgroundRunningNotification(), - ) - notificationHelper.cancel() - }.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") } - } - scheduleBackgroundSuspendCheck() - } - - /** Arm the background suspend check: [maybeSuspendForBackground] runs immediately, then repeats once per [BACKGROUND_IDLE_GRACE_MS] while connection-critical work runs (so nothing has to hook each operation's completion). A foreground event cancels it. */ - private fun scheduleBackgroundSuspendCheck() { - backgroundIdleJob?.cancel() - if (appInForeground || isStopping || isLoggingOut) return - backgroundIdleJob = - scope.launch { - while (isActive) { - if (appInForeground || isStopping || isLoggingOut || suspendedForBackground) { - return@launch - } - // Suspended → done. Still busy → loop and re-check later. - if (maybeSuspendForBackground()) return@launch - delay(BACKGROUND_IDLE_GRACE_MS) - } - } - } - - /** Reason the connection must stay open (null = safe to suspend) — anything that would corrupt data if the CM session dropped mid-operation: an active download (not paused/queued), a running game session, or an in-flight cloud-save sync (never interrupt or the save can be left corrupt). */ - private fun connectionCriticalWork(): String? = - when { - DownloadCoordinator.hasActiveDownload() -> "a download is active" - PluviaApp.isGameSessionActive() -> "a game session is running" - syncInProgressApps.values.any { it.get() } -> "a cloud save sync is in progress" - PrefManager.chatStayRunningOnExit -> "background chat is enabled" - else -> null - } - - /** Suspend the backgrounded Steam session to draw no power — unless [connectionCriticalWork] still needs it. Disconnects the session and cancels every reconnect/PICS loop; wakes from [handleAppForegrounded]. Returns true if suspended. */ - private fun maybeSuspendForBackground(): Boolean { - if (appInForeground || isStopping || isLoggingOut || suspendedForBackground) return false - val keepAliveReason = connectionCriticalWork() - if (keepAliveReason != null) { - Timber.i("App backgrounded but %s — keeping the Steam session connected", keepAliveReason) - return false - } - Timber.i("App backgrounded and idle — suspending WN-Steam-Client session to save battery") - suspendedForBackground = true - connectJob?.cancel() - reconnectJob?.cancel() - stableConnectionJob?.cancel() - refreshTokenWatchdogJob?.cancel() - picsChangesCheckerJob?.cancel() - picsGetProductInfoJob?.cancel() - messagePollerJob?.cancel() - wnSession?.let { s -> runCatching { s.disconnect() } } - scope.launch(Dispatchers.Main) { - runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } - .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } - runCatching { notificationHelper.cancel() } - .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } - } - return true - } - - private fun startRefreshTokenWatchdog() { - refreshTokenWatchdogJob?.cancel() - refreshTokenWatchdogJob = scope.launch(Dispatchers.IO) { - while (isActive && !isStopping && !isLoggingOut) { - runCatching { maybeRotateRefreshToken() } - .onFailure { Timber.w(it, "refresh-token watchdog tick threw") } - delay(REFRESH_TOKEN_ROTATION_CHECK_INTERVAL_MS) - } - } - } - - private suspend fun maybeRotateRefreshToken() { - val cur = PrefManager.refreshToken - if (cur.isBlank()) return - val expMs: Long? = try { - val jwt = com.auth0.android.jwt.JWT(cur) - jwt.expiresAt?.time - } catch (t: Throwable) { - Timber.w(t, "refresh-token watchdog: JWT decode failed; rotating defensively") - null - } - val nowMs = System.currentTimeMillis() - val thresholdMs = REFRESH_TOKEN_ROTATION_THRESHOLD_DAYS * 24L * 60L * 60L * 1000L - val needsRotation = expMs == null || (expMs - nowMs) < thresholdMs - if (!needsRotation) { - val daysLeft = ((expMs!! - nowMs) / (24L * 60L * 60L * 1000L)).coerceAtLeast(0) - Timber.d("refresh-token watchdog: token healthy ($daysLeft days to expiry)") - return - } - val rotated = renewRefreshTokenForHandoff() - Timber.i("refresh-token watchdog: rotation attempt → $rotated") - } + stopForeground(STOP_FOREGROUND_REMOVE) + notificationHelper.cancel() + notificationHelper.cancelBackgroundRunning() - private suspend fun renewRefreshTokenForHandoff(): Boolean = - withContext(Dispatchers.IO) { - val session = wnSession ?: return@withContext false - val current = PrefManager.refreshToken - val sid = PrefManager.steamUserSteamId64 - if (current.isEmpty() || sid == 0L) return@withContext false - val fresh = try { - session.renewRefreshToken(current, sid, timeoutMs = 15_000) - } catch (t: Throwable) { - Timber.w(t, "renewRefreshToken threw") - null - } - if (fresh.isNullOrEmpty()) { - Timber.w("renewRefreshTokenForHandoff: no new token returned") - return@withContext false - } - PrefManager.refreshToken = fresh - Timber.i("renewRefreshTokenForHandoff: token rotated (len ${current.length} -> ${fresh.length})") - true + if (!isStopping) { + scope.launch { stop() } } + } - private fun bionicHandoffAcquireImpl() { - if (suspendedForBionic) return - suspendedForBionic = true - Timber.i("Bionic hand-off ACQUIRE — suspending WN-Steam-Client session for the bootstrap") - connectJob?.cancel() - reconnectJob?.cancel() - stableConnectionJob?.cancel() - refreshTokenWatchdogJob?.cancel() - picsChangesCheckerJob?.cancel() - picsGetProductInfoJob?.cancel() - messagePollerJob?.cancel() - wnSession?.let { s -> runCatching { s.logOffAndDisconnect(500) } } + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + Timber.i("Task removed; stopping managed app services") + AppTerminationHelper.stopManagedServices(applicationContext, "steam_task_removed") } - private fun bionicHandoffReleaseImpl() { - runCatching { - com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamBootstrap.stop() - } - if (!suspendedForBionic) return - suspendedForBionic = false - retryAttempt = 0 - if (!(isRunning && !isStopping && !isLoggingOut && PrefManager.refreshToken.isNotBlank())) { - Timber.i("Bionic hand-off RELEASE — not resuming (service not in a resumable state)") - return - } - // PlanW: defer the wn-session resume so the account stays offline long enough for Steam to reap the launcher's games-played registration; resuming immediately keeps it online and the next launch hits AlreadyRunning (0x10). - if (PrefManager.wnPlanW) { - Timber.i( - "Bionic hand-off RELEASE — PlanW: deferring wn-session resume " + - "${WN_PLANW_REAP_OFFLINE_MS}ms so Steam reaps the launcher's " + - "games-played registration (account stays offline)", - ) - scope.launch(Dispatchers.IO) { - delay(WN_PLANW_REAP_OFFLINE_MS) - // Skip if a new launch re-acquired the hand-off, or the session already came up some other way. - if (!suspendedForBionic && isRunning && !isStopping && !isLoggingOut && - PrefManager.refreshToken.isNotBlank() && (wnSession?.state() ?: 0) != 3 - ) { - Timber.i("Bionic hand-off RELEASE — PlanW reap window elapsed, resuming WN-Steam-Client") - connectAndLogon() - } else { - Timber.i( - "Bionic hand-off RELEASE — PlanW reap window elapsed, resume skipped " + - "(suspendedForBionic=$suspendedForBionic wnState=${wnSession?.state()})", - ) - } + override fun onBind(intent: Intent?): IBinder? = null + + /** Applies the master chat switch to a live session: enable → restart the message poller, go online (stored persona), refresh friends; disable → stop the poller and go offline. No-op until logged on (onWnLoggedOn handles cold start). */ + private fun applyChatServiceState(enabled: Boolean) { + if (!isLoggedIn) return + if (enabled) { + messagePollerJob?.cancel() + messagePollerJob = continuousIncomingMessagePoller() + scope.launch { + val effectiveState = + (EPersonaState.from(PrefManager.personaState) ?: EPersonaState.Online).code() + withWnSession { s -> s.setPersonaState(effectiveState) } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setPersonaState(effectiveState) } + scope.launch { runCatching { refreshFriends() } } } else { - Timber.i("Bionic hand-off RELEASE — bootstrap logged off, resuming WN-Steam-Client") - connectAndLogon() - } - } - - private fun bionicHandoffReleaseAndKickPlayingSessionAsyncImpl(onlyGame: Boolean) { - bionicHandoffReleaseImpl() - if (!isRunning || isStopping || isLoggingOut) return - scope.launch(Dispatchers.IO) { - repeat(20) { attempt -> - if (kickPlayingSessionIfReady(onlyGame)) { - Timber.i( - "Bionic hand-off release: kickPlayingSessionIfReady fired " + - "after reconnect (attempt ${attempt + 1}/20 onlyGame=$onlyGame)", - ) - return@launch - } - delay(500L) + messagePollerJob?.cancel() + messagePollerJob = null + scope.launch { + val offline = EPersonaState.Offline.code() + withWnSession { s -> s.setPersonaState(offline) } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setPersonaState(offline) } - Timber.i( - "Bionic hand-off release: wn-session never became ready " + - "for kickPlayingSessionIfReady (onlyGame=$onlyGame)", - ) } } - private suspend fun bionicHandoffReleaseAndKickPlayingSessionBlockingImpl( - onlyGame: Boolean, - maxWaitMs: Long, - ): Boolean { - bionicHandoffReleaseImpl() - if (!isRunning || isStopping || isLoggingOut) return false - - val deadlineMs = System.currentTimeMillis() + maxWaitMs - var attempt = 0 - do { - attempt++ - if (kickPlayingSessionIfReady(onlyGame)) { - Timber.i( - "Bionic hand-off release: kickPlayingSessionIfReady fired " + - "before close (attempt $attempt onlyGame=$onlyGame)", - ) - return true - } - - val remainingMs = deadlineMs - System.currentTimeMillis() - if (remainingMs <= 0L) break - delay(minOf(250L, remainingMs)) - } while (true) - - Timber.i( - "Bionic hand-off release: wn-session never became ready " + - "before close (onlyGame=$onlyGame timeoutMs=$maxWaitMs)", - ) - return false - } - private suspend fun stop() { Timber.i("Stopping Steam service") isStopping = true @@ -8104,53 +4222,8 @@ class SteamService : Service() { clearValues() } - private fun clearValues() { - if (instance === this) { - instance = null - } - - _loginResult = LoginResult.Failed - isRunning = false - isConnected = false - isLoggingOut = false - isWaitingForQRAuth = false - - wnLoggedOnHandled = false - wnLibraryMirrorJob?.cancel() - wnLibraryMirrorJob = null - wnLibrary?.stopObserving() - wnLibrary = null - - _unifiedFriends?.close() - _unifiedFriends = null - - isStopping = false - retryAttempt = 0 - reconnectJob?.cancel() - reconnectJob = null - stableConnectionJob?.cancel() - refreshTokenWatchdogJob?.cancel() - stableConnectionJob = null - backgroundIdleJob?.cancel() - backgroundIdleJob = null - suspendedForBackground = false - suspendedForBionic = false - appInForeground = true - - PluviaApp.events.off(onEndProcess) - PluviaApp.events.clearAllListenersOf>() - } - // region [REGION] WN-Steam-Client lifecycle - /** Channel-dropped (onDisconnected) handler: reconnects while credentials + retries remain, else emits Disconnected and stops the service. Fired from the [installWnLogonObserver] state observer. */ - /** Exponential reconnect backoff (2s, 4s, 8s… doubling per 1-based attempt, capped at [RECONNECT_BACKOFF_CAP_MS]) — without it a connection that logs on then drops reconnects in a tight loop and overheats the device. */ - private fun reconnectBackoffMs(attempt: Int): Long { - val shift = (attempt - 1).coerceIn(0, 8) // 2^0 .. 2^8 - val seconds = (1L shl shift) * 2L // 2, 4, 8, , 512 - return (seconds * 1000L).coerceAtMost(RECONNECT_BACKOFF_CAP_MS) - } - fun onWnDisconnected() { Timber.i("WN-Steam-Client channel disconnected") if (isStopping || isLoggingOut) return @@ -8321,178 +4394,9 @@ class SteamService : Service() { } // endregion - /** Populate the steam_license / cached_license tables from the received licenses (CMsgClientLicenseList); driven from the post-logon flow. */ - private suspend fun processLicenseList() { - // The license list is pushed just after logon; poll briefly for it. - var json: String? = null - for (attempt in 0 until 15) { - json = withWnSession { session -> session.getLicenseList() } - if (json != null && json != "[]") break - delay(200) - } - val arr = - try { - JSONArray(json ?: "[]") - } catch (e: Exception) { - Timber.w(e, "processLicenseList: bad license JSON") - return - } - if (arr.length() == 0) { - Timber.w("processLicenseList: no licenses received") - return - } - Timber.i("Received License List, size: ${arr.length()}") - - data class RawLicense( - val packageId: Int, val changeNumber: Int, - val timeCreated: Long, val timeNextProcess: Long, - val minuteLimit: Int, val minutesUsed: Int, - val paymentMethod: Int, val flags: Int, - val purchaseCountryCode: String, val licenseType: Int, - val territoryCode: Int, val accessToken: Long, - val ownerId: Int, val masterPackageId: Int, - ) - val raw = - (0 until arr.length()).map { i -> - val o = arr.getJSONObject(i) - RawLicense( - packageId = o.optInt("packageId"), - changeNumber = o.optInt("changeNumber"), - timeCreated = o.optLong("timeCreated"), - timeNextProcess = o.optLong("timeNextProcess"), - minuteLimit = o.optInt("minuteLimit"), - minutesUsed = o.optInt("minutesUsed"), - paymentMethod = o.optInt("paymentMethod"), - flags = o.optInt("flags"), - purchaseCountryCode = o.optString("purchaseCountryCode"), - licenseType = o.optInt("licenseType"), - territoryCode = o.optInt("territoryCode"), - accessToken = o.optLong("accessToken"), - ownerId = o.optInt("ownerId"), - masterPackageId = o.optInt("masterPackageId"), - ) - } - - db.withTransaction { - // Every launch refreshes licenses, so findStaleLicences picks up packages we no longer have (e.g. family-share changes). - - // Store raw licenses for the manifest-fetch path (CachedLicense). - cachedLicenseDao.deleteAll() - cachedLicenseDao.insertAll( - raw.map { l -> - CachedLicense( - licenseJson = - LicenseSerializer.serializeLicenseFields( - packageID = l.packageId, - lastChangeNumber = l.changeNumber, - timeCreatedMs = l.timeCreated * 1000L, - timeNextProcessMs = l.timeNextProcess * 1000L, - minuteLimit = l.minuteLimit, - minutesUsed = l.minutesUsed, - paymentMethod = l.paymentMethod, - flags = l.flags, - purchaseCode = l.purchaseCountryCode, - licenseType = l.licenseType, - territoryCode = l.territoryCode, - accessToken = l.accessToken, - ownerAccountID = l.ownerId, - masterPackageID = l.masterPackageId, - ), - ) - }, - ) - - val myAccountId = userSteamId?.accountID?.toInt() - val licensesToAdd = - raw.groupBy { it.packageId }.map { (packageId, group) -> - val preferred = - group.firstOrNull { it.ownerId == myAccountId } - ?: group.first() - // OR-combine the flag bitfields across every owner of the package. - val combinedFlags = EnumSet.noneOf(ELicenseFlags::class.java) - group.forEach { combinedFlags.addAll(ELicenseFlags.from(it.flags)) } - SteamLicense( - packageId = packageId, - lastChangeNumber = preferred.changeNumber, - timeCreated = Date(preferred.timeCreated * 1000L), - timeNextProcess = Date(preferred.timeNextProcess * 1000L), - minuteLimit = preferred.minuteLimit, - minutesUsed = preferred.minutesUsed, - paymentMethod = EPaymentMethod.from(preferred.paymentMethod) ?: EPaymentMethod.None, - licenseFlags = combinedFlags, - purchaseCode = preferred.purchaseCountryCode, - licenseType = ELicenseType.from(preferred.licenseType) ?: ELicenseType.NoLicense, - territoryCode = preferred.territoryCode, - accessToken = preferred.accessToken, - ownerAccountId = group.map { it.ownerId }, - masterPackageID = preferred.masterPackageId, - ) - } - - if (licensesToAdd.isNotEmpty()) { - Timber.i("Adding ${licensesToAdd.size} licenses") - licenseDao.insertAll(licensesToAdd) - } - - val licensesToRemove = - licenseDao.findStaleLicences(packageIds = raw.map { it.packageId }) - if (licensesToRemove.isNotEmpty()) { - Timber.i("Removing ${licensesToRemove.size} (stale) licenses") - licenseDao.deleteStaleLicenses(licensesToRemove.map { it.packageId }) - } - - licenseDao - .getAllLicenses() - .map { PICSRequest(it.packageId, it.accessToken) } - .chunked(MAX_PICS_BUFFER) - .forEach { chunk -> - Timber.d("processLicenseList: Queueing ${chunk.size} package(s) for PICS") - packagePicsChannel.send(chunk) - } - } - } - // QR challenge-URL updates flow from WnSteamSession via WnQrCallback; see startLoginWithQr below. // endregion - private suspend fun pushFriendsListToLibSteamClient() { - var ids = LongArray(0) - for (attempt in 0 until 30) { - ids = withWnSession { s -> s.getFriendsList() } ?: LongArray(0) - if (ids.isNotEmpty()) break - delay(200) - } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setFriendsList(ids) - Timber.i("Pushed ${ids.size} friend(s) to libsteamclient.so") - - if (ids.isEmpty()) return - - withWnSession { s -> - s.requestFriendPersonas(ids, personaStateRequested = 1) - } - - pushFriendPersonaNamesToLibSteamClient(ids) - } - - private suspend fun pushFriendPersonaNamesToLibSteamClient( - expectedIds: LongArray, - ) { - var lastCount = -1 - var json = "[]" - for (attempt in 0 until 30) { - json = withWnSession { s -> s.getFriendPersonas() } ?: "[]" - val arr = try { JSONArray(json) } catch (_: Exception) { JSONArray() } - if (arr.length() >= expectedIds.size) break - if (arr.length() == lastCount && arr.length() > 0) break - lastCount = arr.length() - delay(200) - } - val pushed = com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .pushFriendPersonasJson(json, persistSnapshot = true) - Timber.i("Pushed $pushed friend persona(s) to libsteamclient.so (snapshot persisted)") - } - suspend fun refreshFriends() { val svc = instance ?: return val ids = withWnSession { s -> s.getFriendsList() } ?: LongArray(0) @@ -8674,11 +4578,6 @@ class SteamService : Service() { runCatching { notificationHelper.cancelChatNotification(steamId) } } - private fun touchRecentChat(friendId: Long) { - if (friendId == 0L) return - _recentChats.update { it + (friendId to System.currentTimeMillis()) } - } - fun sendChatImageAsync(friendId: Long, bytes: ByteArray, fileName: String) { if (friendId == 0L || bytes.isEmpty()) return scope.launch { sendChatImage(friendId, bytes, fileName) } @@ -8689,416 +4588,10 @@ class SteamService : Service() { activeConversations.compute(steamId) { _, v -> if (v == null || v <= 1) null else v - 1 } } - private fun isActiveConversation(steamId: Long): Boolean = activeConversations.containsKey(steamId) - fun clearUnread(steamId: Long) { _unreadCounts.update { if (it.containsKey(steamId)) it - steamId else it } } - private fun continuousIncomingMessagePoller(): Job = - scope.launch { - while (isActive && isLoggedIn) { - delay(1000L) - val grouped = runCatching { drainIncomingMessages() }.getOrNull() - if (grouped.isNullOrEmpty()) continue - dispatchIncomingChat(grouped) - } - } - - private fun dispatchIncomingChat( - grouped: Map>, - ) { - val friends = _friendsList.value.associateBy { it.steamId } - val suppressed = GameSessionState.inGame && !PrefManager.chatInGameEnabled - for ((friendId, messages) in grouped) { - for (m in messages) _incomingChat.tryEmit(friendId to m) - val fromFriend = messages.filter { !it.fromSelf && it.text.isNotBlank() } - if (fromFriend.isEmpty()) continue - touchRecentChat(friendId) - if (isActiveConversation(friendId)) continue - _unreadCounts.update { it + (friendId to ((it[friendId] ?: 0) + fromFriend.size)) } - if (suppressed) continue - val name = friends[friendId]?.name?.ifBlank { friendId.toString() } ?: friendId.toString() - val preview = chatPreview(fromFriend.last().text) - if (PrefManager.chatNotificationsEnabled) { - runCatching { notificationHelper.notifyChatMessage(friendId, name, preview) } - } - if (PrefManager.chatHeadsEnabled) { - runCatching { - com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.onIncoming(this, friendId) - } - } - } - } - - private fun chatPreview(text: String): String { - val t = text.trim() - return if (t.startsWith("[img") || t.contains("steamusercontent.com")) { - getString(com.winlator.cmod.R.string.steam_chat_image) - } else { - t - } - } - - /** Poll PICS for app/package changes since the last change number on a fixed interval. */ - private fun continuousPICSChangesChecker(): Job = - scope.launch { - while (isActive && isLoggedIn) { - PICSChangesCheck() - delay(60.seconds) - } - } - - private fun PICSChangesCheck() { - scope.launch { - ensureActive() - - try { - // PICS change poll via the C++ WN-Steam-Client. - val changesJson = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsChangesSince(PrefManager.lastPICSChangeNumber.toLong()) - } - } - if (changesJson == null) { - Timber.w("PICS changes-since via wn-steam-client unavailable, skipping") - return@launch - } - val changes = JSONObject(changesJson) - val currentCN = changes.optLong("currentChangeNumber", 0L) - - if (PrefManager.lastPICSChangeNumber.toLong() == currentCN) { - Timber.w("Change number was the same as last change number, skipping") - return@launch - } - PrefManager.lastPICSChangeNumber = currentCN.toInt() - - val appChanges = changes.optJSONArray("apps") - val pkgChanges = changes.optJSONArray("packages") - Timber.d( - "picsGetChangesSince(wn): current=$currentCN " + - "apps=${appChanges?.length() ?: 0} pkgs=${pkgChanges?.length() ?: 0}", - ) - - launch { - val reqs = mutableListOf() - if (appChanges != null) { - for (i in 0 until appChanges.length()) { - val c = appChanges.getJSONObject(i) - val appId = c.optInt("appid") - // only queue apps existing in the db that have changed - val dbApp = appDao.findApp(appId) ?: continue - if (c.optInt("changeNumber") != dbApp.lastChangeNumber) { - reqs.add(PICSRequest(id = appId)) - } - } - } - reqs.chunked(MAX_PICS_BUFFER).forEach { chunk -> - ensureActive() - Timber.d("onPicsChanges: Queueing ${chunk.size} app(s) for PICS") - appPicsChannel.send(chunk) - } - } - - launch { - data class PkgChange(val id: Int, val needsToken: Boolean) - val changed = mutableListOf() - if (pkgChanges != null) { - for (i in 0 until pkgChanges.length()) { - val c = pkgChanges.getJSONObject(i) - val pkgId = c.optInt("packageid") - val dbPkg = licenseDao.findLicense(pkgId) ?: continue - if (c.optInt("changeNumber") != dbPkg.lastChangeNumber) { - changed.add(PkgChange(pkgId, c.optBoolean("needsToken"))) - } - } - } - if (changed.isNotEmpty()) { - val needTokenIds = changed.filter { it.needsToken }.map { it.id } - val tokens = HashMap() - if (needTokenIds.isNotEmpty()) { - val tokJson = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsAccessTokens(emptyList(), needTokenIds) - } - } - if (tokJson != null) { - JSONObject(tokJson).optJSONObject("packageTokens")?.let { pt -> - for (k in pt.keys()) { - tokens[k.toInt()] = pt.getString(k).toLongOrNull() ?: 0L - } - } - } - } - ensureActive() - changed - .map { PICSRequest(it.id, tokens[it.id] ?: 0L) } - .chunked(MAX_PICS_BUFFER) - .forEach { chunk -> - Timber.d("onPicsChanges: Queueing ${chunk.size} package(s) for PICS") - packagePicsChannel.send(chunk) - } - } - } - } catch (e: Exception) { - Timber.w(e, "PICSChangesCheck failed") - } - } - } - - /** Buffered flow that batches bursts of PICS requests. */ - private fun continuousPICSGetProductInfo(): Job = - scope.launch { - // App PICS — product info via the C++ WN-Steam-Client. - launch { - appPicsChannel - .receiveAsFlow() - .filter { it.isNotEmpty() } - .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) - .collect { appRequests -> - Timber.d("Processing ${appRequests.size} app PICS requests") - ensureActive() - if (!isLoggedIn) return@collect - - val json = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsAppProductInfo( - appRequests.map { it.id }, - appRequests.map { it.accessToken }, - ) - } - } ?: return@collect - - try { - val arr = JSONArray(json) - val steamAppsList = mutableListOf() - for (i in 0 until arr.length()) { - ensureActive() - try { - val entry = arr.getJSONObject(i) - val appId = entry.optInt("appid") - val changeNumber = entry.optInt("changeNumber") - val appinfo = entry.optJSONObject("appinfo") ?: continue - - val appFromDb = appDao.findApp(appId) - if (changeNumber == appFromDb?.lastChangeNumber) continue - - val packageId = appFromDb?.packageId ?: INVALID_PKG_ID - val packageFromDb = - if (packageId != INVALID_PKG_ID) licenseDao.findLicense(packageId) else null - val ownerAccountId = packageFromDb?.ownerAccountId ?: emptyList() - - val existingInstallDir = appFromDb?.installDir.orEmpty() - val preserveInstallDir = - existingInstallDir.isNotEmpty() && - (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) - - val generatedApp = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() - steamAppsList.add( - generatedApp.copy( - packageId = packageId, - ownerAccountId = ownerAccountId, - receivedPICS = true, - lastChangeNumber = changeNumber, - licenseFlags = packageFromDb?.licenseFlags ?: EnumSet.noneOf(ELicenseFlags::class.java), - installDir = - if (preserveInstallDir) existingInstallDir else generatedApp.installDir, - ), - ) - } catch (e: Exception) { - Timber.w(e, "PICS app entry decode failed") - } - } - if (steamAppsList.isNotEmpty()) { - Timber.i("Inserting ${steamAppsList.size} PICS apps to database (wn)") - db.withTransaction { appDao.insertAll(steamAppsList) } - } - } catch (ce: kotlin.coroutines.cancellation.CancellationException) { - throw ce - } catch (e: Exception) { - Timber.w(e, "PICS app batch processing failed") - } - } - } - - // Package PICS — package info via the C++ WN-Steam-Client. - launch { - packagePicsChannel - .receiveAsFlow() - .filter { it.isNotEmpty() } - .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) - .collect { packageRequests -> - Timber.d("Processing ${packageRequests.size} package PICS requests") - ensureActive() - if (!isLoggedIn) return@collect - - val json = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsPackageInfo( - packageRequests.map { it.id }, - packageRequests.map { it.accessToken }, - ) - } - } ?: return@collect - - val queue = mutableListOf() - try { - val arr = JSONArray(json) - db.withTransaction { - for (i in 0 until arr.length()) { - val pkg = arr.getJSONObject(i) - val pkgId = pkg.optInt("packageid") - val appIds = pkg.optJSONArray("appids").toIntList() - licenseDao.updateApps(pkgId, appIds) - val depotIds = pkg.optJSONArray("depotids").toIntList() - licenseDao.updateDepots(pkgId, depotIds) - - if (appIds.isNotEmpty()) { - // Update package_id on existing rows in one statement; insert stubs for the rest (avoids a per-app find/update/insert N+1). - val existing = appDao.findExistingIds(appIds).toHashSet() - appDao.setPackageIdForApps(appIds, pkgId) - val newApps = appIds.asSequence() - .filter { it !in existing } - .map { SteamApp(id = it, packageId = pkgId) } - .toList() - if (newApps.isNotEmpty()) appDao.insertAll(newApps) - } - queue.addAll(appIds) - } - } - } catch (ce: kotlin.coroutines.cancellation.CancellationException) { - throw ce - } catch (e: Exception) { - Timber.w(e, "PICS package batch processing failed") - } - - if (queue.isNotEmpty()) { - // App access tokens for the package's apps, then re-queue. - val tokens = HashMap() - val tokJson = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsAccessTokens(queue, emptyList()) - } - } - if (tokJson != null) { - JSONObject(tokJson).optJSONObject("appTokens")?.let { at -> - for (k in at.keys()) { - tokens[k.toInt()] = at.getString(k).toLongOrNull() ?: 0L - } - } - } - queue - .map { PICSRequest(id = it, accessToken = tokens[it] ?: 0L) } - .chunked(MAX_PICS_BUFFER) - .forEach { chunk -> - Timber.d("bufferedPICSGetProductInfo: Queueing ${chunk.size} for PICS") - appPicsChannel.send(chunk) - } - } - } - } - } - - /** Re-fetches apps whose stored manifest download>size (impossible — compressed can't exceed uncompressed); re-stores only when the fresh appinfo is clean so a bad response never overwrites good data. Self-limiting once rows are clean. */ - private fun healCorruptManifestDownloadSizes(): Job = - scope.launch { - if (!isLoggedIn) return@launch - - fun SteamApp.hasCorruptDownload(): Boolean = - depots.values.any { depot -> - depot.manifests.values.any { m -> m.size > 0L && m.download > m.size } - } - - val corruptAppIds = - runCatching { - withContext(Dispatchers.IO) { appDao.getAllAsList() } - .filter { it.hasCorruptDownload() } - .map { it.id } - }.getOrElse { e -> - Timber.w(e, "heal: scan for corrupt manifest download sizes failed") - return@launch - } - if (corruptAppIds.isEmpty()) return@launch - Timber.i( - "heal: ${corruptAppIds.size} app(s) have download>size; re-fetching: ${corruptAppIds.sorted()}", - ) - - // Owned-app appinfo only comes back in full with the access token. - val tokenMap = HashMap() - runCatching { - withWnSession { session -> - withContext(Dispatchers.IO) { session.getPicsAccessTokens(corruptAppIds, emptyList()) } - }?.let { tokJson -> - JSONObject(tokJson).optJSONObject("appTokens")?.let { at -> - for (k in at.keys()) tokenMap[k.toInt()] = at.getString(k).toLongOrNull() ?: 0L - } - } - }.onFailure { e -> Timber.w(e, "heal: access-token fetch failed; trying public appinfo") } - - var healedCount = 0 - corruptAppIds.chunked(MAX_PICS_BUFFER).forEach { chunk -> - ensureActive() - val json = - withWnSession { session -> - withContext(Dispatchers.IO) { - session.getPicsAppProductInfo(chunk, chunk.map { tokenMap[it] ?: 0L }) - } - } ?: return@forEach - runCatching { - val arr = JSONArray(json) - val healed = mutableListOf() - for (i in 0 until arr.length()) { - ensureActive() - val entry = arr.getJSONObject(i) - val appId = entry.optInt("appid") - val changeNumber = entry.optInt("changeNumber") - val appinfo = entry.optJSONObject("appinfo") ?: continue - val generated = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() - if (generated.id == INVALID_APP_ID) continue - // Only accept a re-fetch that actually removes the corruption. - if (generated.hasCorruptDownload()) { - Timber.w("heal: appId=$appId still reports download>size after re-fetch; leaving stored row") - continue - } - val appFromDb = appDao.findApp(appId) - val packageId = appFromDb?.packageId ?: INVALID_PKG_ID - val packageFromDb = - if (packageId != INVALID_PKG_ID) licenseDao.findLicense(packageId) else null - val existingInstallDir = appFromDb?.installDir.orEmpty() - val preserveInstallDir = - existingInstallDir.isNotEmpty() && - (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) - healed.add( - generated.copy( - packageId = packageId, - ownerAccountId = - packageFromDb?.ownerAccountId ?: appFromDb?.ownerAccountId.orEmpty(), - receivedPICS = true, - lastChangeNumber = changeNumber, - licenseFlags = - packageFromDb?.licenseFlags - ?: appFromDb?.licenseFlags - ?: EnumSet.noneOf(ELicenseFlags::class.java), - installDir = if (preserveInstallDir) existingInstallDir else generated.installDir, - ), - ) - } - if (healed.isNotEmpty()) { - db.withTransaction { appDao.insertAll(healed) } - healedCount += healed.size - } - }.onFailure { e -> Timber.w(e, "heal: batch processing failed") } - } - if (healedCount > 0) { - Timber.i("heal: corrected manifest download sizes for $healedCount app(s)") - } - } - /** Encrypted app ticket for an app (30-minute cache); serialized protobuf bytes, or null if unavailable. */ suspend fun getEncryptedAppTicket(appId: Int): ByteArray? { return try { diff --git a/app/src/main/feature/stores/steam/service/SteamServiceCloud.kt b/app/src/main/feature/stores/steam/service/SteamServiceCloud.kt new file mode 100644 index 000000000..8a9d35352 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceCloud.kt @@ -0,0 +1,562 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Launch-state priming + libsteamclient/achievements/workshop helpers, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.resolvePreferredLaunchBuildId( + app: SteamApp?, + branch: String, +): Int { + val buildId = + app?.branches?.get(branch)?.buildId + ?: app?.branches?.get("public")?.buildId + ?: app?.branches?.values?.firstOrNull()?.buildId + ?: 0L + return buildId.coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() +} + +internal fun SteamService.Companion.resolvePreferredLaunchDepotIds( + appId: Int, + branch: String, + preferredLanguage: String = PrefManager.containerLanguage, +): IntArray { + val trustedInstalledDepots = + getInstalledApp(appId) + ?.downloadedDepots + .orEmpty() + .asSequence() + .filter { it > 0 } + .distinct() + .sorted() + .toList() + if (trustedInstalledDepots.isNotEmpty()) { + return trustedInstalledDepots.toIntArray() + } + + val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty() + val fallbackSelectedDepots = + getSelectedDownloadDepots( + appId = appId, + userSelectedDlcAppIds = installedDlcAppIds, + preferredLanguage = preferredLanguage, + branch = branch, + ).keys + .asSequence() + .filter { it > 0 } + .distinct() + .sorted() + .toList() + if (fallbackSelectedDepots.isNotEmpty()) { + Timber.w( + "resolvePreferredLaunchDepotIds: appId=$appId branch=$branch " + + "had no trusted depot snapshot; using ${fallbackSelectedDepots.size} selected depot(s)", + ) + return fallbackSelectedDepots.toIntArray() + } + + return IntArray(0) +} + +internal suspend fun SteamService.Companion.primeLibSteamClientLaunchState( + appId: Int, + selectedBranch: String, +): Boolean { + val svc = instance ?: return false + val ctx = svc.applicationContext + val libSteamClient = com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + if (!libSteamClient.ensureLoaded(ctx)) { + Timber.w("primeLibSteamClientLaunchState: failed to load bridge library for appId=$appId") + return false + } + libSteamClient.seedFromPrefManager(ctx) + + val manifestBranch = selectedBranch.ifBlank { "public" } + val app = withContext(Dispatchers.IO) { svc.appDao.findApp(appId) } + val rawInstalledApp = withContext(Dispatchers.IO) { svc.appInfoDao.getInstalledApp(appId) } + val ownedIds = withContext(Dispatchers.IO) { svc.appDao.getAllAppIds() } + val installedIds = + withContext(Dispatchers.IO) { svc.appInfoDao.getAllInstalledAppIds().toMutableSet() } + .apply { + if (rawInstalledApp?.isDownloaded == true) { + add(appId) + } + } + + libSteamClient.setAppId(appId) + if (ownedIds.isNotEmpty()) { + libSteamClient.setOwnedApps(ownedIds.toIntArray()) + } + if (installedIds.isNotEmpty()) { + libSteamClient.setInstalledApps(installedIds.sorted().toIntArray()) + } + + app?.name?.takeIf { it.isNotBlank() }?.let { appName -> + libSteamClient.setAppNames(intArrayOf(appId), arrayOf(appName)) + } + + val installDir = runCatching { getAppDirPath(appId) }.getOrNull() + if (!installDir.isNullOrEmpty()) { + libSteamClient.setAppInstallDir(appId, installDir) + } + + val buildId = resolvePreferredLaunchBuildId(app, manifestBranch) + if (buildId > 0) { + libSteamClient.setAppBuildId(appId, buildId) + } + + val depotIds = resolvePreferredLaunchDepotIds(appId, manifestBranch) + if (depotIds.isNotEmpty()) { + libSteamClient.setAppInstalledDepots(appId, depotIds) + } + + app?.packageId + ?.takeIf { it != INVALID_PKG_ID } + ?.let { packageId -> + libSteamClient.setAppSourcePackages(appId, intArrayOf(packageId)) + } + + val accountId = runCatching { + com.winlator.cmod.feature.stores.steam.utils + .SteamUtils.getSteam3AccountId().toLong() + }.getOrNull() ?: 0L + if (accountId > 0L) { + val remoteDir = runCatching { + com.winlator.cmod.feature.stores.steam.enums + .PathType.SteamUserData.toAbsPath( + svc, + appId, + accountId, + ) + }.getOrNull() + if (!remoteDir.isNullOrEmpty()) { + libSteamClient.setAppCloudRemoteDir(appId, remoteDir) + } + } + + libSteamClient.setAppCurrentBeta(appId, selectedBranch) + Timber.i( + "primeLibSteamClientLaunchState: app=$appId branch=$manifestBranch " + + "buildId=$buildId depots=${depotIds.size} owned=${ownedIds.size} installed=${installedIds.size}", + ) + return true +} + +internal fun SteamService.Companion.pushAchievementSchemaToLibSteamClient( + appId: Int, + achievements: List, + stats: List, + nameToBlockBit: Map> = emptyMap(), +) { + val locale = PrefManager.containerLanguage.ifBlank { "english" } + fun pick(map: Map?): String = + map?.get(locale) ?: map?.get("english") ?: map?.values?.firstOrNull() ?: "" + + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppId(appId) + + val n = achievements.size + if (n == 0) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAchievementSchema(emptyArray(), emptyArray(), emptyArray(), emptyArray(), BooleanArray(0)) + Timber.i("Pushed empty achievement schema to libsteamclient.so (app $appId)") + return + } + + val apiNames = Array(n) { i -> achievements[i].name } + val displayNames = Array(n) { i -> pick(achievements[i].displayName) } + val descriptions = Array(n) { i -> pick(achievements[i].description) } + val icons = Array(n) { i -> achievements[i].icon ?: "" } + val hidden = BooleanArray(n) { i -> achievements[i].hidden != 0 } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAchievementSchema(apiNames, displayNames, descriptions, icons, hidden) + + val statNames = mutableListOf() + val statIds = mutableListOf() + for (s in stats) { + val id = s.id.toIntOrNull() ?: continue + if (s.name.isEmpty() || id < 0) continue + statNames.add(s.name) + statIds.add(id) + } + if (statNames.isNotEmpty()) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setStatIds(statNames.toTypedArray(), statIds.toIntArray()) + Timber.i("Pushed stat name→id map for app $appId: ${statNames.size} entries") + } + + if (nameToBlockBit.isNotEmpty()) { + val mappedNames = mutableListOf() + val mappedBlocks = mutableListOf() + val mappedBits = mutableListOf() + for (a in achievements) { + val pair = nameToBlockBit[a.name] ?: continue + mappedNames.add(a.name) + mappedBlocks.add(pair.first) + mappedBits.add(pair.second) + } + if (mappedNames.isNotEmpty()) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAchievementBlockBits( + mappedNames.toTypedArray(), + mappedBlocks.toIntArray(), + mappedBits.toIntArray(), + ) + Timber.i("Pushed achievement bit-pack mapping for app $appId: ${mappedNames.size} entries") + } + } + + var localeAddsPushed = 0 + for (a in achievements) { + val dnByLocale = a.displayName ?: emptyMap() + val dsByLocale = a.description ?: emptyMap() + val locales = dnByLocale.keys union dsByLocale.keys + for (loc in locales) { + if (loc.equals("english", ignoreCase = true)) continue + val dn = dnByLocale[loc] + val ds = dsByLocale[loc] + if (dn.isNullOrEmpty() && ds.isNullOrEmpty()) continue + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .addAchievementLocale(a.name, loc, dn, ds) + ++localeAddsPushed + } + } + + var unlocksPushed = 0 + for (a in achievements) { + val unlocked = a.unlocked == true + val ts = a.unlockTimestamp ?: 0 + if (unlocked || ts > 0) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAchievementProgress(a.name, unlocked, ts) + ++unlocksPushed + } + } + for (s in stats) { + when (s.type) { + "int" -> com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setStatInt(s.name, s.default.toIntOrNull() ?: 0) + "float" -> com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setStatFloat(s.name, s.default.toFloatOrNull() ?: 0f) + } + } + Timber.i("Pushed achievement schema to libsteamclient.so: app=$appId ach=$n unlocks=$unlocksPushed stats=${stats.size} localeAdds=$localeAddsPushed") + + cacheAchievementSchemaJson(appId, achievements, stats, nameToBlockBit) +} + +internal fun SteamService.Companion.cacheAchievementSchemaJson( + appId: Int, + achievements: List, + stats: List, + nameToBlockBit: Map> = emptyMap(), +) { + if (appId <= 0) return + val ctx = instance?.applicationContext ?: return + try { + val dir = File(ctx.filesDir, "wn_lsteam_schemas") + if (!dir.exists()) dir.mkdirs() + val root = JSONObject() + root.put("v", 1) + root.put("ts", System.currentTimeMillis() / 1000) + val achArr = JSONArray() + for (a in achievements) { + val o = JSONObject() + o.put("name", a.name) + a.displayName?.takeIf { it.isNotEmpty() }?.let { m -> + val mo = JSONObject() + m.forEach { (k, v) -> mo.put(k, v) } + o.put("displayName", mo) + } + a.description?.takeIf { it.isNotEmpty() }?.let { m -> + val mo = JSONObject() + m.forEach { (k, v) -> mo.put(k, v) } + o.put("description", mo) + } + a.icon?.takeIf { it.isNotEmpty() }?.let { o.put("icon", it) } + if (a.hidden != 0) o.put("hidden", a.hidden) + a.unlocked?.let { o.put("unlocked", it) } + a.unlockTimestamp?.let { o.put("unlockTimestamp", it) } + achArr.put(o) + } + root.put("achievements", achArr) + val statArr = JSONArray() + for (s in stats) { + val o = JSONObject() + o.put("id", s.id) + o.put("name", s.name) + o.put("type", s.type) + o.put("default", s.default) + statArr.put(o) + } + root.put("stats", statArr) + if (nameToBlockBit.isNotEmpty()) { + val bitsArr = JSONArray() + for ((name, pair) in nameToBlockBit) { + val o = JSONObject() + o.put("name", name) + o.put("block", pair.first) + o.put("bit", pair.second) + bitsArr.put(o) + } + root.put("nameToBlockBit", bitsArr) + } + File(dir, "$appId.json").writeText(root.toString(), Charsets.UTF_8) + Timber.i("Cached schema for app $appId: ${achievements.size} ach, " + + "${stats.size} stats, ${nameToBlockBit.size} bit-mappings") + } catch (t: Throwable) { + Timber.w(t, "cacheAchievementSchemaJson failed appId=$appId") + } +} + +internal fun SteamService.Companion.downloadWorkshopPreview(url: String, dest: File) { + val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection + conn.connectTimeout = 15_000 + conn.readTimeout = 30_000 + conn.instanceFollowRedirects = true + try { + if (conn.responseCode !in 200..299) return + val type = conn.contentType.orEmpty() + if (type.isNotEmpty() && !type.startsWith("image/")) return + dest.parentFile?.mkdirs() + val maxBytes = 16L * 1024 * 1024 // cap — a preview image is never this large + var over = false + var total = 0L + conn.inputStream.use { input -> + dest.outputStream().use { out -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + total += n + if (total > maxBytes) { over = true; break } + out.write(buf, 0, n) + } + } + } + // Discard an over-cap (truncated) or empty download — never let mods.json reference a corrupt preview. + if (over || total == 0L) dest.delete() + } finally { + conn.disconnect() + } +} + +internal fun SteamService.Companion.findSteamSettingsDir( + context: Context, + appId: Int, +): String? { + val appDirPath = getAppDirPath(appId) + val appDirSettings = File(appDirPath, "steam_settings") + if (appDirSettings.isDirectory) { + return appDirSettings.absolutePath + } + + val container = ContainerUtils.getContainer(context, "STEAM_$appId") ?: return null + val coldclientSettings = + File( + container.rootDir, + ".wine/drive_c/Program Files (x86)/Steam/steam_settings", + ) + if (coldclientSettings.isDirectory) { + return coldclientSettings.absolutePath + } + + return null +} + +/** Decode a hex string (from the native JNI layer) to bytes; empty array for too-short/empty input. */ +internal fun SteamService.Companion.hexToBytes(hex: String): ByteArray { + if (hex.length < 2) return ByteArray(0) + val n = hex.length / 2 + val out = ByteArray(n) + for (i in 0 until n) { + out[i] = ((Character.digit(hex[i * 2], 16) shl 4) or + Character.digit(hex[i * 2 + 1], 16)).toByte() + } + return out +} + +/** Write achievement/stat values back to Steam (CMsgClientStoreUserStats2). Fire-and-forget. */ +internal suspend fun SteamService.Companion.sendStoreUserStats( + appId: Int, + stats: Map, + steamId: Long, + crcStats: Int, +) { + if (stats.isEmpty()) return + val statIds = IntArray(stats.size) + val statValues = IntArray(stats.size) + var i = 0 + for ((id, value) in stats) { + statIds[i] = id + statValues[i] = value + i++ + } + val sent = withWnSession { session -> + session.storeUserStats(appId, steamId, crcStats, statIds, statValues) + true + } + if (sent != true) { + Timber.e("Failed to send storeUserStats for appId=$appId — no session") + } +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt new file mode 100644 index 000000000..83b21b4a5 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceConnection.kt @@ -0,0 +1,668 @@ +package com.winlator.cmod.feature.stores.steam.service +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.BACKGROUND_IDLE_GRACE_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.CONNECT_LOGON_MAX_ATTEMPTS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.MAX_PICS_BUFFER +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.RECONNECT_BACKOFF_CAP_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.REFRESH_TOKEN_ROTATION_CHECK_INTERVAL_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.REFRESH_TOKEN_ROTATION_THRESHOLD_DAYS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.WN_PLANW_REAP_OFFLINE_MS +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.instance +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isConnected +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isLoggingOut +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isRunning +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isStopping +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isWaitingForQRAuth +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.kickPlayingSessionIfReady +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.syncInProgressApps +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.userSteamId +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.withWnSession +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.wnLibrary +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.wnLibraryMirrorJob +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.wnLoggedOnHandled +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.wnSession +import android.app.Service.STOP_FOREGROUND_REMOVE +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Instance connection/session lifecycle + bionic handoff + license processing, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.suspensionReasonForDiag(): String { + val reasons = mutableListOf() + if (suspendedForBackground) reasons += "background-idle" + if (suspendedForBionic) reasons += "bionic-handoff" + if (!appInForeground) reasons += "app-bg" + if (isLoggingOut) reasons += "logging-out" + if (isStopping) reasons += "stopping" + return reasons.joinToString(",") +} + +/** Brings up (or reuses) the long-lived session and logs it on with the stored refresh token; [withWnSession] promotes it and installs the orchestrator observer (fires [onWnLoggedOn]). Retries with backoff until logged on or the service stops. Cold-start auto-logon and reconnect. */ +internal fun SteamService.connectAndLogon() { + if (connectJob?.isActive == true) return + connectJob = + scope.launch { + PluviaApp.events.emit(SteamEvent.Connected(true)) + var attempt = 0 + while (isRunning && !isStopping && PrefManager.refreshToken.isNotBlank()) { + if (wnSession?.state() == 3) break + Timber.d("connectAndLogon: bringing up WN-Steam-Client session") + val state = withWnSession { it.state() } + if (state == 3) break + attempt++ + if (attempt >= CONNECT_LOGON_MAX_ATTEMPTS) { + // Logon failed this many times — likely an expired/revoked token or sustained outage. Stop instead of spinning a full bring-up + logon forever (battery drain); a foreground wake or explicit re-login re-triggers connectAndLogon. + Timber.w("connectAndLogon: giving up after $attempt failed attempts") + break + } + val backoffMs = reconnectBackoffMs(attempt) + Timber.w("connectAndLogon: not logged on — retry $attempt in ${backoffMs}ms") + delay(backoffMs) + } + } +} + +internal fun SteamService.ensureHealthySessionImpl() { + if (!isRunning || isStopping || isLoggingOut) return + if (PrefManager.refreshToken.isBlank()) return + if (PluviaApp.isGameSessionActive()) return + if (suspendedForBionic) { + Timber.w("ensureHealthySession: clearing stale Bionic hand-off (no game running)") + bionicHandoffReleaseImpl() + return + } + if (wnSession?.state() == 3) return + Timber.i("ensureHealthySession: session not logged on — re-driving connectAndLogon") + retryAttempt = 0 + connectAndLogon() +} + +internal fun SteamService.handleAppForegrounded() { + appInForeground = true + // Cancel any pending suspend timer — the app is back, so the session must stay up regardless of how long it was minimized. + backgroundIdleJob?.cancel() + backgroundIdleJob = null + // Restore the quiet foreground notification and drop the background-chat one. + if (isRunning && !isStopping) { + runCatching { + startForeground(1, notificationHelper.createForegroundNotification("Steam Service is running")) + notificationHelper.cancelBackgroundRunning() + }.onFailure { Timber.w(it, "Failed to restore SteamService foreground notification") } + } + if (!suspendedForBackground) return + suspendedForBackground = false + Timber.i("App foregrounded — waking the WN-Steam-Client session") + retryAttempt = 0 + if (isRunning && !isStopping && PrefManager.refreshToken.isNotBlank()) { + runCatching { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setLoggedOn(true) + } + connectAndLogon() + } +} + +/** App went to the background — arm the deferred suspend check. */ +internal fun SteamService.handleAppBackgrounded() { + appInForeground = false + if (PrefManager.chatStayRunningOnExit && isRunning && !isStopping) { + runCatching { + startForeground( + NotificationHelper.BACKGROUND_RUNNING_NOTIFICATION_ID, + notificationHelper.createBackgroundRunningNotification(), + ) + notificationHelper.cancel() + }.onFailure { Timber.w(it, "Failed to show Steam background-chat notification") } + } + scheduleBackgroundSuspendCheck() +} + +/** Arm the background suspend check: [maybeSuspendForBackground] runs immediately, then repeats once per [BACKGROUND_IDLE_GRACE_MS] while connection-critical work runs (so nothing has to hook each operation's completion). A foreground event cancels it. */ +internal fun SteamService.scheduleBackgroundSuspendCheck() { + backgroundIdleJob?.cancel() + if (appInForeground || isStopping || isLoggingOut) return + backgroundIdleJob = + scope.launch { + while (isActive) { + if (appInForeground || isStopping || isLoggingOut || suspendedForBackground) { + return@launch + } + // Suspended → done. Still busy → loop and re-check later. + if (maybeSuspendForBackground()) return@launch + delay(BACKGROUND_IDLE_GRACE_MS) + } + } +} + +/** Reason the connection must stay open (null = safe to suspend) — anything that would corrupt data if the CM session dropped mid-operation: an active download (not paused/queued), a running game session, or an in-flight cloud-save sync (never interrupt or the save can be left corrupt). */ +internal fun SteamService.connectionCriticalWork(): String? = + when { + DownloadCoordinator.hasActiveDownload() -> "a download is active" + PluviaApp.isGameSessionActive() -> "a game session is running" + syncInProgressApps.values.any { it.get() } -> "a cloud save sync is in progress" + PrefManager.chatStayRunningOnExit -> "background chat is enabled" + else -> null + } + + +/** Suspend the backgrounded Steam session to draw no power — unless [connectionCriticalWork] still needs it. Disconnects the session and cancels every reconnect/PICS loop; wakes from [handleAppForegrounded]. Returns true if suspended. */ +internal fun SteamService.maybeSuspendForBackground(): Boolean { + if (appInForeground || isStopping || isLoggingOut || suspendedForBackground) return false + val keepAliveReason = connectionCriticalWork() + if (keepAliveReason != null) { + Timber.i("App backgrounded but %s — keeping the Steam session connected", keepAliveReason) + return false + } + Timber.i("App backgrounded and idle — suspending WN-Steam-Client session to save battery") + suspendedForBackground = true + connectJob?.cancel() + reconnectJob?.cancel() + stableConnectionJob?.cancel() + refreshTokenWatchdogJob?.cancel() + picsChangesCheckerJob?.cancel() + picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() + wnSession?.let { s -> runCatching { s.disconnect() } } + scope.launch(Dispatchers.Main) { + runCatching { stopForeground(STOP_FOREGROUND_REMOVE) } + .onFailure { Timber.w(it, "Failed to remove SteamService foreground state on background suspend") } + runCatching { notificationHelper.cancel() } + .onFailure { Timber.w(it, "Failed to cancel SteamService notification on background suspend") } + } + return true +} + +internal fun SteamService.startRefreshTokenWatchdog() { + refreshTokenWatchdogJob?.cancel() + refreshTokenWatchdogJob = scope.launch(Dispatchers.IO) { + while (isActive && !isStopping && !isLoggingOut) { + runCatching { maybeRotateRefreshToken() } + .onFailure { Timber.w(it, "refresh-token watchdog tick threw") } + delay(REFRESH_TOKEN_ROTATION_CHECK_INTERVAL_MS) + } + } +} + +internal suspend fun SteamService.maybeRotateRefreshToken() { + val cur = PrefManager.refreshToken + if (cur.isBlank()) return + val expMs: Long? = try { + val jwt = com.auth0.android.jwt.JWT(cur) + jwt.expiresAt?.time + } catch (t: Throwable) { + Timber.w(t, "refresh-token watchdog: JWT decode failed; rotating defensively") + null + } + val nowMs = System.currentTimeMillis() + val thresholdMs = REFRESH_TOKEN_ROTATION_THRESHOLD_DAYS * 24L * 60L * 60L * 1000L + val needsRotation = expMs == null || (expMs - nowMs) < thresholdMs + if (!needsRotation) { + val daysLeft = ((expMs!! - nowMs) / (24L * 60L * 60L * 1000L)).coerceAtLeast(0) + Timber.d("refresh-token watchdog: token healthy ($daysLeft days to expiry)") + return + } + val rotated = renewRefreshTokenForHandoff() + Timber.i("refresh-token watchdog: rotation attempt → $rotated") +} + +internal suspend fun SteamService.renewRefreshTokenForHandoff(): Boolean = + withContext(Dispatchers.IO) { + val session = wnSession ?: return@withContext false + val current = PrefManager.refreshToken + val sid = PrefManager.steamUserSteamId64 + if (current.isEmpty() || sid == 0L) return@withContext false + val fresh = try { + session.renewRefreshToken(current, sid, timeoutMs = 15_000) + } catch (t: Throwable) { + Timber.w(t, "renewRefreshToken threw") + null + } + if (fresh.isNullOrEmpty()) { + Timber.w("renewRefreshTokenForHandoff: no new token returned") + return@withContext false + } + PrefManager.refreshToken = fresh + Timber.i("renewRefreshTokenForHandoff: token rotated (len ${current.length} -> ${fresh.length})") + true + } + + +internal fun SteamService.bionicHandoffAcquireImpl() { + if (suspendedForBionic) return + suspendedForBionic = true + Timber.i("Bionic hand-off ACQUIRE — suspending WN-Steam-Client session for the bootstrap") + connectJob?.cancel() + reconnectJob?.cancel() + stableConnectionJob?.cancel() + refreshTokenWatchdogJob?.cancel() + picsChangesCheckerJob?.cancel() + picsGetProductInfoJob?.cancel() + messagePollerJob?.cancel() + wnSession?.let { s -> runCatching { s.logOffAndDisconnect(500) } } +} + +internal fun SteamService.bionicHandoffReleaseImpl() { + runCatching { + com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamBootstrap.stop() + } + if (!suspendedForBionic) return + suspendedForBionic = false + retryAttempt = 0 + if (!(isRunning && !isStopping && !isLoggingOut && PrefManager.refreshToken.isNotBlank())) { + Timber.i("Bionic hand-off RELEASE — not resuming (service not in a resumable state)") + return + } + // PlanW: defer the wn-session resume so the account stays offline long enough for Steam to reap the launcher's games-played registration; resuming immediately keeps it online and the next launch hits AlreadyRunning (0x10). + if (PrefManager.wnPlanW) { + Timber.i( + "Bionic hand-off RELEASE — PlanW: deferring wn-session resume " + + "${WN_PLANW_REAP_OFFLINE_MS}ms so Steam reaps the launcher's " + + "games-played registration (account stays offline)", + ) + scope.launch(Dispatchers.IO) { + delay(WN_PLANW_REAP_OFFLINE_MS) + // Skip if a new launch re-acquired the hand-off, or the session already came up some other way. + if (!suspendedForBionic && isRunning && !isStopping && !isLoggingOut && + PrefManager.refreshToken.isNotBlank() && (wnSession?.state() ?: 0) != 3 + ) { + Timber.i("Bionic hand-off RELEASE — PlanW reap window elapsed, resuming WN-Steam-Client") + connectAndLogon() + } else { + Timber.i( + "Bionic hand-off RELEASE — PlanW reap window elapsed, resume skipped " + + "(suspendedForBionic=$suspendedForBionic wnState=${wnSession?.state()})", + ) + } + } + } else { + Timber.i("Bionic hand-off RELEASE — bootstrap logged off, resuming WN-Steam-Client") + connectAndLogon() + } +} + +internal fun SteamService.bionicHandoffReleaseAndKickPlayingSessionAsyncImpl(onlyGame: Boolean) { + bionicHandoffReleaseImpl() + if (!isRunning || isStopping || isLoggingOut) return + scope.launch(Dispatchers.IO) { + repeat(20) { attempt -> + if (kickPlayingSessionIfReady(onlyGame)) { + Timber.i( + "Bionic hand-off release: kickPlayingSessionIfReady fired " + + "after reconnect (attempt ${attempt + 1}/20 onlyGame=$onlyGame)", + ) + return@launch + } + delay(500L) + } + Timber.i( + "Bionic hand-off release: wn-session never became ready " + + "for kickPlayingSessionIfReady (onlyGame=$onlyGame)", + ) + } +} + +internal suspend fun SteamService.bionicHandoffReleaseAndKickPlayingSessionBlockingImpl( + onlyGame: Boolean, + maxWaitMs: Long, +): Boolean { + bionicHandoffReleaseImpl() + if (!isRunning || isStopping || isLoggingOut) return false + + val deadlineMs = System.currentTimeMillis() + maxWaitMs + var attempt = 0 + do { + attempt++ + if (kickPlayingSessionIfReady(onlyGame)) { + Timber.i( + "Bionic hand-off release: kickPlayingSessionIfReady fired " + + "before close (attempt $attempt onlyGame=$onlyGame)", + ) + return true + } + + val remainingMs = deadlineMs - System.currentTimeMillis() + if (remainingMs <= 0L) break + delay(minOf(250L, remainingMs)) + } while (true) + + Timber.i( + "Bionic hand-off release: wn-session never became ready " + + "before close (onlyGame=$onlyGame timeoutMs=$maxWaitMs)", + ) + return false +} + +internal fun SteamService.clearValues() { + if (instance === this) { + instance = null + } + + _loginResult = LoginResult.Failed + isRunning = false + isConnected = false + isLoggingOut = false + isWaitingForQRAuth = false + + wnLoggedOnHandled = false + wnLibraryMirrorJob?.cancel() + wnLibraryMirrorJob = null + wnLibrary?.stopObserving() + wnLibrary = null + + _unifiedFriends?.close() + _unifiedFriends = null + + isStopping = false + retryAttempt = 0 + reconnectJob?.cancel() + reconnectJob = null + stableConnectionJob?.cancel() + refreshTokenWatchdogJob?.cancel() + stableConnectionJob = null + backgroundIdleJob?.cancel() + backgroundIdleJob = null + suspendedForBackground = false + suspendedForBionic = false + appInForeground = true + + PluviaApp.events.off(onEndProcess) + PluviaApp.events.clearAllListenersOf>() +} + +/** Channel-dropped (onDisconnected) handler: reconnects while credentials + retries remain, else emits Disconnected and stops the service. Fired from the [installWnLogonObserver] state observer. */ +/** Exponential reconnect backoff (2s, 4s, 8s… doubling per 1-based attempt, capped at [RECONNECT_BACKOFF_CAP_MS]) — without it a connection that logs on then drops reconnects in a tight loop and overheats the device. */ +internal fun SteamService.reconnectBackoffMs(attempt: Int): Long { + val shift = (attempt - 1).coerceIn(0, 8) // 2^0 .. 2^8 + val seconds = (1L shl shift) * 2L // 2, 4, 8, , 512 + return (seconds * 1000L).coerceAtMost(RECONNECT_BACKOFF_CAP_MS) +} + +/** Populate the steam_license / cached_license tables from the received licenses (CMsgClientLicenseList); driven from the post-logon flow. */ +internal suspend fun SteamService.processLicenseList() { + // The license list is pushed just after logon; poll briefly for it. + var json: String? = null + for (attempt in 0 until 15) { + json = withWnSession { session -> session.getLicenseList() } + if (json != null && json != "[]") break + delay(200) + } + val arr = + try { + JSONArray(json ?: "[]") + } catch (e: Exception) { + Timber.w(e, "processLicenseList: bad license JSON") + return + } + if (arr.length() == 0) { + Timber.w("processLicenseList: no licenses received") + return + } + Timber.i("Received License List, size: ${arr.length()}") + + data class RawLicense( + val packageId: Int, val changeNumber: Int, + val timeCreated: Long, val timeNextProcess: Long, + val minuteLimit: Int, val minutesUsed: Int, + val paymentMethod: Int, val flags: Int, + val purchaseCountryCode: String, val licenseType: Int, + val territoryCode: Int, val accessToken: Long, + val ownerId: Int, val masterPackageId: Int, + ) + val raw = + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + RawLicense( + packageId = o.optInt("packageId"), + changeNumber = o.optInt("changeNumber"), + timeCreated = o.optLong("timeCreated"), + timeNextProcess = o.optLong("timeNextProcess"), + minuteLimit = o.optInt("minuteLimit"), + minutesUsed = o.optInt("minutesUsed"), + paymentMethod = o.optInt("paymentMethod"), + flags = o.optInt("flags"), + purchaseCountryCode = o.optString("purchaseCountryCode"), + licenseType = o.optInt("licenseType"), + territoryCode = o.optInt("territoryCode"), + accessToken = o.optLong("accessToken"), + ownerId = o.optInt("ownerId"), + masterPackageId = o.optInt("masterPackageId"), + ) + } + + db.withTransaction { + // Every launch refreshes licenses, so findStaleLicences picks up packages we no longer have (e.g. family-share changes). + + // Store raw licenses for the manifest-fetch path (CachedLicense). + cachedLicenseDao.deleteAll() + cachedLicenseDao.insertAll( + raw.map { l -> + CachedLicense( + licenseJson = + LicenseSerializer.serializeLicenseFields( + packageID = l.packageId, + lastChangeNumber = l.changeNumber, + timeCreatedMs = l.timeCreated * 1000L, + timeNextProcessMs = l.timeNextProcess * 1000L, + minuteLimit = l.minuteLimit, + minutesUsed = l.minutesUsed, + paymentMethod = l.paymentMethod, + flags = l.flags, + purchaseCode = l.purchaseCountryCode, + licenseType = l.licenseType, + territoryCode = l.territoryCode, + accessToken = l.accessToken, + ownerAccountID = l.ownerId, + masterPackageID = l.masterPackageId, + ), + ) + }, + ) + + val myAccountId = userSteamId?.accountID?.toInt() + val licensesToAdd = + raw.groupBy { it.packageId }.map { (packageId, group) -> + val preferred = + group.firstOrNull { it.ownerId == myAccountId } + ?: group.first() + // OR-combine the flag bitfields across every owner of the package. + val combinedFlags = EnumSet.noneOf(ELicenseFlags::class.java) + group.forEach { combinedFlags.addAll(ELicenseFlags.from(it.flags)) } + SteamLicense( + packageId = packageId, + lastChangeNumber = preferred.changeNumber, + timeCreated = Date(preferred.timeCreated * 1000L), + timeNextProcess = Date(preferred.timeNextProcess * 1000L), + minuteLimit = preferred.minuteLimit, + minutesUsed = preferred.minutesUsed, + paymentMethod = EPaymentMethod.from(preferred.paymentMethod) ?: EPaymentMethod.None, + licenseFlags = combinedFlags, + purchaseCode = preferred.purchaseCountryCode, + licenseType = ELicenseType.from(preferred.licenseType) ?: ELicenseType.NoLicense, + territoryCode = preferred.territoryCode, + accessToken = preferred.accessToken, + ownerAccountId = group.map { it.ownerId }, + masterPackageID = preferred.masterPackageId, + ) + } + + if (licensesToAdd.isNotEmpty()) { + Timber.i("Adding ${licensesToAdd.size} licenses") + licenseDao.insertAll(licensesToAdd) + } + + val licensesToRemove = + licenseDao.findStaleLicences(packageIds = raw.map { it.packageId }) + if (licensesToRemove.isNotEmpty()) { + Timber.i("Removing ${licensesToRemove.size} (stale) licenses") + licenseDao.deleteStaleLicenses(licensesToRemove.map { it.packageId }) + } + + licenseDao + .getAllLicenses() + .map { PICSRequest(it.packageId, it.accessToken) } + .chunked(MAX_PICS_BUFFER) + .forEach { chunk -> + Timber.d("processLicenseList: Queueing ${chunk.size} package(s) for PICS") + packagePicsChannel.send(chunk) + } + } +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceControllerConfig.kt b/app/src/main/feature/stores/steam/service/SteamServiceControllerConfig.kt new file mode 100644 index 000000000..d152a9d6d --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceControllerConfig.kt @@ -0,0 +1,321 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Steam Input / controller-config parsing helpers, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.selectSteamControllerConfig(details: List): SteamControllerConfigDetail? { + if (details.isEmpty()) return null + + val branchPriority = listOf("default", "public") + val controllerPriority = + listOf( + "controller_xbox360", + "controller_xboxone", + "controller_steamcontroller_gordon", + "controller_generic", + ) + + for (branch in branchPriority) { + for (controllerType in controllerPriority) { + val match = + details.firstOrNull { detail -> + detail.controllerType.equals(controllerType, ignoreCase = true) && + detail.enabledBranches.any { it.equals(branch, ignoreCase = true) } + } + if (match != null) return match + } + } + + return null +} + +internal fun SteamService.Companion.resolveSteamInputManifestFile( + appId: Int, + appDirPath: String, +): File? { + val manifestPath = + getAppInfoOf(appId) + ?.config + ?.steamInputManifestPath + ?.trim() + .orEmpty() + if (manifestPath.isEmpty()) return null + + return resolvePathCaseInsensitive(appDirPath, manifestPath) +} + +internal fun SteamService.Companion.loadConfigFromManifest(manifestFile: File): String? { + if (!manifestFile.exists()) return null + val manifestDirPath = manifestFile.parentFile?.path ?: return null + + val manifestText = manifestFile.readText(Charsets.UTF_8) + val configText = + try { + parseManifestForConfig(manifestDirPath, manifestText) + } catch (e: Exception) { + Timber.e(e, "Failed to parse Steam Input manifest config at ${manifestFile.path}") + return null + } + return configText ?: manifestText +} + +internal fun SteamService.Companion.parseManifestForConfig( + manifestDirPath: String, + manifestText: String, +): String? { + return try { + val kv = KeyValue.loadFromString(manifestText) ?: return null + val actionManifest = + if (kv.name?.equals("Action Manifest", ignoreCase = true) == true) { + kv + } else { + kv["Action Manifest"] + } + if (actionManifest === KeyValue.INVALID) return null + + val configs = actionManifest["configurations"] + if (configs === KeyValue.INVALID || configs.children.isEmpty()) { + throw IllegalStateException("No configurations found in Action Manifest") + } + + val preferredControllers = + listOf( + "controller_xboxone", + "controller_steamcontroller_gordon", + "controller_generic", + "controller_xbox360", + ) + + for (controllerType in preferredControllers) { + val controllerBlock = configs[controllerType] + if (controllerBlock === KeyValue.INVALID) continue + + for (entry in controllerBlock.children) { + val pathNode = entry["path"] + val configPath = pathNode.asString().orEmpty() + if (pathNode === KeyValue.INVALID || configPath.isEmpty()) continue + + val configFile = + resolvePathCaseInsensitive(manifestDirPath, configPath) + ?: continue + return configFile.readText(Charsets.UTF_8) + } + } + + throw IllegalStateException("No valid controller configuration found in Action Manifest") + } catch (e: Exception) { + Timber.e(e, "Failed to parse Steam Input manifest config") + null + } +} + +internal fun SteamService.Companion.resolvePathCaseInsensitive( + baseDirPath: String, + relativePath: String, +): File? { + val normalizedPath = relativePath.replace('\\', '/') + val directFile = File(baseDirPath, normalizedPath) + if (directFile.exists()) return directFile + + var currentDir = File(baseDirPath) + if (!currentDir.exists() || !currentDir.isDirectory) return null + + val segments = normalizedPath.split('/').filter { it.isNotEmpty() } + for ((index, segment) in segments.withIndex()) { + if (segment == ".") continue + if (segment == "..") { + currentDir = currentDir.parentFile ?: return null + continue + } + val entries = currentDir.listFiles() ?: return null + val matched = + entries.firstOrNull { + it.name.equals(segment, ignoreCase = true) + } ?: return null + + if (index == segments.lastIndex) { + return matched + } + + if (!matched.isDirectory) return null + currentDir = matched + } + + return null +} + +internal fun SteamService.Companion.readBuiltInSteamInputTemplate(fileName: String): String? { + val assets = instance?.assets ?: return null + return runCatching { + assets.open("steaminput/$fileName").use { stream -> + stream.readBytes().toString(Charsets.UTF_8) + } + }.getOrNull() +} + +internal fun SteamService.Companion.readDownloadedSteamInputTemplate(appId: Int): String? { + val configFile = File(getAppDirPath(appId), STEAM_CONTROLLER_CONFIG_FILENAME) + if (!configFile.exists()) return null + return configFile.readText(Charsets.UTF_8) +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceDepot.kt b/app/src/main/feature/stores/steam/service/SteamServiceDepot.kt new file mode 100644 index 000000000..e63c0220f --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceDepot.kt @@ -0,0 +1,443 @@ +package com.winlator.cmod.feature.stores.steam.service +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.GroupedBaseAppDlcDepot +import com.winlator.cmod.feature.stores.steam.service.SteamService.ManifestSizes +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Depot/manifest resolution + size calculation, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.getEntitledDepotIds(packageId: Int): Set? { + if (packageId == INVALID_PKG_ID) return null + val depotIds = + runBlocking(Dispatchers.IO) { + instance + ?.licenseDao + ?.findLicense(packageId) + ?.depotIds + .orEmpty() + } + return depotIds.takeIf { it.isNotEmpty() }?.toSet() +} + +internal fun SteamService.Companion.isDepotEntitled( + depotId: Int, + depot: DepotInfo, + entitledDepotIds: Set?, +): Boolean { + // Explicit package grant wins (covers low-violence / regional packages). + if (entitledDepotIds != null && depotId in entitledDepotIds) return true + // Low-violence content needs an explicit grant; Steam denies its depot key otherwise. + if (depot.lowViolence) return false + if (entitledDepotIds == null) return true + if (depot.sharedInstall || depot.depotFromApp != INVALID_APP_ID) return true + // Package depot lists are often incomplete for base content; owning the app entitles it. + return depot.dlcAppId == INVALID_APP_ID +} + +internal fun SteamService.Companion.getSelectedDownloadDepots( + appId: Int, + userSelectedDlcAppIds: Collection, + preferredLanguage: String = PrefManager.containerLanguage, + branch: String = "public", +): Map { + val downloadableDepots = getDownloadableDepots(appId, preferredLanguage) + if (downloadableDepots.isEmpty()) return emptyMap() + + val selectedDlcIds = userSelectedDlcAppIds.toSet() + val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id }.toSet() + val mainDepots = getMainAppDepots(appId) + val appInfoForGrouping = getAppInfoOf(appId) + val groupedBaseDlcDepotIds = + appInfoForGrouping + ?.let { getGroupedBaseAppDlcContentDepotIds(it) } + .orEmpty() + // Base-package-entitled depots are always base content; never let positional DLC grouping drop them (a DLC marker preceding base depots would zero the game's size, e.g. DMC5 601151/2). + val baseEntitledDepotIds = + appInfoForGrouping?.packageId?.let { getEntitledDepotIds(it) }.orEmpty() + + val selectedMainDepots = + mainDepots.filter { (depotId, depot) -> + ( + depot.dlcAppId == INVALID_APP_ID && + (depotId !in groupedBaseDlcDepotIds || depotId in baseEntitledDepotIds) + ) || + (depot.dlcAppId in selectedDlcIds && resolveDepotManifestInfo(depot, branch) != null) + } + getSelectedBaseAppDlcContentDepots(appId, selectedDlcIds, preferredLanguage, branch) + + val selectedDlcDepots = + downloadableDepots.filter { (depotId, depot) -> + depotId !in selectedMainDepots && + depot.dlcAppId in selectedDlcIds && + depot.dlcAppId in indirectDlcAppIds && + resolveDepotManifestInfo(depot, branch) != null + } + + return selectedMainDepots + selectedDlcDepots +} + +internal fun SteamService.Companion.getGroupedBaseAppDlcContentDepotIds(appInfo: SteamApp): Set { + return getGroupedBaseAppDlcDepots(appInfo).map { it.depotId }.toSet() +} + +internal fun SteamService.Companion.getGroupedBaseAppDlcIds( + appInfo: SteamApp, + preferredLanguage: String = PrefManager.containerLanguage, + has64Bit: Boolean = + appInfo.depots.values.any { + it.osArch == OSArch.Arch64 && + (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) + }, +): Set { + return getGroupedBaseAppDlcDepots(appInfo) + .filter { groupedDepot -> + filterForDownloadableDepots(groupedDepot.depot, has64Bit, preferredLanguage, ownedDlc = null) + }.map { it.dlcAppId } + .toSet() +} + +internal fun SteamService.Companion.getGroupedBaseAppDlcDepots(appInfo: SteamApp): List { + val declaredDlcIds = + ( + appInfo.dlcAppIds.asSequence() + + appInfo.depots.values.asSequence() + .map { it.dlcAppId } + .filter { it != INVALID_APP_ID } + ).toSet() + if (declaredDlcIds.isEmpty()) return emptyList() + + val depotIds = mutableListOf() + var activeDlcAppId: Int? = null + for ((depotId, depot) in appInfo.depots) { + val isDlcMarkerDepot = + depotId in declaredDlcIds && + depot.manifests.isEmpty() + if (isDlcMarkerDepot) { + activeDlcAppId = depotId + continue + } + + val dlcAppId = activeDlcAppId + if (dlcAppId != null && depot.dlcAppId == INVALID_APP_ID) { + depotIds += GroupedBaseAppDlcDepot(depotId, dlcAppId, depot) + } + } + + return depotIds +} + +internal fun SteamService.Companion.getSelectedBaseAppDlcContentDepots( + appId: Int, + selectedDlcAppIds: Collection, + preferredLanguage: String = PrefManager.containerLanguage, + branch: String = "public", +): Map { + if (selectedDlcAppIds.isEmpty()) return emptyMap() + val appInfo = getAppInfoOf(appId) ?: return emptyMap() + val selectedDlcIds = selectedDlcAppIds.toSet() + val declaredDlcIds = + ( + appInfo.dlcAppIds.asSequence() + + appInfo.depots.values.asSequence() + .map { it.dlcAppId } + .filter { it != INVALID_APP_ID } + ).toSet() + if (declaredDlcIds.isEmpty()) return emptyMap() + + val has64Bit = + appInfo.depots.values.any { + it.osArch == OSArch.Arch64 && + (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) + } + + val selectedDepots = linkedMapOf() + var activeDlcAppId: Int? = null + for ((depotId, depot) in appInfo.depots) { + val isDlcMarkerDepot = + depotId in declaredDlcIds && + depot.manifests.isEmpty() + if (isDlcMarkerDepot) { + activeDlcAppId = depotId.takeIf { it in selectedDlcIds } + continue + } + + val selectedDlcAppId = + when { + depot.dlcAppId in selectedDlcIds -> depot.dlcAppId + depot.dlcAppId == INVALID_APP_ID -> activeDlcAppId + else -> null + } ?: continue + + val selectedDepot = + if (depot.dlcAppId == selectedDlcAppId) { + depot + } else { + depot.copy(dlcAppId = selectedDlcAppId) + } + + if (!filterForDownloadableDepots(selectedDepot, has64Bit, preferredLanguage, ownedDlc = null)) continue + if (resolveDepotManifestInfo(selectedDepot, branch) == null) continue + selectedDepots[depotId] = selectedDepot + } + + if (selectedDepots.isNotEmpty()) { + Timber.i( + "Recovered base-app DLC content depots for appId=$appId " + + "selectedDlcAppIds=${selectedDlcIds.sorted()} " + + "depotIdsByDlc=${selectedDepots.values.groupBy({ it.dlcAppId }, { it.depotId })}", + ) + } + return selectedDepots +} + +internal fun SteamService.Companion.resolveDepotManifestInfo( + depot: DepotInfo, + branch: String, + visitedApps: MutableSet = mutableSetOf(), +): ManifestInfo? { + depot.manifests[branch]?.let { return it } + depot.encryptedManifests[branch]?.let { return it } + + if (!branch.equals("public", ignoreCase = true)) { + depot.manifests["public"]?.let { return it } + depot.encryptedManifests["public"]?.let { return it } + } + + val sourceAppId = depot.depotFromApp + if (sourceAppId == INVALID_APP_ID || !visitedApps.add(sourceAppId)) { + return null + } + + val sourceDepot = getAppInfoOf(sourceAppId)?.depots?.get(depot.depotId) ?: return null + return resolveDepotManifestInfo(sourceDepot, branch, visitedApps) +} + +internal fun SteamService.Companion.manifestDownloadBytes(manifest: ManifestInfo?): Long { + if (manifest == null) return 0L + val size = manifest.size.coerceAtLeast(0L) + // Compressed download can't exceed uncompressed size; reject bogus stored values (legacy depots showed tens of TiB), bound to size — healCorruptManifestDownloadSizes() restores the real value. + return manifest.download.takeIf { it in 1L..size } ?: size +} + +internal fun SteamService.Companion.calculateManifestSizes( + depots: Collection, + branch: String, +): ManifestSizes { + var totalInstallSize = 0L + var totalDownloadSize = 0L + + depots.forEach { depot -> + val manifest = resolveDepotManifestInfo(depot, branch) + totalInstallSize += manifest?.size ?: 0L + totalDownloadSize += manifestDownloadBytes(manifest) + } + + return ManifestSizes( + installSize = totalInstallSize, + downloadSize = totalDownloadSize, + ) +} + +internal fun SteamService.Companion.filterAlreadyInstalledDepots( + appId: Int, + depots: Map, + includeInstalledDepots: Boolean, +): Map { + if (includeInstalledDepots || depots.isEmpty()) return depots + + val installedApp = getTrustedInstalledAppInfo(appId) ?: return depots + val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toSet() + + return depots.filter { (depotId, depot) -> + val isInstalledBaseDepot = + depot.dlcAppId == INVALID_APP_ID || + depotId in installedApp.downloadedDepots + val isInstalledDlcDepot = + depot.dlcAppId != INVALID_APP_ID && + depot.dlcAppId in installedDlcAppIds + + !isInstalledBaseDepot && !isInstalledDlcDepot + } +} + +internal fun SteamService.Companion.filterAlreadyInstalledDlcSelection( + appId: Int, + dlcAppIds: List, + includeInstalledDepots: Boolean, + customInstallPath: String?, +): List { + val selected = dlcAppIds.distinct() + if (selected.isEmpty() || includeInstalledDepots || customInstallPath != null) return selected + + val installedDlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toSet() + if (installedDlcAppIds.isEmpty()) return selected + + val filtered = selected.filterNot { it in installedDlcAppIds } + val skipped = selected - filtered.toSet() + if (skipped.isNotEmpty()) { + Timber.i( + "Skipping already-installed Steam DLC selection for appId=$appId " + + "dlcAppIds=${skipped.sorted()}", + ) + } + return filtered +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceDownloadApp.kt b/app/src/main/feature/stores/steam/service/SteamServiceDownloadApp.kt new file mode 100644 index 000000000..f44199bc0 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceDownloadApp.kt @@ -0,0 +1,218 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Private app-download helper overload, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.downloadApp( + appId: Int, + dlcAppIds: List, + includeInstalledDepots: Boolean, + enableVerify: Boolean, + allowPersistedProgress: Boolean = false, + hasPersistedResumeRow: Boolean = false, + customInstallPath: String? = null, + downloadTaskType: String = DownloadRecord.TASK_INSTALL, + targetDepotIds: Set? = null, +): DownloadInfo? { + ensureFreshDepotData(appId) + val appInfo = getAppInfoOf(appId) + if (appInfo == null) { + Timber.e("Download aborted: Could not find AppInfo for appId: $appId") + return null + } + + val effectiveDlcAppIds = + filterAlreadyInstalledDlcSelection( + appId = appId, + dlcAppIds = dlcAppIds, + includeInstalledDepots = includeInstalledDepots, + customInstallPath = customInstallPath, + ) + + val downloadableDepots = getDownloadableDepots(appId) + if (downloadableDepots.isEmpty()) { + Timber.w("Download aborted: No downloadable depots found for appId: $appId") + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show(service.applicationContext, "No downloadable content found for this game", Toast.LENGTH_LONG) + } + } + return null + } + + // Delegate to the full depot-level downloadApp overload + return downloadApp( + appId = appId, + downloadableDepots = downloadableDepots, + userSelectedDlcAppIds = effectiveDlcAppIds, + branch = "public", + includeInstalledDepots = includeInstalledDepots, + enableVerify = enableVerify, + allowPersistedProgress = allowPersistedProgress, + hasPersistedResumeRow = hasPersistedResumeRow, + customInstallPath = customInstallPath, + downloadTaskType = downloadTaskType, + targetDepotIds = targetDepotIds, + ) +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceDownloadFinalize.kt b/app/src/main/feature/stores/steam/service/SteamServiceDownloadFinalize.kt new file mode 100644 index 000000000..7bd69c35c --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceDownloadFinalize.kt @@ -0,0 +1,554 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Download finalize/verify/complete, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.finalizeSnapshotResumeAsComplete( + appId: Int, + appDirPath: String, + mainAppDepots: Map, + dlcAppDepots: Map, + userSelectedDlcAppIds: List, +): DownloadInfo { + val downloadingAppIds = CopyOnWriteArrayList() + val calculatedDlcAppIds = CopyOnWriteArrayList() + val allDepotIdsByDlcAppId = + dlcAppDepots.values + .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) + .mapValues { (_, depotIds) -> depotIds.sorted() } + + userSelectedDlcAppIds.forEach { dlcAppId -> + if (allDepotIdsByDlcAppId[dlcAppId]?.isNotEmpty() == true) { + downloadingAppIds.add(dlcAppId) + calculatedDlcAppIds.add(dlcAppId) + } + } + + if (mainAppDepots.isNotEmpty() && !downloadingAppIds.contains(appId)) { + downloadingAppIds.add(appId) + } + + val info = DownloadInfo(1, appId, downloadingAppIds) + info.setPersistencePath(appDirPath) + info.updateStatus(DownloadPhase.COMPLETE) + info.setProgress(1f) + downloadJobs[appId] = info + notifyDownloadStarted(appId) + + val selectedDlcAppIdSet = userSelectedDlcAppIds.toSet() + val mainAppDlcIds = + getMainAppDlcIdsWithoutProperDepotDlcIds(appId) + .filterTo(mutableListOf()) { it in selectedDlcAppIdSet } + mainAppDlcIds.addAll( + mainAppDepots.values + .map { it.dlcAppId } + .filter { it != INVALID_APP_ID && it in selectedDlcAppIdSet } + .distinct(), + ) + if (dlcAppDepots.isEmpty()) { + mainAppDlcIds.addAll( + mainAppDepots + .filter { it.value.dlcAppId != INVALID_APP_ID && it.value.dlcAppId in selectedDlcAppIdSet } + .map { it.value.dlcAppId } + .distinct(), + ) + } + mainAppDlcIds.addAll(calculatedDlcAppIds.filter { it !in mainAppDlcIds }) + + runBlocking(Dispatchers.IO) { + if (mainAppDepots.isNotEmpty()) { + completeAppDownload( + downloadInfo = info, + downloadingAppId = appId, + entitledDepotIds = mainAppDepots.keys.sorted(), + selectedDlcAppIds = mainAppDlcIds, + appDirPath = appDirPath, + ) + } + + calculatedDlcAppIds.forEach { dlcAppId -> + val dlcDepotIds = allDepotIdsByDlcAppId[dlcAppId].orEmpty() + completeAppDownload( + downloadInfo = info, + downloadingAppId = dlcAppId, + entitledDepotIds = dlcDepotIds, + selectedDlcAppIds = emptyList(), + appDirPath = appDirPath, + ) + } + + instance?.downloadingAppInfoDao?.deleteApp(appId) + Unit + } + + // Show success message to user for no-op/resume completion + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show(service.applicationContext, "Download complete", Toast.LENGTH_SHORT) + Unit + } + } + return info +} + +/** Returns one description per [expectedManifestByDepot] entry not finished at the expected manifest id in depot.config (missing, in-progress, or wrong manifest); an absent/unreadable config passes (legacy installs predate it). */ +/** Depot ids Steam denied a key for on the last native run (.DepotDownloader/denied.depots). */ +internal fun SteamService.Companion.readDeniedDepots(appDirPath: String): Set = + runCatching { + val file = File(File(appDirPath, ".DepotDownloader"), "denied.depots") + if (!file.isFile) return@runCatching emptySet() + file.readLines().mapNotNull { it.trim().toIntOrNull() }.toSet() + }.getOrDefault(emptySet()) + + +internal fun SteamService.Companion.verifyDepotConfigComplete( + appDirPath: String, + expectedManifestByDepot: Map, +): List { + if (expectedManifestByDepot.isEmpty()) return emptyList() + val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") + val installed: Map? = + runCatching { + if (!configFile.isFile) return@runCatching null + val ids = JSONObject(configFile.readText()).optJSONObject("installedManifestIDs") + ?: return@runCatching emptyMap() + buildMap { + for (key in ids.keys()) { + val depotId = key.toIntOrNull() ?: continue + put(depotId, ids.optLong(key, 0L)) + } + } + }.getOrNull() + + if (installed == null) { + Timber.w( + "Completeness gate: depot.config missing/unreadable at $configFile for " + + "${expectedManifestByDepot.size} expected depot(s); treating as pass (legacy install?)", + ) + return emptyList() + } + + val invalidManifestId = 0x7fffffffffffffffL + val failures = mutableListOf() + for ((depotId, expectedGid) in expectedManifestByDepot.toSortedMap()) { + when (val recorded = installed[depotId]) { + null -> failures.add("depot $depotId missing (expected manifest $expectedGid)") + invalidManifestId -> failures.add("depot $depotId still in-progress (expected $expectedGid)") + expectedGid -> Unit + else -> failures.add("depot $depotId recorded at manifest $recorded, expected $expectedGid") + } + } + if (failures.isEmpty()) { + Timber.i( + "Completeness gate passed: ${expectedManifestByDepot.size} depot(s) fully recorded in depot.config at $appDirPath", + ) + } + return failures +} + +/** Read-only diagnostic: logs each base depot of [appId], whether it reached [selectedDepots] and the first rule that dropped it, warning when a base content depot is dropped. */ +internal fun SteamService.Companion.logDepotScopeDiagnostics( + appId: Int, + branch: String, + selectedDepots: Map, +) { + runCatching { + val appInfo = getAppInfoOf(appId) ?: return + val preferredLanguage = PrefManager.containerLanguage + val entitledDepotIds = getEntitledDepotIds(appInfo.packageId) + val has64Bit = + appInfo.depots.values.any { + it.osArch == OSArch.Arch64 && + (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) + } + val groupedBaseDlcDepotIds = getGroupedBaseAppDlcContentDepotIds(appInfo) + val baseEntitledDepotIds = getEntitledDepotIds(appInfo.packageId).orEmpty() + + fun exclusionReason(depotId: Int, depot: DepotInfo): String { + if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) return "encrypted-only-manifest" + if (depot.manifests.isEmpty() && !depot.sharedInstall) return "no-manifest" + if (resolveDepotManifestInfo(depot, branch) == null) return "manifest-unresolved(branch=$branch)" + val osOk = + depot.osList.contains(OS.windows) || + (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)) + if (!osOk) return "os-excluded(osList=${depot.osList})" + val archOk = + when (depot.osArch) { + OSArch.Arch64, OSArch.Unknown -> true + OSArch.Arch32 -> !has64Bit + else -> false + } + if (!archOk) return "arch-excluded(osArch=${depot.osArch},has64Bit=$has64Bit)" + if (depot.language.isNotEmpty() && !depot.language.equals(preferredLanguage, ignoreCase = true)) { + return "language-mismatch(depot='${depot.language}',preferred='$preferredLanguage')" + } + if (!isDepotEntitled(depotId, depot, entitledDepotIds)) return "not-entitled" + if (depotId in groupedBaseDlcDepotIds && depotId !in baseEntitledDepotIds) return "grouped-as-dlc-content" + return "excluded-outside-base-filters" + } + + val baseDepots = appInfo.depots.filter { it.value.dlcAppId == INVALID_APP_ID } + var maxBaseContentBytes = 0L + var selectedBaseBytes = 0L + val droppedBaseContent = mutableListOf() + Timber.i( + "DEPOT-DIAG appId=$appId branch=$branch baseDepots=${baseDepots.size} " + + "selected=${selectedDepots.size} has64Bit=$has64Bit preferredLang='$preferredLanguage' " + + "entitled=${entitledDepotIds?.sorted()} groupedAsDlc=${groupedBaseDlcDepotIds.sorted()}", + ) + for ((depotId, depot) in baseDepots) { + val manifest = resolveDepotManifestInfo(depot, branch) + val size = manifest?.size ?: 0L + val included = depotId in selectedDepots + val reason = if (included) null else exclusionReason(depotId, depot) + if (manifest != null) maxBaseContentBytes += size + if (included) { + selectedBaseBytes += size + } else if (manifest != null && size > 0L) { + droppedBaseContent.add("depot=$depotId size=$size reason=$reason") + } + Timber.i( + "DEPOT-DIAG base depot=$depotId included=$included size=$size gid=${manifest?.gid} " + + "osList=${depot.osList} osArch=${depot.osArch} lang='${depot.language}' " + + "shared=${depot.sharedInstall} fromApp=${depot.depotFromApp}" + + (if (reason != null) " DROP=$reason" else ""), + ) + } + if (droppedBaseContent.isNotEmpty()) { + Timber.w( + "DEPOT-DIAG appId=$appId DROPPED ${droppedBaseContent.size} base content depot(s): " + + "selectedBaseBytes=$selectedBaseBytes maxBaseContentBytes=$maxBaseContentBytes " + + "(maxBase double-counts redundant 32/64-bit variants) -> $droppedBaseContent", + ) + } + }.onFailure { e -> Timber.w(e, "DEPOT-DIAG failed for appId=$appId") } +} + +internal suspend fun SteamService.Companion.completeAppDownload( + downloadInfo: DownloadInfo, + downloadingAppId: Int, + entitledDepotIds: List, + selectedDlcAppIds: List, + appDirPath: String, +) { + Timber.i("Item $downloadingAppId download completed, saving database") + Timber.i( + "Steam DLC downloaded item: baseAppId=${downloadInfo.gameId} completedAppId=$downloadingAppId " + + "entitledDepotIds=${entitledDepotIds.sorted()} selectedDlcAppIds=${selectedDlcAppIds.sorted()} " + + "remainingAppIds=${downloadInfo.downloadingAppIds.sorted()}", + ) + + // runCatching: a transient Room failure on one DLC row shouldn't FAIL the whole download — bytes are on disk; stale-metadata recovery fixes the row on next launch. + runCatching { + val appInfo = instance?.appInfoDao?.getInstalledApp(downloadingAppId) + if (appInfo != null) { + val updatedDownloadedDepots = (appInfo.downloadedDepots + entitledDepotIds).distinct() + val updatedDlcDepots = (appInfo.dlcDepots + selectedDlcAppIds).distinct() + + instance?.appInfoDao?.update( + AppInfo( + downloadingAppId, + isDownloaded = true, + downloadedDepots = updatedDownloadedDepots.sorted(), + dlcDepots = updatedDlcDepots.sorted(), + ), + ) + } else { + instance?.appInfoDao?.insert( + AppInfo( + downloadingAppId, + isDownloaded = true, + downloadedDepots = entitledDepotIds.sorted(), + dlcDepots = selectedDlcAppIds.sorted(), + ), + ) + } + }.onFailure { e -> + Timber.e( + e, + "DB write failed for completed item $downloadingAppId (baseApp=${downloadInfo.gameId}); " + + "files are on disk, continuing finalize anyway.", + ) + } + + // Remove completed appId from downloadInfo.dlcAppIds and check if it was actually removed + val wasRemoved = downloadInfo.downloadingAppIds.remove(downloadingAppId) + if (!wasRemoved) { + Timber.d("Item $downloadingAppId was already removed from downloading list, skipping redundant completion.") + return + } + + // All downloading appIds are removed + if (downloadInfo.downloadingAppIds.isEmpty()) { + Timber.i("All items for game ${downloadInfo.gameId} completed, running final completion logic.") + Timber.i( + "Steam DLC download complete: appId=${downloadInfo.gameId} " + + "downloadedBytes=${downloadInfo.getBytesDownloaded()} totalBytes=${downloadInfo.getTotalExpectedBytes()}", + ) + // Settle remaining bytes at the end so progress doesn't sit under 100% when complete (e.g. dedup-skipped chunks that never reported via onChunkCompleted). + val totalExpectedBytes = downloadInfo.getTotalExpectedBytes() + if (totalExpectedBytes > 0L) { + val downloadedBytes = downloadInfo.getBytesDownloaded() + val remainingBytes = (totalExpectedBytes - downloadedBytes).coerceAtLeast(0L) + if (remainingBytes > 0L) { + downloadInfo.updateBytesDownloaded(remainingBytes, System.currentTimeMillis()) + downloadInfo.emitProgressChange() + updateCoordinatorDownloadProgress(downloadInfo) + } + } + + // Defensive wrapping per marker — bytes are on disk, a single marker/DB write failing shouldn't flip the game to FAILED. + withContext(Dispatchers.IO) { + val markerAdded = + runCatching { MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) } + .getOrElse { e -> + Timber.e(e, "Failed to add DOWNLOAD_COMPLETE_MARKER at $appDirPath") + false + } + if (!markerAdded) { + Timber.e( + "DOWNLOAD_COMPLETE_MARKER write returned false for appId=${downloadInfo.gameId} at $appDirPath " + + "(disk full / permissions?). Game files are on disk but next launch may re-validate.", + ) + } + runCatching { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) } + runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } + runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } + runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } + runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } + + // Same reason as above: a Room exception here used to FAIL a fully-downloaded game with the COMPLETE marker already on disk. + val mainAppId = downloadInfo.gameId + val service = instance + if (service != null) { + runCatching { + val mainAppInfo = service.appInfoDao.getInstalledApp(mainAppId) + if (mainAppInfo != null) { + val updatedMainDlcDepots = + (mainAppInfo.dlcDepots + selectedDlcAppIds).distinct().sorted() + service.appInfoDao.update( + mainAppInfo.copy( + isDownloaded = true, + dlcDepots = updatedMainDlcDepots, + ), + ) + Timber.i( + "Marked main app $mainAppId as downloaded in DB with dlcDepots=$updatedMainDlcDepots", + ) + } else { + service.appInfoDao.insert( + AppInfo( + mainAppId, + isDownloaded = true, + dlcDepots = selectedDlcAppIds.distinct().sorted(), + ), + ) + Timber.i( + "Inserted main app $mainAppId as downloaded in DB with dlcDepots=${selectedDlcAppIds.distinct().sorted()}", + ) + } + }.onFailure { e -> + Timber.e( + e, + "Database write failed during finalize for appId=$mainAppId — bytes are on disk, " + + "marker write ${if (markerAdded) "succeeded" else "FAILED"}; download will still be marked COMPLETE.", + ) + } + } + Unit + } + + val service = instance + if (service != null) { + createSteamShortcut(service, downloadInfo.gameId) + } + + // Mark inactive BEFORE updating status so checkQueue() frees this slot — else isActive() stays true and blocks the queue until manually cleared. + downloadInfo.setActive(false) + downloadInfo.updateStatus(DownloadPhase.COMPLETE) + PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(downloadInfo.gameId)) + + downloadInfo.clearPersistedBytesDownloaded(appDirPath, sync = true) + // Notify the coordinator to advance the cross-store queue and persist COMPLETE. + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + downloadInfo.gameId.toString(), + DownloadRecord.STATUS_COMPLETE, + ) + } + checkQueue() + } + Unit +} + +internal fun SteamService.Companion.updateCoordinatorDownloadProgress(downloadInfo: DownloadInfo) { + val (displayDownloadedBytes, displayTotalBytes) = downloadInfo.getDisplayBytesProgress() + DownloadCoordinator.updateProgress( + DownloadRecord.STORE_STEAM, + downloadInfo.gameId.toString(), + displayDownloadedBytes, + displayTotalBytes, + ) +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceDownloadMain.kt b/app/src/main/feature/stores/steam/service/SteamServiceDownloadMain.kt new file mode 100644 index 000000000..0624f0760 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceDownloadMain.kt @@ -0,0 +1,1450 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Full app-download/verify/update implementation (11-arg overload), split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.downloadApp( + appId: Int, + downloadableDepots: Map, + userSelectedDlcAppIds: List, + branch: String, + includeInstalledDepots: Boolean, + enableVerify: Boolean, + allowPersistedProgress: Boolean = false, + hasPersistedResumeRow: Boolean = false, + customInstallPath: String? = null, + downloadTaskType: String = DownloadRecord.TASK_INSTALL, + targetDepotIds: Set? = null, +): DownloadInfo? { + var appDirPath = getAppDirPath(appId) + Timber.i("downloadApp called for appId: $appId, customInstallPath: $customInstallPath") + Timber.i( + "Steam DLC selection: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + + "includeInstalledDepots=$includeInstalledDepots verify=$enableVerify allowResume=$allowPersistedProgress " + + "targetDepotIds=${targetDepotIds?.sorted().orEmpty()}", + ) + + activeDownloadRecordFor(appId)?.let { activeRecord -> + val requestedScopeIds = + if (downloadTaskType == DownloadRecord.TASK_UPDATE && targetDepotIds != null) { + targetDepotIds + } else { + userSelectedDlcAppIds.toSet() + } + val isSameCoordinatorDispatch = + customInstallPath == null && + activeRecord.taskType == downloadTaskType && + parseDownloadScopeIds(activeRecord.selectedDlcs) == requestedScopeIds + if (!isSameCoordinatorDispatch) { + return rejectConflictingDownloadRequest(appId, activeRecord) + } + } + + if (customInstallPath != null) { + val appInfo = getAppInfoOf(appId) + val folderName = getAppDirName(appInfo) + val safeFolderName = if (folderName.isNotEmpty()) folderName else appId.toString() + + val customFile = File(customInstallPath) + val finalPath = + if (customFile.name.equals(safeFolderName, ignoreCase = true)) { + // User selected the game folder itself + normalizeInstallPath(customFile.absolutePath) + } else { + // User selected parent folder, create/use subfolder + normalizeInstallPath(File(customInstallPath, safeFolderName).absolutePath) + } + + appDirPath = finalPath + Timber.i("Final custom appDirPath: $appDirPath") + + runBlocking { + if (appInfo != null) { + val updatedApp = appInfo.copy(installDir = finalPath) + instance?.appDao?.update(updatedApp) + Timber.i("Updated SteamApp installDir in DB to: $finalPath") + } + } + } + + val hasTrustedInstallAtStart = + customInstallPath == null && + getTrustedInstalledAppInfo(appId) != null + val isAddingDlcToTrustedInstall = + hasTrustedInstallAtStart && + !includeInstalledDepots && + userSelectedDlcAppIds.isNotEmpty() + + // Ensure the download directory exists + try { + val dir = File(appDirPath) + if (!dir.exists()) { + if (dir.mkdirs()) { + Timber.i("Created download directory: $appDirPath") + } else { + Timber.e("Failed to create download directory (mkdirs returned false): $appDirPath") + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show( + service.applicationContext, + "Failed to create download directory. Check permissions.", + Toast.LENGTH_LONG, + ) + } + } + return null + } + } + + if (!MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)) { + Timber.e("Failed to add DOWNLOAD_IN_PROGRESS_MARKER at $appDirPath") + } + + // Fresh installs reset completion state; when the base is already trusted, keep the marker while adding DLC so a cancelled DLC download doesn't make the base look missing. + if (downloadTaskType == DownloadRecord.TASK_UPDATE) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } else if (!includeInstalledDepots && !hasTrustedInstallAtStart) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + } catch (e: Exception) { + Timber.e(e, "Error preparing download directory or markers: $appDirPath") + } + + // If a custom path is provided, we want to force a new download at that location + if (customInstallPath != null) { + Timber.i("Custom path provided, cancelling any existing job for appId: $appId") + downloadJobs[appId]?.cancel("Restarting download at custom path") + downloadJobs.remove(appId) + } else { + // Only return existing job if it's still active + val existingJob = downloadJobs[appId] + if (existingJob != null && existingJob.isActive()) { + Timber.i("Returning existing active download job for appId: $appId") + return existingJob + } + } + + Timber.d("Checking depots for appId: $appId. downloadableDepots count: ${downloadableDepots.size}") + if (downloadableDepots.isEmpty()) { + Timber.w("Download aborted: downloadableDepots is empty for appId: $appId") + return null + } + + val indirectDlcAppIds = getDownloadableDlcAppsOf(appId).orEmpty().map { it.id } + Timber.d("Indirect DLC app IDs for appId $appId: $indirectDlcAppIds") + + // Depots from Main game + val mainDepots = getMainAppDepots(appId) + val appInfoForDownload = getAppInfoOf(appId) + val groupedBaseDlcDepotIds = + appInfoForDownload + ?.let { getGroupedBaseAppDlcContentDepotIds(it) } + .orEmpty() + // Base-package-entitled depots are base content; never let positional DLC grouping drop them (e.g. DMC5 601151/2) — that zeroes the downloads-tab size. + val baseEntitledDepotIds = + appInfoForDownload?.packageId?.let { getEntitledDepotIds(it) }.orEmpty() + Timber.d("Main app depots count: ${mainDepots.size}") + val baseMainAppDepots = + if (isAddingDlcToTrustedInstall) { + Timber.i( + "Building DLC-only Steam download scope for installed appId=$appId " + + "selectedDlcAppIds=${userSelectedDlcAppIds.sorted()}", + ) + emptyMap() + } else { + mainDepots.filter { (depotId, depot) -> + depot.dlcAppId == INVALID_APP_ID && + (depotId !in groupedBaseDlcDepotIds || depotId in baseEntitledDepotIds) + } + } + val targetDepotIdSet = targetDepotIds?.takeIf { it.isNotEmpty() } + var originalMainAppDepots = + baseMainAppDepots + + mainDepots.filter { (_, depot) -> + userSelectedDlcAppIds.contains(depot.dlcAppId) && + resolveDepotManifestInfo(depot, branch) != null + } + + getSelectedBaseAppDlcContentDepots( + appId = appId, + selectedDlcAppIds = userSelectedDlcAppIds, + preferredLanguage = PrefManager.containerLanguage, + branch = branch, + ) + if (targetDepotIdSet != null) { + originalMainAppDepots = originalMainAppDepots.filterKeys { it in targetDepotIdSet } + } + var mainAppDepots = originalMainAppDepots + Timber.d("Filtered main app depots count: ${mainAppDepots.size}") + + // Depots from indirect DLC apps (reachable via findDownloadableDLCApps, which needs a cached license row). + val indirectDlcAppDepots = + downloadableDepots.filter { (_, depot) -> + !mainAppDepots.map { it.key }.contains(depot.depotId) && + userSelectedDlcAppIds.contains(depot.dlcAppId) && + indirectDlcAppIds.contains(depot.dlcAppId) && + resolveDepotManifestInfo(depot, branch) != null + } + Timber.d("Filtered indirect DLC app depots count: ${indirectDlcAppDepots.size}") + + // Selected DLCs whose depots aren't reachable via indirectDlcAppIds (stale license row, or DLC declared on the base game) — look them up by appId so the download matches getDlcOnlyManifestSizes and the estimate/downloads-tab stay in sync. + val coveredDlcAppIds = + (originalMainAppDepots.values.asSequence() + indirectDlcAppDepots.values.asSequence()) + .mapNotNull { d -> d.dlcAppId.takeIf { it != INVALID_APP_ID } } + .toSet() + val missingDlcAppIds = userSelectedDlcAppIds.filter { it !in coveredDlcAppIds } + val extraDlcAppDepots: Map = + if (missingDlcAppIds.isEmpty()) { + emptyMap() + } else { + val appInfoForArch = getAppInfoOf(appId) + val extraHas64Bit = + appInfoForArch?.depots?.values?.any { + it.osArch == OSArch.Arch64 && + (it.osList.contains(OS.windows) || it.osList.isEmpty() || it.osList.contains(OS.none)) + } ?: false + val extraLanguage = PrefManager.containerLanguage + val coveredDepotIds = originalMainAppDepots.keys + indirectDlcAppDepots.keys + val collected = mutableMapOf() + for (dlcAppId in missingDlcAppIds) { + // Only recover depots for DLC the account owns; otherwise Steam denies the key. + val ownsDlc = + runBlocking(Dispatchers.IO) { + (instance?.licenseDao?.countLicensesForApp(dlcAppId) ?: 0) > 0 + } + if (!ownsDlc) { + Timber.i("Skipping recovery depots for unowned DLC appId=$dlcAppId") + continue + } + val dlcAppInfo = + runBlocking(Dispatchers.IO) { instance?.appDao?.findApp(dlcAppId) } + ?: continue + for ((depotId, depot) in dlcAppInfo.depots) { + if (depotId in coveredDepotIds || depotId in collected) continue + if (!filterForDownloadableDepots(depot, extraHas64Bit, extraLanguage, ownedDlc = null)) continue + if (resolveDepotManifestInfo(depot, branch) == null) continue + collected[depotId] = + DepotInfo( + depotId = depot.depotId, + dlcAppId = dlcAppId, + optionalDlcId = depot.optionalDlcId, + depotFromApp = depot.depotFromApp, + sharedInstall = depot.sharedInstall, + osList = depot.osList, + osArch = depot.osArch, + language = depot.language, + lowViolence = depot.lowViolence, + manifests = depot.manifests, + encryptedManifests = depot.encryptedManifests, + ) + } + } + collected + } + if (extraDlcAppDepots.isNotEmpty()) { + Timber.d("Recovered ${extraDlcAppDepots.size} extra DLC depots for selected DLCs ${missingDlcAppIds}") + } + // Single combined view of DLC depots (indirect + extras) used downstream for grouping, totals, and DownloadingAppInfo persistence — extras must be visible everywhere. + val dlcAppDepots = + (indirectDlcAppDepots + extraDlcAppDepots).let { depots -> + if (targetDepotIdSet == null) depots else depots.filterKeys { it in targetDepotIdSet } + } + + // Drop already-downloaded depots only when install metadata is trusted; a custom path re-checks/downloads everything at the new location. + var installedApp = getInstalledApp(appId) + val hasCompleteMarker = MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + var hasTrustedInstalledState = installedApp?.isDownloaded == true && hasCompleteMarker + if (!includeInstalledDepots && installedApp != null && !hasTrustedInstalledState && customInstallPath == null) { + val hasStaleInstallMetadata = + installedApp.isDownloaded || + installedApp.downloadedDepots.isNotEmpty() || + installedApp.dlcDepots.isNotEmpty() + if (hasStaleInstallMetadata) { + Timber.w( + "Clearing stale install metadata for appId=$appId " + + "(isDownloaded=${installedApp.isDownloaded}, marker=$hasCompleteMarker)", + ) + runBlocking(Dispatchers.IO) { + instance?.appInfoDao?.deleteApp(appId) + } + installedApp = null + } + hasTrustedInstalledState = false + } + if (installedApp != null && !includeInstalledDepots && hasTrustedInstalledState && customInstallPath == null) { + val beforeCount = mainAppDepots.size + mainAppDepots = mainAppDepots.filter { it.key !in installedApp.downloadedDepots } + Timber.d("Removed already downloaded depots. Count before: $beforeCount, after: ${mainAppDepots.size}") + } + + // Resume support: .DepotDownloader/depot.config records depot state — finish_depot is written only after every file lands, so a "finished" entry always means a complete depot and is safe to keep across pause/resume (download() skips finished, write_depot() resumes the in-progress one chunk-by-chunk). Treat as "fresh" (discard depot.config) only for a brand-new install or a verify pass. + val depotConfigFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") + val isFreshDownload = + downloadTaskType == DownloadRecord.TASK_VERIFY || !depotConfigFile.exists() + Timber.i( + "Download fresh=$isFreshDownload for appId=$appId " + + "(task=$downloadTaskType, depotConfigExists=${depotConfigFile.exists()})", + ) + + val allDepots = originalMainAppDepots + dlcAppDepots + // Use install (uncompressed) size for progress; resolveDepotManifestInfo follows depot.depotFromApp and falls back to the public branch so shared/proxied DLC depots contribute their full size, not the 1L fallback. + val depotSizeById = + allDepots.mapValues { (_, depot) -> + val mInfo = resolveDepotManifestInfo(depot, branch) + (mInfo?.size ?: 1L).coerceAtLeast(1L) + } + + // Mutable so the safety check below can drop suspicious entries before they poison di.depotCumulativeUncompressedBytes during resume init. + var persistedDepotBytes: Map = + if (allowPersistedProgress) { + DownloadInfo.loadPersistedDepotBytes(appDirPath) + } else { + emptyMap() + } + + // Scope shrink (DLC de-selected, or Steam republished the depot list): drop orphan snapshot entries instead of refusing the resume; selectedDepots is built from current scope only, so partial-COMPLETE can't happen. + if (allowPersistedProgress && persistedDepotBytes.isNotEmpty()) { + val depotsInScope = allDepots.keys + val orphanSnapshotDepots = persistedDepotBytes.keys - depotsInScope + if (orphanSnapshotDepots.isNotEmpty()) { + Timber.w( + "Resume scope shrunk for appId=$appId: snapshot has depot(s) " + + "$orphanSnapshotDepots that are not in the current download scope " + + "(scope depots: $depotsInScope). Dropping orphan snapshot entries " + + "and continuing with in-scope depots.", + ) + persistedDepotBytes = persistedDepotBytes.filterKeys { it in depotsInScope } + DownloadInfo.persistDepotBytes(appDirPath, persistedDepotBytes) + } + } + + val fullyDownloadedDepotsFromSnapshot = mutableSetOf() + if (persistedDepotBytes.isNotEmpty()) { + for ((depotId, _) in allDepots) { + val depotSize = depotSizeById[depotId] ?: 1L + val downloadedBytes = persistedDepotBytes[depotId] ?: 0L + Timber.d( + "Resume snapshot for appId=$appId depot=$depotId: persisted=$downloadedBytes / size=$depotSize " + + (if (downloadedBytes >= depotSize) "-> SKIP-CANDIDATE" else "-> include"), + ) + if (downloadedBytes >= depotSize) { + fullyDownloadedDepotsFromSnapshot.add(depotId) + } + } + if (fullyDownloadedDepotsFromSnapshot.isNotEmpty()) { + // Trust the snapshot's "fully downloaded" claim only when the COMPLETE marker exists; without it the install was partial and snapshots have historically been corrupted to depotSize prematurely, so let per-file checksum validation handle resume instead. + if (hasCompleteMarker) { + Timber.i( + "Skipping ${fullyDownloadedDepotsFromSnapshot.size} fully downloaded depots from snapshot " + + "(COMPLETE marker present): $fullyDownloadedDepotsFromSnapshot", + ) + mainAppDepots = mainAppDepots.filter { it.key !in fullyDownloadedDepotsFromSnapshot } + } else { + Timber.w( + "REFUSING to skip ${fullyDownloadedDepotsFromSnapshot.size} depots claimed full by snapshot " + + "for appId=$appId because COMPLETE marker is absent. Depots will be re-validated " + + "by the downloader; persisted byte counts are kept so the progress bar stays at the " + + "user's last position while verification confirms files on disk: " + + "$fullyDownloadedDepotsFromSnapshot", + ) + // Keep persistedDepotBytes (the monotonic CAS in onProgress can't lower them, so the restored % shows during validation); just clear the skip set so the depots are re-validated. + fullyDownloadedDepotsFromSnapshot.clear() + } + } + } + + // Combine main app and DLC depots + val filteredDlcAppDepots = dlcAppDepots.filter { it.key !in fullyDownloadedDepotsFromSnapshot } + val selectedDepots = mainAppDepots + filteredDlcAppDepots + Timber.i("Total selected depots for download: ${selectedDepots.size}") + + logDepotScopeDiagnostics(appId, branch, selectedDepots) + + if (selectedDepots.isEmpty()) { + var preSnapshotMainAppDepots = originalMainAppDepots + if (installedApp != null && !includeInstalledDepots && hasTrustedInstalledState) { + preSnapshotMainAppDepots = preSnapshotMainAppDepots.filter { it.key !in installedApp.downloadedDepots } + } + val preSnapshotSelectedDepots = preSnapshotMainAppDepots + dlcAppDepots + + if (preSnapshotSelectedDepots.isEmpty()) { + // Zero depots resolved: either (1) nothing selected and base already installed (genuine no-op complete), or (2) selected DLC(s) with no downloadable content (entitlement/branch-access DLCs, e.g. appid 373300) — case (2) must not silently show "Complete / 0 B". + val selectedContentlessDlc = userSelectedDlcAppIds.isNotEmpty() + Timber.i( + "selectedDepots empty for appId=$appId — " + + if (selectedContentlessDlc) { + "selected DLC(s) $userSelectedDlcAppIds have no downloadable content" + } else { + "app already installed" + }, + ) + + // Instead of returning null, create a completed job so it shows in UI + val info = DownloadInfo(1, appId, CopyOnWriteArrayList(listOf(appId))) + info.updateStatus(DownloadPhase.COMPLETE) + info.setProgress(1f) + downloadJobs[appId] = info + + if (allowPersistedProgress) { + Timber.i("Resume became a no-op; clearing stale persisted resume state") + clearFailedResumeState(appId) + } + + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + + if (selectedContentlessDlc) { + // Record content-less DLC(s) as installed so the picker shows "Installed" — owned but with nothing to download. + runCatching { + runBlocking(Dispatchers.IO) { + val mainAppInfo = instance?.appInfoDao?.getInstalledApp(appId) + if (mainAppInfo != null) { + val updatedDlc = + (mainAppInfo.dlcDepots + userSelectedDlcAppIds) + .distinct() + .sorted() + instance?.appInfoDao?.update( + mainAppInfo.copy(dlcDepots = updatedDlc), + ) + Timber.i( + "Marked content-less DLC(s) installed for appId=$appId: dlcDepots=$updatedDlc", + ) + } + } + }.onFailure { e -> + Timber.w(e, "Failed to record content-less DLC(s) for appId=$appId") + } + } + + // Honest message — don't claim a download happened when the selected DLC had no content to fetch. + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + if (selectedContentlessDlc) { + WinToast.show( + service.applicationContext, + "Selected DLC requires no download — marked installed", + Toast.LENGTH_LONG, + ) + } else { + WinToast.show( + service.applicationContext, + "Download complete", + Toast.LENGTH_SHORT, + ) + } + } + } + + return info + } + + // Snapshot says all depots complete but marker missing — finalize metadata/markers directly instead of re-queuing depots. + val canFinalizeFromSnapshot = + allowPersistedProgress && + fullyDownloadedDepotsFromSnapshot.isNotEmpty() && + (hasCompleteMarker || hasPersistedResumeRow) + if (canFinalizeFromSnapshot) { + Timber.i("All resume depots appear complete from snapshot; finalizing without downloader") + val info = + finalizeSnapshotResumeAsComplete( + appId = appId, + appDirPath = appDirPath, + mainAppDepots = preSnapshotMainAppDepots, + dlcAppDepots = dlcAppDepots, + userSelectedDlcAppIds = userSelectedDlcAppIds, + ) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + return info + } else { + if (allowPersistedProgress) { + if (fullyDownloadedDepotsFromSnapshot.isNotEmpty()) { + Timber.w( + "Snapshot indicates completion for appId=$appId but state is untrusted " + + "(marker=$hasCompleteMarker, resumeRow=$hasPersistedResumeRow); clearing resume metadata", + ) + } else { + Timber.i("selectedDepots resolved empty on resume; clearing stale resume metadata") + } + clearFailedResumeState(appId) + } else { + Timber.i("selectedDepots resolved empty after filtering; skipping download start") + } + } + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + return null + } + + val downloadingAppIds = CopyOnWriteArrayList() + val calculatedDlcAppIds = CopyOnWriteArrayList() + val allDepotIdsByDlcAppId = + dlcAppDepots.values + .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) + .mapValues { (_, depotIds) -> depotIds.sorted() } + val selectedDlcDepotIdsByDlcAppId = + filteredDlcAppDepots.values + .groupBy(keySelector = { it.dlcAppId }, valueTransform = { it.depotId }) + .mapValues { (_, depotIds) -> depotIds.sorted() } + + userSelectedDlcAppIds.forEach { dlcAppId -> + if (allDepotIdsByDlcAppId[dlcAppId]?.isNotEmpty() == true) { + downloadingAppIds.add(dlcAppId) + calculatedDlcAppIds.add(dlcAppId) + } + } + + if (mainAppDepots.isNotEmpty()) { + downloadingAppIds.add(appId) + } + + // Some apps put DLC content under the base app with no dlcAppId on every depot; persist only DLC metadata in the selected scope, else marker-only DLCs get falsely saved as installed when a sibling DLC is selected. + val selectedDlcAppIdSet = userSelectedDlcAppIds.toSet() + val mainAppDlcIds = + getMainAppDlcIdsWithoutProperDepotDlcIds(appId) + .filterTo(mutableListOf()) { it in selectedDlcAppIdSet } + mainAppDlcIds.addAll( + mainAppDepots.values + .map { it.dlcAppId } + .filter { it != INVALID_APP_ID && it in selectedDlcAppIdSet } + .distinct(), + ) + + // If there are no DLC depots, download the main app only + if (dlcAppDepots.isEmpty()) { + // Because all dlcIDs are coming from main depots, need to add the dlcID to main app in order to save it to db after finish download + mainAppDlcIds.addAll( + mainAppDepots + .filter { it.value.dlcAppId != INVALID_APP_ID && it.value.dlcAppId in selectedDlcAppIdSet } + .map { it.value.dlcAppId } + .distinct(), + ) + // Entitlement/config DLCs have no downloadable depot but must still be remembered as selected/installed so launch metadata can expose them later. + mainAppDlcIds.addAll(userSelectedDlcAppIds) + + // Refresh id List, so only main app is downloaded + calculatedDlcAppIds.clear() + downloadingAppIds.clear() + downloadingAppIds.add(appId) + } + + Timber.i("Starting download for $appId") + Timber.i("App contains ${mainAppDepots.size} depot(s): ${mainAppDepots.keys}") + Timber.i("DLC contains ${dlcAppDepots.size} depot(s): ${dlcAppDepots.keys}") + Timber.i("downloadingAppIds: $downloadingAppIds") + + val service = + instance ?: run { + Timber.e("SteamService instance is null, cannot start download job.") + return null + } + + val selectedDepotSizes = + selectedDepots.mapValues { (depotId, _) -> + depotSizeById[depotId] ?: 1L + } + val selectedTotalBytes = selectedDepotSizes.values.sum() + val totalBytes = selectedTotalBytes.coerceAtLeast(1L) + val selectedDisplayDownloadBytes = + selectedDepots.values + .sumOf { depot -> manifestDownloadBytes(resolveDepotManifestInfo(depot, branch)) } + .takeIf { it > 0L } + ?: totalBytes + Timber.i( + "Steam DLC selected download scope: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + + "calculatedDlcAppIds=${calculatedDlcAppIds.sorted()} mainDepotIds=${mainAppDepots.keys.sorted()} " + + "dlcDepotIdsByApp=$selectedDlcDepotIdsByDlcAppId totalBytes=$totalBytes " + + "displayDownloadBytes=$selectedDisplayDownloadBytes metadataDlcAppIds=${mainAppDlcIds.sorted()}", + ) + + runBlocking { + service.downloadingAppInfoDao.insert( + DownloadingAppInfo( + appId, + dlcAppIds = userSelectedDlcAppIds, + ), + ) + Unit + } + Timber.i( + "Steam DLC selection persisted: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + + "installPath=$appDirPath", + ) + + // Ask the global coordinator whether this can start now or must queue behind other stores' downloads; it persists the decision in DownloadRecord so the request survives an app restart. + val coordDecision = + runBlocking { + val title = getAppInfoOf(appId)?.name.orEmpty() + val persistedScope = + if (downloadTaskType == DownloadRecord.TASK_UPDATE && targetDepotIdSet != null) { + targetDepotIdSet.sorted().joinToString(",") + } else { + userSelectedDlcAppIds.joinToString(",") + } + DownloadCoordinator.requestSlot( + store = DownloadRecord.STORE_STEAM, + storeGameId = appId.toString(), + title = title, + installPath = appDirPath, + selectedDlcs = persistedScope, + taskType = downloadTaskType, + bytesTotal = selectedDisplayDownloadBytes, + ) + } + Timber.i( + "Steam DLC coordinator record: appId=$appId selectedDlcAppIds=${userSelectedDlcAppIds.sorted()} " + + "bytesTotal=$totalBytes displayDownloadBytes=$selectedDisplayDownloadBytes " + + "decision=${coordDecision::class.simpleName}", + ) + if (coordDecision is DownloadCoordinator.Decision.Queue) { + Timber.i("Coordinator queued appId: $appId") + if (downloadTaskType == DownloadRecord.TASK_UPDATE) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + val info = + DownloadInfo(selectedDepots.size, appId, downloadingAppIds).also { di -> + di.setPersistencePath(appDirPath) + di.setTotalExpectedBytes(totalBytes) + di.setDisplayTotalExpectedBytes(selectedDisplayDownloadBytes) + di.updateStatus(DownloadPhase.QUEUED, "Queued") + di.setActive(false) + } + downloadJobs[appId] = info + notifyDownloadStarted(appId) + return info + } + + val info = + DownloadInfo(selectedDepots.size, appId, downloadingAppIds).also { di -> + di.setPersistencePath(appDirPath) + + // Set weights for each depot based on manifest sizes + selectedDepots.keys.forEachIndexed { index, depotId -> + di.setWeight(index, selectedDepotSizes[depotId] ?: 1L) + } + + // Track progress only for depots in this run so excluded/complete depots can't pre-fill progress at startup. + + // Total expected size (used for ETA based on recent download speed) + di.setTotalExpectedBytes(totalBytes) + di.setDisplayTotalExpectedBytes(selectedDisplayDownloadBytes) + + var resumedBytes = 0L + + if (allowPersistedProgress) { + for ((depotId, bytes) in persistedDepotBytes) { + // Depot excluded as fully downloaded still needs its bytes tracked so future snapshots retain this progress. + val depotSize = depotSizeById[depotId] ?: continue + val safeBytes = bytes.coerceIn(0L, depotSize) + di.depotCumulativeUncompressedBytes[depotId] = + java.util.concurrent.atomic + .AtomicLong(safeBytes) + // Count resumed bytes only for depots actively downloading in this run. + if (depotId in selectedDepots) { + resumedBytes += safeBytes + } + Timber.i( + "RESUME-INIT depot=$depotId loaded=$safeBytes (snapshot=$bytes, max=$depotSize, " + + "inSelected=${depotId in selectedDepots}, inSession=${depotId in selectedDepotSizes.keys})", + ) + } + } else { + // SYNC clear so a stale snapshot from a prior session can't poison this fresh download — async clear races new persists that could read/overwrite with stale byte counts. + di.clearPersistedBytesDownloaded(appDirPath, sync = true) + Timber.i("RESUME-INIT cleared persisted snapshot (sync) for fresh download appId=$appId") + } + resumedBytes = resumedBytes.coerceIn(0L, totalBytes) + + if (resumedBytes > 0L) { + di.initializeBytesDownloaded(resumedBytes) + Timber.i("Resumed download: initialized with $resumedBytes bytes") + } + + val downloadJob = + service.scope.launch { + // Worker-local session brought up when no logged-on wnSession exists; NOT promoted to the global field so a concurrent logOut()/relogin can't close it mid-download. Disconnected + closed in this worker's finally. + var workerWnSession: WnSteamSession? = null + // Wi-Fi + CPU keep-alive: without it Wi-Fi PSP drops radio power on screen-off and router NAT evicts chunk sockets, surfacing as spurious "WN download failed" on stable Wi-Fi. + val keepAliveTag = "steam-download-$appId" + val keepAliveCtx = service.applicationContext + runCatching { + SessionKeepAliveService.startDownload(keepAliveCtx, keepAliveTag) + }.onFailure { e -> + Timber.w(e, "Failed to acquire keep-alive for Steam download $appId") + } + try { + // Retry loop for transient Steam API failures (AsyncJobFailedException) or missing client + val maxRetries = 3 + + for (attempt in 1..maxRetries) { + try { + if (attempt > 1) { + Timber.i("Retry attempt $attempt/$maxRetries for appId: $appId") + di.updateStatusMessage("Retrying download (attempt $attempt/$maxRetries)") + withContext(Dispatchers.Main) { + WinToast.show( + instance?.applicationContext ?: return@withContext, + "Retrying download (attempt $attempt/$maxRetries)", + Toast.LENGTH_SHORT, + ) + } + kotlinx.coroutines.delay(3000L * attempt) // Exponential backoff + } + + // Ensure a logged-on session (state()==3) for the download: prefer the long-lived wnSession, but it may not be logged on (cached-token restore, idled CM), so bring one up here if needed. The downloader requests depot keys itself. + var wnReady = wnSession?.takeIf { it.state() == 3 } + if (wnReady == null) { + // Brief grace period: wnSession may be mid-logon. + var grace = 0 + while (grace < 8 && wnSession?.state() != 3) { + di.updateStatusMessage("Waiting for Steam connection") + delay(1000L) + grace++ + } + wnReady = wnSession?.takeIf { it.state() == 3 } + } + if (wnReady == null) { + // Reuse a session this worker brought up on a prior retry, if still logged on. + wnReady = workerWnSession?.takeIf { it.state() == 3 } + } + if (wnReady == null) { + Timber.i("downloadApp: no logged-on wnSession — bringing one up for the download") + di.updateStatusMessage("Connecting to Steam") + val svc = instance + ?: throw Exception("Steam service unavailable.") + val refreshTok = PrefManager.refreshToken + if (refreshTok.isBlank()) { + throw Exception( + "Not logged in to Steam (no refresh token). " + + "Please sign in and try again.", + ) + } + // Discard a worker session from a prior attempt that is no longer logged on. + workerWnSession?.let { stale -> + runCatching { stale.disconnect() } + runCatching { stale.close() } + } + workerWnSession = null + val brought = bringUpWnSession(svc) + ?: throw Exception( + "WN-Steam-Client: could not connect to Steam.", + ) + workerWnSession = brought + // Download-only session: skip the library-populate PICS crawl so it doesn't flood the CM while the download needs the channel for depot keys. + brought.setAutoPopulateLibrary(false) + di.updateStatusMessage("Logging in to Steam") + if (!brought.logonWithRefreshToken( + refreshTok, + PrefManager.username, + PrefManager.steamUserSteamId64, + ) + ) { + throw Exception("WN-Steam-Client: logon request failed.") + } + var logonWait = 0 + while (brought.state() != 3 && logonWait < 30) { + delay(1000L) + logonWait++ + } + if (brought.state() != 3) { + throw Exception( + "WN-Steam-Client: could not log on to Steam. " + + "Please check your connection.", + ) + } + wnReady = brought + Timber.i("downloadApp: WN-Steam session logged on for download") + } + + // Capture wnReady (a mutable var) once as a stable non-null handle for the download below. + val wnSessionForDownload = wnReady + ?: throw Exception("WN-Steam-Client session unavailable.") + + Timber.i("Initializing WN-Steam downloader for appId: $appId (attempt $attempt)") + di.updateStatusMessage("Initializing downloader") + + // CA bundle for HTTPS CDN verification (same one CaBundleExtractor provides for the CM session). + val caPath = CaBundleExtractor.ensureBundle( + instance?.applicationContext + ?: throw Exception("Steam service unavailable"), + ) + + // Total expected bytes (drives di.getProgress()): use ManifestInfo.size (DECOMPRESSED) since onProgress reports decompressed bytes — the compressed .download size would overshoot 1.0. + val grandTotalBytes = selectedDepots.values.sumOf { depot -> + resolveDepotManifestInfo(depot, branch)?.size ?: 0L + } + if (grandTotalBytes > 0L) di.setTotalExpectedBytes(grandTotalBytes) + + // Native downloadApp() takes one appId (depot-key entitlement), so split into one batch per app — main app then each owned DLC. Triple = (appId, depotIds, manifestIds). + val wnBatches: List> = buildList { + if (mainAppDepots.isNotEmpty()) { + // Drop unresolvable depots (gid 0); sending manifest 0 aborts the native batch. + val resolved = mainAppDepots.keys.sorted().mapNotNull { id -> + val gid = resolveDepotManifestInfo(mainAppDepots[id]!!, branch)?.gid ?: 0L + if (gid > 0L) { + id to gid + } else { + Timber.w("Skipping main depot $id: unresolved manifest gid (branch=$branch)") + null + } + } + if (resolved.isNotEmpty()) { + add(Triple( + appId, + resolved.map { it.first }.toIntArray(), + resolved.map { it.second }.toLongArray(), + )) + } + } + calculatedDlcAppIds.forEach { dlcAppId -> + val dlcDepotIds = selectedDlcDepotIdsByDlcAppId[dlcAppId].orEmpty() + if (dlcDepotIds.isEmpty()) return@forEach + val resolved = dlcDepotIds.mapNotNull { depotId -> + val gid = selectedDepots[depotId]?.let { resolveDepotManifestInfo(it, branch)?.gid } ?: 0L + if (gid > 0L) { + depotId to gid + } else { + Timber.w("Skipping DLC depot $depotId (dlcAppId=$dlcAppId): unresolved manifest gid (branch=$branch)") + null + } + } + if (resolved.isEmpty()) return@forEach + Timber.i("Steam DLC batch queued: dlcAppId=$dlcAppId depotIds=${resolved.map { it.first }}") + add(Triple( + dlcAppId, + resolved.map { it.first }.toIntArray(), + resolved.map { it.second }.toLongArray(), + )) + } + } + if (wnBatches.isEmpty()) { + throw Exception("No depots resolved for download.") + } + Timber.i("WN download: ${wnBatches.size} app batch(es) to $appDirPath") + + // Steam Controller Config download + val appConfig = getAppInfoOf(appId)?.config + if (appConfig?.steamControllerTemplateIndex == 1) { + val controllerConfig = + appConfig.steamControllerConfigDetails + .let { selectSteamControllerConfig(it) } + + if (controllerConfig != null) { + val publishedFileId = controllerConfig.publishedFileId + runCatching { + val requestBody = + FormBody + .Builder() + .add( + "itemcount", + "1", + ).add("publishedfileids[0]", publishedFileId.toString()) + .build() + val request = + Request + .Builder() + .url( + "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1", + ).post(requestBody) + .build() + Net.http.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (!responseBody.isNullOrEmpty()) { + val responseJson = JSONObject(responseBody) + val responseData = responseJson.optJSONObject("response") + val fileUrl = + responseData + ?.optJSONArray( + "publishedfiledetails", + )?.optJSONObject(0) + ?.optString("file_url", "") + ?.trim() + if (!fileUrl.isNullOrEmpty()) { + val configFile = File(appDirPath, STEAM_CONTROLLER_CONFIG_FILENAME) + val downloadRequest = + Request + .Builder() + .url(fileUrl) + .get() + .build() + Net.http.newCall(downloadRequest).execute().use { downloadResponse -> + if (downloadResponse.isSuccessful) { + downloadResponse.body?.byteStream()?.use { input -> + configFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } + } + } + } + } + } + } + } + + // Run each app batch through the native downloader (downloadApp() runs on a native worker thread); suspendCancellableCoroutine bridges WnDownloadListener.onComplete back here. Progress sets di's absolute byte count from the sum of every depot's cumulative bytes. + Timber.i("Downloading game to $appDirPath (attempt $attempt)") + val wnDepotBytes = java.util.concurrent.ConcurrentHashMap() + for ((depotId, bytes) in di.depotCumulativeUncompressedBytes) { + if (depotId in selectedDepots) { + val initialBytes = bytes.get().coerceAtLeast(0L) + if (initialBytes > 0L) { + wnDepotBytes[depotId] = initialBytes + } + } + } + val wnGlobalPrev = + java.util.concurrent.atomic.AtomicLong(wnDepotBytes.values.sum()) + // Throttle DownloadRecord progress persistence. + val wnLastPersistMs = java.util.concurrent.atomic.AtomicLong(0L) + for (batch in wnBatches) { + val (batchAppId, batchDepotIds, batchManifestIds) = batch + kotlinx.coroutines.suspendCancellableCoroutine { cont -> + wnSessionForDownload.downloadApp( + batchAppId, + batchDepotIds, + batchManifestIds, + branch, + appDirPath, + isFreshDownload, + caPath, + // "Download Speed" setting → parallel chunk-download worker count. + PrefManager.downloadSpeed, + object : WnDownloadListener { + override fun onProgress( + depotId: Int, + depotDone: Long, + depotTotal: Long, + depotsDone: Int, + depotsTotal: Int, + verifying: Boolean, + ) { + // The native worker may fire late callbacks after a pause/cancel (before it unwinds); ignore them or they'd overwrite PAUSED back to DOWNLOADING. + if (!di.isActive()) return + // Record per-depot cumulative bytes so the throttled snapshot (depot_bytes.json) restores the real % on resume; verification reports bytes from 0 each resume, so never let it lower a previously persisted count (quick pause/resume during VERIFYING would rewrite the snapshot to a partial scan). + val depotBytes = + di.depotCumulativeUncompressedBytes + .getOrPut(depotId) { + java.util.concurrent.atomic.AtomicLong(0L) + } + val observedDepotDone = depotDone.coerceAtLeast(0L) + var monotonicDepotDone: Long + while (true) { + val currentDepotDone = depotBytes.get() + monotonicDepotDone = maxOf(currentDepotDone, observedDepotDone) + if (monotonicDepotDone == currentDepotDone || + depotBytes.compareAndSet(currentDepotDone, monotonicDepotDone) + ) { + break + } + } + wnDepotBytes[depotId] = monotonicDepotDone + di.markProgressSnapshotDirty() + val g = wnDepotBytes.values.sum() + val delta = g - wnGlobalPrev.getAndSet(g) + if (delta > 0L) di.updateBytesDownloaded(delta) + val statusTick = + if (verifying && observedDepotDone < monotonicDepotDone) { + "$g/$observedDepotDone" + } else { + g.toString() + } + // Phase from the native verifying flag: VERIFYING while validating on-disk content, DOWNLOADING while fetching. The status carries a unique suffix (g) each tick because a StateFlow dedups equal values, so a constant message would freeze the live byte count/speed — a changing one forces recomposition. + di.updateStatus( + if (verifying) { + DownloadPhase.VERIFYING + } else { + DownloadPhase.DOWNLOADING + }, + if (verifying) { + "Verifying depot $depotId ($statusTick)" + } else { + "Downloading depot $depotId ($statusTick)" + }, + ) + di.emitProgressChange() + // Persist progress to the DownloadRecord (throttled 3s) so an app restart restores the real % instead of 0. + val nowMs = System.currentTimeMillis() + if (nowMs - wnLastPersistMs.get() >= 3000L) { + wnLastPersistMs.set(nowMs) + val (dispDone, dispTotal) = + di.getDisplayBytesProgress() + DownloadCoordinator.updateProgress( + DownloadRecord.STORE_STEAM, + appId.toString(), + dispDone, + dispTotal, + ) + } + } + + override fun onComplete( + success: Boolean, + error: String, + bytesWritten: Long, + depotsCompleted: Int, + depotsSkipped: Int, + ) { + if (!cont.isActive) return + if (success) { + cont.resumeWith(Result.success(Unit)) + } else if (!di.isActive() || di.isCancelling) { + // Pause/cancel aborted the native download — resume normally; the post-await barrier classifies it as PAUSED/CANCELLED, not a spurious FAILED. + cont.resumeWith(Result.success(Unit)) + } else { + cont.resumeWith( + Result.failure( + WnDownloadTransientException( + "WN download failed (app $batchAppId): $error", + ), + ), + ) + } + } + }, + ) + // Pause/cancel cancels this coroutine — abort the native worker so it stops promptly instead of running on in the background. + cont.invokeOnCancellation { + runCatching { wnSessionForDownload.cancelDownload() } + } + } + } + + // Hard barrier: re-check cancellation even when the await returned cleanly — completion can fire as a side-effect of pending chunks being cancelled, and in that race we must NOT run completeAppDownload (it would set COMPLETE for a paused/partial install). + coroutineContext.ensureActive() + if (!di.isActive() || di.isCancelling) { + Timber.i( + "DepotDownloader completion returned but DownloadInfo is no longer active " + + "(isActive=${di.isActive()}, isCancelling=${di.isCancelling}). " + + "Skipping completeAppDownload — the user paused or cancelled.", + ) + throw CancellationException( + if (di.isCancelling) "Cancelled by user" else "Paused by user", + ) + } + + Timber.i("DepotDownloader finished for appId: $appId") + + // If it was extremely fast (e.g. already downloaded), ensure some visibility in UI + if (di.getProgress() >= 1.0f) { + delay(1000) + } + + // If we got here without exception, download succeeded + break + } catch (e: AsyncJobFailedException) { + Timber.w(e, "AsyncJobFailedException on attempt $attempt/$maxRetries for appId: $appId") + if (attempt >= maxRetries) { + Timber.e("All $maxRetries retry attempts failed for appId: $appId") + throw e + } + di.setActive(true) + continue + } catch (e: Exception) { + if (e is CancellationException) throw e + if (!di.isActive() || di.isCancelling) throw e + // Only retry the depot-transfer phase; entitlement/manifest/session errors won't fix themselves — fail fast. + if (e !is WnDownloadTransientException) throw e + Timber.w(e, "Transient WN download failure on attempt $attempt/$maxRetries for appId: $appId") + if (attempt >= maxRetries) { + Timber.e("All $maxRetries retry attempts failed for appId: $appId") + throw e + } + // Force-flush the byte snapshot so the next attempt resumes from the same offset instead of re-validating. + runCatching { di.persistProgressSnapshot(force = true) } + runCatching { updateCoordinatorDownloadProgress(di) } + // Failed batch's listener sets isActive=false; restore it so the next attempt's onProgress doesn't bail. + di.setActive(true) + continue + } + } + + // Complete app download - Wrap in try-catch to ensure we don't crash at the finish line + try { + di.updateStatusMessage("Finalizing installation") + Timber.i("Finalizing installation at path: $appDirPath") + + // Refuse to mark COMPLETE unless every depot fetched this run is recorded as finished at the expected manifest id in depot.config. + val deniedDepots = readDeniedDepots(appDirPath) + if (deniedDepots.isNotEmpty()) { + Timber.w( + "Completeness gate excluding ${deniedDepots.size} depot(s) Steam denied " + + "a key for appId=$appId: ${deniedDepots.sorted()}", + ) + } + val expectedManifestByDepot = + selectedDepots.mapNotNull { (depotId, depot) -> + // Steam-denied depots aren't part of this account's install. + if (depotId in deniedDepots) return@mapNotNull null + val gid = resolveDepotManifestInfo(depot, branch)?.gid ?: 0L + if (gid > 0L) depotId to gid else null + }.toMap() + val completenessFailures = + verifyDepotConfigComplete(appDirPath, expectedManifestByDepot) + if (completenessFailures.isNotEmpty()) { + Timber.e( + "COMPLETENESS GATE FAILED for appId=$appId task=$downloadTaskType at $appDirPath: " + + "${completenessFailures.size}/${expectedManifestByDepot.size} depot(s) not fully " + + "installed — refusing to mark COMPLETE. Details: ${completenessFailures.take(30)}", + ) + // Keep resume state so a resume re-fetches the missing depots. + runCatching { di.persistProgressSnapshot(force = true) } + di.updateStatus( + DownloadPhase.FAILED, + "Install incomplete: ${completenessFailures.size} depot(s) missing — resume to finish", + ) + di.setActive(false) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + if (downloadTaskType == DownloadRecord.TASK_UPDATE) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + appId.toString(), + DownloadRecord.STATUS_FAILED, + "incomplete: ${completenessFailures.size} depot(s) missing", + ) + } + removeDownloadJob(appId) + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show( + service.applicationContext, + "Download incomplete — some files are missing. Resume to finish.", + Toast.LENGTH_LONG, + ) + } + } + PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) + return@launch + } + + if (originalMainAppDepots.isNotEmpty()) { + val mainAppDepotIds = originalMainAppDepots.keys.sorted() + completeAppDownload(di, appId, mainAppDepotIds, mainAppDlcIds, appDirPath) + } + + calculatedDlcAppIds.forEach { dlcAppId -> + val dlcDepotIds = selectedDlcDepotIdsByDlcAppId[dlcAppId].orEmpty() + completeAppDownload(di, dlcAppId, dlcDepotIds, emptyList(), appDirPath) + } + Timber.i("Installation finalized for appId: $appId") + + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show(service.applicationContext, "Download complete", Toast.LENGTH_SHORT) + Unit + } + } + } catch (e: Exception) { + Timber.e(e, "Error during finalize/database update for appId: $appId") + throw e + } + + removeDownloadJob(appId) + + runBlocking { + instance?.downloadingAppInfoDao?.deleteApp(appId) + Unit + } + Unit + } catch (e: DownloadFailedException) { + Timber.d(e, "Download failed for app $appId via cancellation") + clearFailedResumeState(appId) + di.updateStatus(DownloadPhase.FAILED) + di.setActive(false) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + if (downloadTaskType == DownloadRecord.TASK_UPDATE) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + appId.toString(), + DownloadRecord.STATUS_FAILED, + e.message, + ) + } + removeDownloadJob(appId) + return@launch + } catch (e: CancellationException) { + if (di.isDeleting) { + Timber.d("Download cancelled for deletion for app $appId") + return@launch + } + + if (di.isCancelling) { + Timber.d("Download cancelled by user for app $appId") + di.persistProgressSnapshot(force = true) + di.updateStatus(DownloadPhase.CANCELLED) + di.setActive(false) + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + appId.toString(), + DownloadRecord.STATUS_CANCELLED, + ) + } + throw e + } + + Timber.d(e, "Download paused for app $appId") + // Keep downloadingAppInfo on cancellation so resume does not fall into verify mode. + di.persistProgressSnapshot(force = true) + di.updateStatus(DownloadPhase.PAUSED) + di.setActive(false) + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + appId.toString(), + DownloadRecord.STATUS_PAUSED, + ) + } + throw e + } catch (e: Exception) { + Timber.e(e, "Download failed for app $appId") + // Transient failures keep resume state so Retry continues from the same offset. + val isTransientFailure = e is WnDownloadTransientException + if (isTransientFailure) { + runCatching { di.persistProgressSnapshot(force = true) } + } else { + clearFailedResumeState(appId) + } + + val errorMsg = + when (e) { + is ClassCastException -> "Casting error: ${e.message}" + is NullPointerException -> "Null reference: ${e.message}" + else -> e.localizedMessage ?: e.message ?: e.javaClass.simpleName + } + + di.updateStatus(DownloadPhase.FAILED, errorMsg) + di.setActive(false) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + if (downloadTaskType == DownloadRecord.TASK_UPDATE) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + if (!isTransientFailure) { + runBlocking { + instance?.downloadingAppInfoDao?.deleteApp(appId) + Unit + } + } + runBlocking { + DownloadCoordinator.notifyFinished( + DownloadRecord.STORE_STEAM, + appId.toString(), + DownloadRecord.STATUS_FAILED, + errorMsg, + ) + } + removeDownloadJob(appId) + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show(service.applicationContext, "Download failed: $errorMsg", Toast.LENGTH_LONG) + Unit + } + } + PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) + Unit + } finally { + // Tear down a session this worker brought up itself. + workerWnSession?.let { ws -> + runCatching { ws.disconnect() } + runCatching { ws.close() } + Timber.i("downloadApp: closed worker WN-Steam session for app $appId") + } + workerWnSession = null + runCatching { + SessionKeepAliveService.stopDownload(keepAliveCtx, keepAliveTag) + }.onFailure { e -> + Timber.w(e, "Failed to release keep-alive for Steam download $appId") + } + Unit + } + Unit + } + downloadJob.invokeOnCompletion { throwable -> + if (throwable is CancellationException && throwable !is DownloadFailedException) { + if (di.isDeleting) { + // Deletion handled externally + } else if (di.isCancelling) { + // Keep in downloadJobs for UI visibility, but still check queue + checkQueue() + } else { + Timber.d(throwable, "Download paused for app $appId") + removeDownloadJob(appId) + } + } + } + di.setDownloadJob(downloadJob) + } + + downloadJobs[appId] = info + info.updateStatus(DownloadPhase.PREPARING) + notifyDownloadStarted(appId) + return info +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceDownloadQueue.kt b/app/src/main/feature/stores/steam/service/SteamServiceDownloadQueue.kt new file mode 100644 index 000000000..ea9098841 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceDownloadQueue.kt @@ -0,0 +1,473 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Download-queue control, resume/cleanup helpers + sync flags, split out of SteamService.kt (behavior-identical). + +// Overlay the real unlock state onto schema-derived achievement definitions. +internal suspend fun SteamService.Companion.mergeAchievementUnlockState( + appId: Int, + achievements: List, + nameToBlockBit: Map>, +): List { + if (achievements.isEmpty() || nameToBlockBit.isEmpty()) return achievements + val statsJson = withWnSession { s -> s.getUserStatsFull(appId) } ?: return achievements + val blockUnlock = HashMap>() + runCatching { + val obj = JSONObject(statsJson) + if (obj.optInt("eresult", 2) != EResult.OK.code()) return achievements + val blocks = obj.optJSONArray("achievementBlocks") ?: return achievements + for (i in 0 until blocks.length()) { + val b = blocks.getJSONObject(i) + val times = b.optJSONArray("unlockTimes") + val list = ArrayList(times?.length() ?: 0) + for (j in 0 until (times?.length() ?: 0)) list.add(times!!.getLong(j)) + blockUnlock[b.optInt("achievementId")] = list + } + } + if (blockUnlock.isEmpty()) return achievements + val unlockedTotal = blockUnlock.values.sumOf { times -> times.count { it != 0L } } + Timber.i("Achievements: app=$appId merged unlock state ($unlockedTotal unlocked across ${blockUnlock.size} blocks)") + return achievements.map { ach -> + val mapped = nameToBlockBit[ach.name] ?: return@map ach + val t = blockUnlock[mapped.first]?.getOrNull(mapped.second) ?: 0L + if (t != 0L) ach.copy(unlocked = true, unlockTimestamp = t.toInt()) else ach.copy(unlocked = false) + } +} + +internal fun SteamService.Companion.downloadUrlsFor(fileName: String): List { + val alternate = + when (fileName) { + "steam-token.tzst" -> "steam-token-r2.tzst" + else -> null + } + return if (alternate != null) { + listOf( + "$COMPONENTS_BASE_URL/$fileName", + "$COMPONENTS_BASE_URL/$alternate", + ) + } else { + listOf("$COMPONENTS_BASE_URL/$fileName") + } +} + +internal fun SteamService.Companion.notifyDownloadStarted(appId: Int) { + PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, true)) +} + +internal fun SteamService.Companion.notifyDownloadStopped(appId: Int) { + PluviaApp.events.emit(AndroidEvent.DownloadStatusChanged(appId, false)) +} + +internal fun SteamService.Companion.removeDownloadJob( + appId: Int, + forceRemove: Boolean = false, +) { + if (forceRemove) { + val removed = downloadJobs.remove(appId) + if (removed != null) { + notifyDownloadStopped(appId) + } + } else { + notifyDownloadStopped(appId) + } + checkQueue() + Unit +} + +internal fun SteamService.Companion.clearCompletedDownloadsInternal(dispatchQueueAfterClear: Boolean) { + val toRemove = + downloadJobs + .filterValues { + val status = it.getStatusFlow().value + status == DownloadPhase.COMPLETE || + status == DownloadPhase.CANCELLED || + status == DownloadPhase.FAILED + }.keys + toRemove.forEach { appId -> + val removed = downloadJobs.remove(appId) + if (removed != null) { + notifyDownloadStopped(appId) + } + } + if (dispatchQueueAfterClear && toRemove.isNotEmpty()) { + checkQueue() + } +} + +/** Returns true if there is an incomplete download on disk (in-progress marker or actively downloading). */ +internal fun SteamService.Companion.hasPartialDownloadFiles(appDirPath: String): Boolean { + val appDir = File(appDirPath) + if (!appDir.exists()) return false + + val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) + if (persistenceFile.exists() && persistenceFile.length() > 0L) { + return true + } + + // Complete marker present and no persisted resume file → fully installed, not a resumable partial. + if (MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER)) { + return false + } + + if (MarkerUtils.hasMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)) { + return true + } + + val rootFiles = appDir.listFiles() ?: return false + return rootFiles.any { file -> + if (file.name != DOWNLOAD_INFO_DIR) { + true + } else { + val nestedFiles = file.listFiles().orEmpty() + nestedFiles.any { nested -> + nested.name != DOWNLOAD_INFO_FILE && nested.name != LEGACY_DOWNLOAD_INFO_FILE + } + } + } +} + +internal fun SteamService.Companion.inferResumeDlcAppIds( + appId: Int, + appDirPath: String, +): List { + // Try to recover selected DLCs from persisted depot progress when metadata row is missing. + return runCatching { + val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) + if (!persistenceFile.exists() || !persistenceFile.canRead()) return@runCatching emptyList() + + val text = persistenceFile.readText().trim() + if (text.isEmpty()) return@runCatching emptyList() + + val persistedDepotIds = mutableSetOf() + val json = JSONObject(text) + for (key in json.keys()) { + val depotId = key.toIntOrNull() ?: continue + persistedDepotIds.add(depotId) + } + if (persistedDepotIds.isEmpty()) return@runCatching emptyList() + + val context = instance!!.applicationContext + val container = + if (ContainerUtils.hasContainer(context, "STEAM_$appId")) { + ContainerUtils.getContainer(context, "STEAM_$appId") + } else { + null + } + val containerLanguage = container?.language ?: PrefManager.containerLanguage + val depots = getDownloadableDepots(appId = appId, preferredLanguage = containerLanguage) + depots + .asSequence() + .filter { (depotId, _) -> depotId in persistedDepotIds } + .map { (_, depot) -> depot.dlcAppId } + .filter { it != INVALID_APP_ID } + .distinct() + .toList() + }.getOrElse { + emptyList() + } +} + +internal fun SteamService.Companion.hasPersistedDepotResumeMetadata(appDirPath: String): Boolean { + return runCatching { + val persistenceFile = File(File(appDirPath, DOWNLOAD_INFO_DIR), DOWNLOAD_INFO_FILE) + if (!persistenceFile.exists() || !persistenceFile.canRead()) return@runCatching false + + val text = persistenceFile.readText().trim() + if (text.isEmpty()) return@runCatching false + + val json = JSONObject(text) + json.keys().asSequence().any { key -> key.toIntOrNull() != null } + }.getOrElse { + false + } +} + +internal fun SteamService.Companion.clearPersistedProgressSnapshot(appDirPath: String) { + val persistenceDir = File(appDirPath, DOWNLOAD_INFO_DIR) + val persistenceFile = File(persistenceDir, DOWNLOAD_INFO_FILE) + if (persistenceFile.exists()) { + persistenceFile.delete() + } + val legacyFile = File(persistenceDir, LEGACY_DOWNLOAD_INFO_FILE) + if (legacyFile.exists()) { + legacyFile.delete() + } + if (persistenceDir.exists() && persistenceDir.list().isNullOrEmpty()) { + persistenceDir.delete() + } +} + +internal fun SteamService.Companion.clearFailedResumeState(appId: Int) { + val appDirPath = getAppDirPath(appId) + clearPersistedProgressSnapshot(appDirPath) + runBlocking(Dispatchers.IO) { + instance?.downloadingAppInfoDao?.deleteApp(appId) + } +} + +internal fun SteamService.Companion.deleteRecursivelyWithRetries( + target: File, + maxAttempts: Int = 5, + delayMs: Long = 250L, +): Boolean { + if (!target.exists()) return true + + repeat(maxAttempts) { + if (target.deleteRecursively()) return true + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return !target.exists() + } + } + + return !target.exists() +} + +internal fun SteamService.Companion.cleanupSteamAppCacheDirs(appId: Int) { + StoreArtworkCache.deleteGame(PluviaApp.instance, "steam", appId.toString()) + steamAppCacheDirs(appId).forEach { dir -> + if (!dir.exists()) return@forEach + Timber.i("Deleting Steam cache folder for appId $appId: ${dir.absolutePath}") + if (!deleteRecursivelyWithRetries(dir)) { + Timber.w("Failed to fully delete Steam cache folder for appId $appId: ${dir.absolutePath}") + } + } +} + +internal fun SteamService.Companion.steamAppCacheDirs(appId: Int): List { + val appIdString = appId.toString() + val dirs = linkedMapOf() + + fun addDir(dir: File) { + val normalized = + try { + dir.canonicalFile + } catch (_: IOException) { + dir.absoluteFile + } + dirs[normalized.path] = normalized + } + + fun addSteamAppsRoot(root: File) { + addDir(File(root, "staging/$appIdString")) + addDir(File(root, "shadercache/$appIdString")) + } + + fun addInstallRoot(installRoot: String) { + if (installRoot.isBlank()) return + val root = File(installRoot) + val steamAppsRoot = + if (root.name.equals("common", ignoreCase = true)) { + root.parentFile ?: root + } else { + root + } + addSteamAppsRoot(steamAppsRoot) + } + + addDir(File(defaultAppStagingPath, appIdString)) + if (defaultStoragePath.isNotBlank()) { + addDir(File(defaultStoragePath, "Steam/steamapps/shadercache/$appIdString")) + } + + addInstallRoot(internalAppInstallPath) + addInstallRoot(externalAppInstallPath) + addInstallRoot(defaultAppInstallPath) + allInstallPaths.forEach(::addInstallRoot) + + return dirs.values.toList() +} + +internal fun SteamService.Companion.steamProtectedInstallRoots(): List = + listOf( + internalAppInstallPath, + externalAppInstallPath, + defaultAppInstallPath, + ).filter { it.isNotBlank() }.distinct() + + +internal fun SteamService.Companion.getSyncFlag(appId: Int): AtomicBoolean { + val existing = syncInProgressApps[appId] + if (existing != null) { + return existing + } + val created = AtomicBoolean(false) + val prior = syncInProgressApps.putIfAbsent(appId, created) + return prior ?: created +} + +internal fun SteamService.Companion.tryAcquireSync(appId: Int): Boolean { + val flag = getSyncFlag(appId) + return flag.compareAndSet(false, true) +} + +internal fun SteamService.Companion.releaseSync(appId: Int) { + val flag = syncInProgressApps[appId] + flag?.set(false) + if (flag != null && !flag.get()) { + syncInProgressApps.remove(appId, flag) + } +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceFriendsChat.kt b/app/src/main/feature/stores/steam/service/SteamServiceFriendsChat.kt new file mode 100644 index 000000000..e0b039076 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceFriendsChat.kt @@ -0,0 +1,624 @@ +package com.winlator.cmod.feature.stores.steam.service +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.INVALID_APP_ID +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.INVALID_PKG_ID +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.MAX_PICS_BUFFER +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.isLoggedIn +import com.winlator.cmod.feature.stores.steam.service.SteamService.Companion.withWnSession +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Friends presence + chat + PICS pollers, split out of SteamService.kt (behavior-identical). + +internal suspend fun SteamService.pushFriendsListToLibSteamClient() { + var ids = LongArray(0) + for (attempt in 0 until 30) { + ids = withWnSession { s -> s.getFriendsList() } ?: LongArray(0) + if (ids.isNotEmpty()) break + delay(200) + } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setFriendsList(ids) + Timber.i("Pushed ${ids.size} friend(s) to libsteamclient.so") + + if (ids.isEmpty()) return + + withWnSession { s -> + s.requestFriendPersonas(ids, personaStateRequested = 1) + } + + pushFriendPersonaNamesToLibSteamClient(ids) +} + +internal suspend fun SteamService.pushFriendPersonaNamesToLibSteamClient( + expectedIds: LongArray, +) { + var lastCount = -1 + var json = "[]" + for (attempt in 0 until 30) { + json = withWnSession { s -> s.getFriendPersonas() } ?: "[]" + val arr = try { JSONArray(json) } catch (_: Exception) { JSONArray() } + if (arr.length() >= expectedIds.size) break + if (arr.length() == lastCount && arr.length() > 0) break + lastCount = arr.length() + delay(200) + } + val pushed = com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .pushFriendPersonasJson(json, persistSnapshot = true) + Timber.i("Pushed $pushed friend persona(s) to libsteamclient.so (snapshot persisted)") +} + +internal fun SteamService.touchRecentChat(friendId: Long) { + if (friendId == 0L) return + _recentChats.update { it + (friendId to System.currentTimeMillis()) } +} + +internal fun SteamService.isActiveConversation(steamId: Long): Boolean = activeConversations.containsKey(steamId) + + +internal fun SteamService.continuousIncomingMessagePoller(): Job = + scope.launch { + while (isActive && isLoggedIn) { + delay(1000L) + val grouped = runCatching { drainIncomingMessages() }.getOrNull() + if (grouped.isNullOrEmpty()) continue + dispatchIncomingChat(grouped) + } + } + + +internal fun SteamService.dispatchIncomingChat( + grouped: Map>, +) { + val friends = _friendsList.value.associateBy { it.steamId } + val suppressed = GameSessionState.inGame && !PrefManager.chatInGameEnabled + for ((friendId, messages) in grouped) { + for (m in messages) _incomingChat.tryEmit(friendId to m) + val fromFriend = messages.filter { !it.fromSelf && it.text.isNotBlank() } + if (fromFriend.isEmpty()) continue + touchRecentChat(friendId) + if (isActiveConversation(friendId)) continue + _unreadCounts.update { it + (friendId to ((it[friendId] ?: 0) + fromFriend.size)) } + if (suppressed) continue + val name = friends[friendId]?.name?.ifBlank { friendId.toString() } ?: friendId.toString() + val preview = chatPreview(fromFriend.last().text) + if (PrefManager.chatNotificationsEnabled) { + runCatching { notificationHelper.notifyChatMessage(friendId, name, preview) } + } + if (PrefManager.chatHeadsEnabled) { + runCatching { + com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.onIncoming(this, friendId) + } + } + } +} + +internal fun SteamService.chatPreview(text: String): String { + val t = text.trim() + return if (t.startsWith("[img") || t.contains("steamusercontent.com")) { + getString(com.winlator.cmod.R.string.steam_chat_image) + } else { + t + } +} + +/** Poll PICS for app/package changes since the last change number on a fixed interval. */ +internal fun SteamService.continuousPICSChangesChecker(): Job = + scope.launch { + while (isActive && isLoggedIn) { + PICSChangesCheck() + delay(60.seconds) + } + } + + +internal fun SteamService.PICSChangesCheck() { + scope.launch { + ensureActive() + + try { + // PICS change poll via the C++ WN-Steam-Client. + val changesJson = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsChangesSince(PrefManager.lastPICSChangeNumber.toLong()) + } + } + if (changesJson == null) { + Timber.w("PICS changes-since via wn-steam-client unavailable, skipping") + return@launch + } + val changes = JSONObject(changesJson) + val currentCN = changes.optLong("currentChangeNumber", 0L) + + if (PrefManager.lastPICSChangeNumber.toLong() == currentCN) { + Timber.w("Change number was the same as last change number, skipping") + return@launch + } + PrefManager.lastPICSChangeNumber = currentCN.toInt() + + val appChanges = changes.optJSONArray("apps") + val pkgChanges = changes.optJSONArray("packages") + Timber.d( + "picsGetChangesSince(wn): current=$currentCN " + + "apps=${appChanges?.length() ?: 0} pkgs=${pkgChanges?.length() ?: 0}", + ) + + launch { + val reqs = mutableListOf() + if (appChanges != null) { + for (i in 0 until appChanges.length()) { + val c = appChanges.getJSONObject(i) + val appId = c.optInt("appid") + // only queue apps existing in the db that have changed + val dbApp = appDao.findApp(appId) ?: continue + if (c.optInt("changeNumber") != dbApp.lastChangeNumber) { + reqs.add(PICSRequest(id = appId)) + } + } + } + reqs.chunked(MAX_PICS_BUFFER).forEach { chunk -> + ensureActive() + Timber.d("onPicsChanges: Queueing ${chunk.size} app(s) for PICS") + appPicsChannel.send(chunk) + } + } + + launch { + data class PkgChange(val id: Int, val needsToken: Boolean) + val changed = mutableListOf() + if (pkgChanges != null) { + for (i in 0 until pkgChanges.length()) { + val c = pkgChanges.getJSONObject(i) + val pkgId = c.optInt("packageid") + val dbPkg = licenseDao.findLicense(pkgId) ?: continue + if (c.optInt("changeNumber") != dbPkg.lastChangeNumber) { + changed.add(PkgChange(pkgId, c.optBoolean("needsToken"))) + } + } + } + if (changed.isNotEmpty()) { + val needTokenIds = changed.filter { it.needsToken }.map { it.id } + val tokens = HashMap() + if (needTokenIds.isNotEmpty()) { + val tokJson = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsAccessTokens(emptyList(), needTokenIds) + } + } + if (tokJson != null) { + JSONObject(tokJson).optJSONObject("packageTokens")?.let { pt -> + for (k in pt.keys()) { + tokens[k.toInt()] = pt.getString(k).toLongOrNull() ?: 0L + } + } + } + } + ensureActive() + changed + .map { PICSRequest(it.id, tokens[it.id] ?: 0L) } + .chunked(MAX_PICS_BUFFER) + .forEach { chunk -> + Timber.d("onPicsChanges: Queueing ${chunk.size} package(s) for PICS") + packagePicsChannel.send(chunk) + } + } + } + } catch (e: Exception) { + Timber.w(e, "PICSChangesCheck failed") + } + } +} + +/** Buffered flow that batches bursts of PICS requests. */ +internal fun SteamService.continuousPICSGetProductInfo(): Job = + scope.launch { + // App PICS — product info via the C++ WN-Steam-Client. + launch { + appPicsChannel + .receiveAsFlow() + .filter { it.isNotEmpty() } + .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) + .collect { appRequests -> + Timber.d("Processing ${appRequests.size} app PICS requests") + ensureActive() + if (!isLoggedIn) return@collect + + val json = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsAppProductInfo( + appRequests.map { it.id }, + appRequests.map { it.accessToken }, + ) + } + } ?: return@collect + + try { + val arr = JSONArray(json) + val steamAppsList = mutableListOf() + for (i in 0 until arr.length()) { + ensureActive() + try { + val entry = arr.getJSONObject(i) + val appId = entry.optInt("appid") + val changeNumber = entry.optInt("changeNumber") + val appinfo = entry.optJSONObject("appinfo") ?: continue + + val appFromDb = appDao.findApp(appId) + if (changeNumber == appFromDb?.lastChangeNumber) continue + + val packageId = appFromDb?.packageId ?: INVALID_PKG_ID + val packageFromDb = + if (packageId != INVALID_PKG_ID) licenseDao.findLicense(packageId) else null + val ownerAccountId = packageFromDb?.ownerAccountId ?: emptyList() + + val existingInstallDir = appFromDb?.installDir.orEmpty() + val preserveInstallDir = + existingInstallDir.isNotEmpty() && + (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) + + val generatedApp = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() + steamAppsList.add( + generatedApp.copy( + packageId = packageId, + ownerAccountId = ownerAccountId, + receivedPICS = true, + lastChangeNumber = changeNumber, + licenseFlags = packageFromDb?.licenseFlags ?: EnumSet.noneOf(ELicenseFlags::class.java), + installDir = + if (preserveInstallDir) existingInstallDir else generatedApp.installDir, + ), + ) + } catch (e: Exception) { + Timber.w(e, "PICS app entry decode failed") + } + } + if (steamAppsList.isNotEmpty()) { + Timber.i("Inserting ${steamAppsList.size} PICS apps to database (wn)") + db.withTransaction { appDao.insertAll(steamAppsList) } + } + } catch (ce: kotlin.coroutines.cancellation.CancellationException) { + throw ce + } catch (e: Exception) { + Timber.w(e, "PICS app batch processing failed") + } + } + } + + // Package PICS — package info via the C++ WN-Steam-Client. + launch { + packagePicsChannel + .receiveAsFlow() + .filter { it.isNotEmpty() } + .buffer(capacity = MAX_PICS_BUFFER, onBufferOverflow = BufferOverflow.SUSPEND) + .collect { packageRequests -> + Timber.d("Processing ${packageRequests.size} package PICS requests") + ensureActive() + if (!isLoggedIn) return@collect + + val json = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsPackageInfo( + packageRequests.map { it.id }, + packageRequests.map { it.accessToken }, + ) + } + } ?: return@collect + + val queue = mutableListOf() + try { + val arr = JSONArray(json) + db.withTransaction { + for (i in 0 until arr.length()) { + val pkg = arr.getJSONObject(i) + val pkgId = pkg.optInt("packageid") + val appIds = pkg.optJSONArray("appids").toIntList() + licenseDao.updateApps(pkgId, appIds) + val depotIds = pkg.optJSONArray("depotids").toIntList() + licenseDao.updateDepots(pkgId, depotIds) + + if (appIds.isNotEmpty()) { + // Update package_id on existing rows in one statement; insert stubs for the rest (avoids a per-app find/update/insert N+1). + val existing = appDao.findExistingIds(appIds).toHashSet() + appDao.setPackageIdForApps(appIds, pkgId) + val newApps = appIds.asSequence() + .filter { it !in existing } + .map { SteamApp(id = it, packageId = pkgId) } + .toList() + if (newApps.isNotEmpty()) appDao.insertAll(newApps) + } + queue.addAll(appIds) + } + } + } catch (ce: kotlin.coroutines.cancellation.CancellationException) { + throw ce + } catch (e: Exception) { + Timber.w(e, "PICS package batch processing failed") + } + + if (queue.isNotEmpty()) { + // App access tokens for the package's apps, then re-queue. + val tokens = HashMap() + val tokJson = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsAccessTokens(queue, emptyList()) + } + } + if (tokJson != null) { + JSONObject(tokJson).optJSONObject("appTokens")?.let { at -> + for (k in at.keys()) { + tokens[k.toInt()] = at.getString(k).toLongOrNull() ?: 0L + } + } + } + queue + .map { PICSRequest(id = it, accessToken = tokens[it] ?: 0L) } + .chunked(MAX_PICS_BUFFER) + .forEach { chunk -> + Timber.d("bufferedPICSGetProductInfo: Queueing ${chunk.size} for PICS") + appPicsChannel.send(chunk) + } + } + } + } + } + + +/** Re-fetches apps whose stored manifest download>size (impossible — compressed can't exceed uncompressed); re-stores only when the fresh appinfo is clean so a bad response never overwrites good data. Self-limiting once rows are clean. */ +internal fun SteamService.healCorruptManifestDownloadSizes(): Job = + scope.launch { + if (!isLoggedIn) return@launch + + fun SteamApp.hasCorruptDownload(): Boolean = + depots.values.any { depot -> + depot.manifests.values.any { m -> m.size > 0L && m.download > m.size } + } + + val corruptAppIds = + runCatching { + withContext(Dispatchers.IO) { appDao.getAllAsList() } + .filter { it.hasCorruptDownload() } + .map { it.id } + }.getOrElse { e -> + Timber.w(e, "heal: scan for corrupt manifest download sizes failed") + return@launch + } + if (corruptAppIds.isEmpty()) return@launch + Timber.i( + "heal: ${corruptAppIds.size} app(s) have download>size; re-fetching: ${corruptAppIds.sorted()}", + ) + + // Owned-app appinfo only comes back in full with the access token. + val tokenMap = HashMap() + runCatching { + withWnSession { session -> + withContext(Dispatchers.IO) { session.getPicsAccessTokens(corruptAppIds, emptyList()) } + }?.let { tokJson -> + JSONObject(tokJson).optJSONObject("appTokens")?.let { at -> + for (k in at.keys()) tokenMap[k.toInt()] = at.getString(k).toLongOrNull() ?: 0L + } + } + }.onFailure { e -> Timber.w(e, "heal: access-token fetch failed; trying public appinfo") } + + var healedCount = 0 + corruptAppIds.chunked(MAX_PICS_BUFFER).forEach { chunk -> + ensureActive() + val json = + withWnSession { session -> + withContext(Dispatchers.IO) { + session.getPicsAppProductInfo(chunk, chunk.map { tokenMap[it] ?: 0L }) + } + } ?: return@forEach + runCatching { + val arr = JSONArray(json) + val healed = mutableListOf() + for (i in 0 until arr.length()) { + ensureActive() + val entry = arr.getJSONObject(i) + val appId = entry.optInt("appid") + val changeNumber = entry.optInt("changeNumber") + val appinfo = entry.optJSONObject("appinfo") ?: continue + val generated = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() + if (generated.id == INVALID_APP_ID) continue + // Only accept a re-fetch that actually removes the corruption. + if (generated.hasCorruptDownload()) { + Timber.w("heal: appId=$appId still reports download>size after re-fetch; leaving stored row") + continue + } + val appFromDb = appDao.findApp(appId) + val packageId = appFromDb?.packageId ?: INVALID_PKG_ID + val packageFromDb = + if (packageId != INVALID_PKG_ID) licenseDao.findLicense(packageId) else null + val existingInstallDir = appFromDb?.installDir.orEmpty() + val preserveInstallDir = + existingInstallDir.isNotEmpty() && + (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) + healed.add( + generated.copy( + packageId = packageId, + ownerAccountId = + packageFromDb?.ownerAccountId ?: appFromDb?.ownerAccountId.orEmpty(), + receivedPICS = true, + lastChangeNumber = changeNumber, + licenseFlags = + packageFromDb?.licenseFlags + ?: appFromDb?.licenseFlags + ?: EnumSet.noneOf(ELicenseFlags::class.java), + installDir = if (preserveInstallDir) existingInstallDir else generated.installDir, + ), + ) + } + if (healed.isNotEmpty()) { + db.withTransaction { appDao.insertAll(healed) } + healedCount += healed.size + } + }.onFailure { e -> Timber.w(e, "heal: batch processing failed") } + } + if (healedCount > 0) { + Timber.i("heal: corrected manifest download sizes for $healedCount app(s)") + } + } + diff --git a/app/src/main/feature/stores/steam/service/SteamServiceInstall.kt b/app/src/main/feature/stores/steam/service/SteamServiceInstall.kt new file mode 100644 index 000000000..dfd84ce45 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceInstall.kt @@ -0,0 +1,290 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Install-path/shortcut helpers + download-request gating, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.isSuspiciousSteamInstallDirLeaf(value: String): Boolean { + val normalized = value.trim().replace('\\', '/').trimEnd('/') + if (normalized.isEmpty()) return false + val leaf = normalized.substringAfterLast('/') + return leaf.equals("common", ignoreCase = true) || + leaf.equals("steamapps", ignoreCase = true) +} + +internal fun SteamService.Companion.isSuspiciousSteamInstallPath(path: String): Boolean { + val normalized = path.trim().replace('\\', '/').trimEnd('/') + if (normalized.isEmpty()) return false + return normalized.endsWith("/steamapps/common", ignoreCase = true) || + normalized.endsWith("/steamapps", ignoreCase = true) +} + +internal fun SteamService.Companion.normalizeInstallPath(path: String): String { + if (path.isBlank()) return path + return try { + File(path).canonicalPath + } catch (_: IOException) { + File(path).absolutePath + } +} + +internal fun SteamService.Companion.createSteamShortcut( + context: Context, + appId: Int, +) { + try { + val container = ContainerUtils.getOrCreateContainer(context, "STEAM_$appId") + val appInfo = getAppInfoOf(appId) ?: return + val installPath = getAppDirPath(appId) + val launchExecutable = getInstalledExe(appId) + val desktopDir = container.getDesktopDir() + if (!desktopDir.exists()) desktopDir.mkdirs() + + val shortcutFile = File(desktopDir, "${appInfo.name}.desktop") + + // Skip if present — rewriting on every verify/update wiped per-game [Extra Data] (wine version, dxwrapper, env vars, cover art). + if (shortcutFile.exists() && shortcutFile.length() > 0L) { + Timber.i( + "Steam shortcut already exists for appId=$appId (${appInfo.name}); " + + "preserving existing per-game settings.", + ) + return + } + + val content = StringBuilder() + content.append("[Desktop Entry]\n") + content.append("Type=Application\n") + content.append("Name=${appInfo.name}\n") + content.append("Exec=wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"\n") + content.append("Icon=steam_icon_$appId\n") + content.append("\n[Extra Data]\n") + content.append("game_source=STEAM\n") + content.append("app_id=$appId\n") + content.append("container_id=${container.id}\n") + content.append("game_install_path=$installPath\n") + content.append("launch_exe_path=$launchExecutable\n") + content.append("use_container_defaults=1\n") + + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcutFile, content.toString()) + Timber.i("Created Steam shortcut for ${appInfo.name} in container ${container.id}") + } catch (e: Exception) { + Timber.e(e, "Failed to create Steam shortcut for appId $appId") + } +} + +internal fun SteamService.Companion.resolveInstalledDlcIdsForUpdateOrVerify(appId: Int): List { + val dlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toMutableList() + + getDownloadableDlcAppsOf(appId)?.forEach { dlcApp -> + val installedDlcApp = getInstalledApp(dlcApp.id) + if (installedDlcApp != null) { + dlcAppIds.add(installedDlcApp.id) + } + } + + return dlcAppIds.distinct() +} + +internal fun SteamService.Companion.parseDownloadScopeIds(scope: String): Set = + scope + .split(',') + .mapNotNull { it.trim().toIntOrNull() } + .toSet() + + +internal fun SteamService.Companion.activeDownloadRecordFor(appId: Int): DownloadRecord? = + runCatching { + runBlocking(Dispatchers.IO) { + DownloadCoordinator.findRecord( + DownloadRecord.STORE_STEAM, + appId.toString(), + ) + } + }.getOrNull() + ?.takeIf { + it.status in setOf( + DownloadRecord.STATUS_QUEUED, + DownloadRecord.STATUS_DOWNLOADING, + DownloadRecord.STATUS_PAUSED, + ) + } + + +internal fun SteamService.Companion.rejectConflictingDownloadRequest(appId: Int, record: DownloadRecord): DownloadInfo? { + Timber.i( + "Refusing Steam download request for appId=$appId because an active record already exists " + + "status=${record.status} taskType=${record.taskType} selectedDlcs=${record.selectedDlcs}", + ) + instance?.let { service -> + service.scope.launch(Dispatchers.Main) { + WinToast.show( + service.applicationContext, + service.getString(R.string.store_game_download_already_active), + Toast.LENGTH_SHORT, + ) + } + } + // Return null so callers can tell the request was rejected; returning the pre-existing job would let a verify/update pop-up latch onto an unrelated in-flight download and mislabel it. + return null +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceLogin.kt b/app/src/main/feature/stores/steam/service/SteamServiceLogin.kt new file mode 100644 index 000000000..3e843984d --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceLogin.kt @@ -0,0 +1,622 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Login token/session bring-up + update-check helpers, split out of SteamService.kt (behavior-identical). + +/** Persist native-client auth credentials for cold-start auto-logon. */ +internal fun SteamService.Companion.persistLoginTokens( + username: String, + accessToken: String?, + refreshToken: String?, + clientId: Long? = null, +) { + isLoggingOut = false + PrefManager.username = username + if (accessToken != null) PrefManager.accessToken = accessToken + if (refreshToken != null) PrefManager.refreshToken = refreshToken + if (clientId != null) PrefManager.clientId = clientId + val tokenForSid = refreshToken ?: accessToken + var newSid: Long = 0L + var accountSwitched: Boolean = false + if (tokenForSid != null) { + runCatching { + val sub = JWT(tokenForSid).subject + if (!sub.isNullOrBlank()) { + val sid64 = sub.toLongOrNull() + if (sid64 != null && sid64 != 0L) { + newSid = sid64 + val prev = PrefManager.steamUserSteamId64 + if (prev != sid64) { + accountSwitched = (prev != 0L) + PrefManager.steamUserSteamId64 = sid64 + PrefManager.steamUserAccountId = + (sid64 and 0xFFFFFFFFL).toInt() + Timber.i("persistLoginTokens: cached steamId64=$sid64" + + if (accountSwitched) " (account switch from $prev)" else "") + } + } + } + }.onFailure { e -> + Timber.w(e, "persistLoginTokens: JWT decode failed") + } + } + if (newSid != 0L) { + runCatching { + if (accountSwitched) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setPersonaName("") + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setFriendsList(LongArray(0)) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setCloudFiles(emptyArray(), IntArray(0), LongArray(0)) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setCloudEnabledForApp(false) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppId(0) + Timber.i("persistLoginTokens: cleared libsteamclient mirror on account switch") + instance?.let { svc -> + svc.scope.launch(Dispatchers.IO) { + runCatching { svc.encryptedAppTicketDao.deleteAll() } + .onFailure { + Timber.w(it, + "Failed to clear encrypted-app-ticket cache on account switch") + } + runCatching { svc.db.steamAppDao().deleteAll() } + .onFailure { + Timber.w(it, + "Failed to clear steam_app catalog on account switch") + } + runCatching { svc.licenseDao.deleteAll() } + .onFailure { + Timber.w(it, + "Failed to clear steam_license on account switch") + } + } + } + } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setSteamId(newSid) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setLoggedOn(true) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setCloudEnabledForAccount(true) + }.onFailure { e -> + Timber.w(e, "persistLoginTokens: libsteamclient identity push failed") + } + } +} + +/** Orchestrator observer on the long-lived [WnSteamSession]; drives the connection lifecycle off the channel state — state 2 (Connected): mark connected; state 3 (LoggedOn): mark connected+logged-in then run [onWnLoggedOn] once; state 0 (Disconnected): clear flows and, if still the shared session, hand off to [onWnDisconnected]. */ +internal fun SteamService.Companion.installWnLogonObserver(session: WnSteamSession) { + // A fresh session begins a fresh logon — let onWnLoggedOn re-run. + wnLoggedOnHandled = false + session.setStateObserver(object : WnSteamStateObserver { + override fun onStateChanged(state: Int) { + val name = when (state) { + 0 -> "Disconnected"; 1 -> "Connecting" + 2 -> "Connected"; 3 -> "LoggedOn" + else -> "?($state)" + } + Timber.i("WnSteam(logon) state -> %s", name) + when (state) { + 2 -> { + isConnected = true + } + 3 -> { + isConnected = true + _isLoggedInFlow.value = true + recordLogonSuccess() + if (!wnLoggedOnHandled) { + wnLoggedOnHandled = true + instance?.onWnLoggedOn(session) + } + } + 0 -> { + if (wnSession === session) { + isConnected = false + if (PrefManager.refreshToken.isBlank()) { + _isLoggedInFlow.value = false + } + wnSession = null + wnLoggedOnHandled = false + instance?.onWnDisconnected() + } + } + } + } + override fun onClientMessage(emsg: Int, eresult: Int, body: ByteArray) { + Timber.d("WnSteam(logon) inbound emsg=%d eresult=%d body=%d bytes", + emsg, eresult, body.size) + if (emsg == 751 && eresult != 1) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .reportLogonFailure(eresult = eresult, stillRetrying = true) + recordLogonFailure(eresult) + val nonRecoverable = eresult == 5 || eresult == 15 || + eresult == 18 || eresult == 65 + if (nonRecoverable && PrefManager.refreshToken.isNotBlank()) { + Timber.w("WnSteam: non-recoverable EResult=$eresult on logon — " + + "clearing refresh-token, will require re-sign-in") + PrefManager.clearAuthTokens() + _isLoggedInFlow.value = false + runCatching { + PluviaApp.events.emit( + SteamEvent.LogonEnded( + PrefManager.username, + LoginResult.Failed, + "Steam refused the cached session (EResult=$eresult). Please sign in again.")) + } + runCatching { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setLoggedOn(false) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setSteamId(0L) + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setPersonaName("") + } + } + } + } + }) + // Wire the Kotlin library facade now so it's ready for the populate-complete observer fire that lands a couple seconds after the ClientLicenseList push. + wnLibraryMirrorJob?.cancel() + wnLibrary?.stopObserving() + val library = WnLibraryStore(session) + wnLibrary = library + library.startObserving() + // Log every snapshot transition to see when the populate pipeline completes; the flow is hot + replay=1 so late collectors get the latest snapshot. + wnLibraryMirrorJob = instance?.scope?.launch(Dispatchers.Default) { + library.snapshots.collect { snap -> + Timber.i( + "WnLibrary snapshot: %d packages, %d owned apps (of %d tracked)", + snap.packages.size, snap.ownedApps.size, snap.allAppsCount, + ) + val nameIds = mutableListOf() + val nameStrs = mutableListOf() + var buildIdsPushed = 0 + var sourcePackagesPushed = 0 + for (a in snap.ownedApps) { + if (a.name.isNotEmpty()) { + nameIds.add(a.id) + nameStrs.add(a.name) + } + if (a.buildId > 0) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppBuildId(a.id, a.buildId) + ++buildIdsPushed + } + if (a.sourcePackageIds.isNotEmpty()) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppSourcePackages( + a.id, a.sourcePackageIds.toIntArray()) + ++sourcePackagesPushed + } + } + if (nameIds.isNotEmpty()) { + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppNames(nameIds.toIntArray(), nameStrs.toTypedArray()) + } + if (nameIds.isNotEmpty() || buildIdsPushed > 0 || sourcePackagesPushed > 0) { + Timber.d("WnLibrary mirror → libsteamclient.so: " + + "names=${nameIds.size} buildIds=$buildIdsPushed " + + "sourcePackages=$sourcePackagesPushed") + } + } + } +} + +internal suspend fun SteamService.Companion.bringUpWnSession(svc: SteamService): WnSteamSession? { + val caPath = CaBundleExtractor.ensureBundle(svc) + if (caPath.isEmpty()) { + Timber.e("Cannot start WnSteam session: CA bundle unavailable") + return null + } + val cmUrl = withContext(Dispatchers.IO) { + WnSteamSession.pickCmUrl(caPath) + } + if (cmUrl.isEmpty()) { + Timber.e("Cannot start WnSteam session: no CM URL") + return null + } + Timber.i("WnSteam: connecting to %s", cmUrl) + + val session = WnSteamSession() + var ok = false + try { + session.setCaBundlePath(caPath) + val connected = suspendCancellableCoroutine { cont -> + session.setStateObserver(object : WnSteamStateObserver { + override fun onStateChanged(state: Int) { + if (!cont.isActive) return + if (state == 2) cont.resume(true) + else if (state == 0) cont.resume(false) + } + override fun onClientMessage(emsg: Int, eresult: Int, body: ByteArray) {} + }) + if (!session.connect(cmUrl)) cont.resume(false) + cont.invokeOnCancellation { session.disconnect() } + } + if (!connected) { + Timber.e("WnSteam channel did not reach Connected state") + return null + } + ok = true + return session + } finally { + if (!ok) { + try { session.disconnect() } catch (_: Throwable) {} + try { session.close() } catch (_: Throwable) {} + } + } +} + +internal fun SteamService.Companion.dispatchOverlayRequest(serialized: String) { + val parts = serialized.split('\u0001') + if (parts.size < 4) { + Timber.w("dispatchOverlayRequest: malformed payload (parts=${parts.size})") + return + } + val kind = parts[0] + val arg1 = parts[1] + val sid = parts[2].toLongOrNull() ?: 0L + val appId = parts[3].toIntOrNull() ?: 0 + val url = when (kind) { + "webpage" -> if (arg1.startsWith("http://") || arg1.startsWith("https://")) arg1 + else "https://${arg1}" + "store" -> "https://store.steampowered.com/app/${appId}/" + "user" -> "https://steamcommunity.com/profiles/${sid}" + "invite" -> { + Timber.i("overlay: invite dialog requested (lobby=0x${sid.toString(16)})") + return + } + "dialog" -> when (arg1) { + "Achievements" -> "https://steamcommunity.com/stats/${appId}/achievements/" + "Players" -> "https://steamcommunity.com/profiles/${ + com.winlator.cmod.feature.stores.steam.utils.PrefManager.steamUserSteamId64 + }" + else -> "https://steamcommunity.com/profiles/${ + com.winlator.cmod.feature.stores.steam.utils.PrefManager.steamUserSteamId64 + }" + } + else -> { + Timber.w("dispatchOverlayRequest: unknown kind '$kind'") + return + } + } + val svc = instance ?: return + runCatching { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url) + ).apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + svc.applicationContext.startActivity(intent) + Timber.i("overlay: dispatched $kind → $url") + }.onFailure { e -> + Timber.w(e, "overlay: startActivity failed for $url") + } +} + +internal fun SteamService.Companion.clearUserData() { + PrefManager.clearAuthTokens() + + clearDatabase() +} + +internal fun SteamService.Companion.clearCloudSyncCaches() { + instance?.let { svc -> + svc.scope.launch { + svc.db.withTransaction { + svc.changeNumbersDao.deleteAll() + svc.fileChangeListsDao.deleteAll() + } + Timber.i("Cleared cloud sync caches (change numbers + file lists)") + } + } +} + +internal suspend fun SteamService.Companion.fetchLatestSteamAppInfo(appId: Int): SteamApp? { + // getPicsAppInfo returns {"changeNumber":N,"appinfo":{...}}; the native side parses appinfo VDF and WnKeyValue decodes it. + val wnApp = + withWnSession { session -> + withContext(Dispatchers.IO) { + // Fetch the app token first; token-gated apps omit depots without it. + val token = + runCatching { + session.getPicsAccessTokens(listOf(appId), emptyList())?.let { tj -> + JSONObject(tj) + .optJSONObject("appTokens") + ?.optString(appId.toString()) + ?.toLongOrNull() + } + }.getOrNull() ?: 0L + session.getPicsAppInfo(appId, token)?.let { json -> + try { + val obj = JSONObject(json) + val appinfo = obj.optJSONObject("appinfo") ?: return@let null + val app = WnKeyValue.fromJsonObject(appinfo).generateSteamApp() + if (app.id == INVALID_APP_ID) { + null + } else { + app.copy( + receivedPICS = true, + lastChangeNumber = obj.optInt("changeNumber", 0), + ) + } + } catch (e: Exception) { + Timber.w(e, "wn-steam-client appinfo parse failed for appId=$appId") + null + } + } + } + } + if (wnApp != null) { + Timber.i("app info via wn-steam-client: appId=$appId name='${wnApp.name}'") + return wnApp + } + Timber.w("wn-steam-client app info unavailable for appId=$appId") + return null +} + +internal suspend fun SteamService.Companion.persistLatestSteamAppInfo( + appId: Int, + remoteSteamApp: SteamApp, +) { + val service = instance ?: return + val appFromDb = service.appDao.findApp(appId) + val packageId = appFromDb?.packageId ?: remoteSteamApp.packageId + val packageFromDb = if (packageId != INVALID_PKG_ID) service.licenseDao.findLicense(packageId) else null + val existingInstallDir = appFromDb?.installDir.orEmpty() + val preserveInstallDir = + existingInstallDir.isNotEmpty() && + (existingInstallDir.startsWith("/") || existingInstallDir.contains(File.separator)) + + service.appDao.insert( + remoteSteamApp.copy( + packageId = packageId, + ownerAccountId = packageFromDb?.ownerAccountId ?: appFromDb?.ownerAccountId.orEmpty(), + licenseFlags = + packageFromDb?.licenseFlags + ?: appFromDb?.licenseFlags + ?: EnumSet.noneOf(ELicenseFlags::class.java), + installDir = if (preserveInstallDir) existingInstallDir else remoteSteamApp.installDir, + ), + ) +} + +/** Force-refreshes [appId]'s PICS depot data once per session; no-op on the main thread. */ +internal fun SteamService.Companion.ensureFreshDepotData(appId: Int) { + if (appId <= 0 || instance == null) return + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) return + if (!picsRefreshedAppsThisSession.add(appId)) return + val refreshed = + runCatching { + runBlocking(Dispatchers.IO) { + val fresh = fetchLatestSteamAppInfo(appId) ?: return@runBlocking false + persistLatestSteamAppInfo(appId, fresh) + true + } + }.getOrDefault(false) + if (refreshed) { + Timber.i("Refreshed PICS depot data for appId=$appId before depot selection") + } else { + picsRefreshedAppsThisSession.remove(appId) + } +} + +internal fun SteamService.Companion.readInstalledDepotManifestIds(appDirPath: String): Map = + runCatching { + val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") + if (!configFile.exists() || !configFile.canRead()) return@runCatching emptyMap() + val json = JSONObject(configFile.readText()) + val manifests = json.optJSONObject("installedManifestIDs") ?: return@runCatching emptyMap() + val result = mutableMapOf() + for (key in manifests.keys()) { + val depotId = key.toIntOrNull() ?: continue + // Missing → INVALID_MANIFEST_ID (Long.MAX_VALUE). + result[depotId] = manifests.optLong(key, Long.MAX_VALUE) + } + result + }.getOrElse { + Timber.w(it, "Failed to read Steam depot.config for $appDirPath") + emptyMap() + } + + +internal fun SteamService.Companion.cleanupCancelledUpdate(appDirPath: String) { + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + clearPersistedProgressSnapshot(appDirPath) + + val stagingDir = File(File(appDirPath, ".DepotDownloader"), "staging") + if (!stagingDir.exists()) return + + stagingDir + .walkBottomUp() + .forEach { staged -> + if (staged == stagingDir) return@forEach + if (staged.isDirectory) { + if (staged.list().isNullOrEmpty()) staged.delete() + return@forEach + } + + val relative = staged.relativeTo(stagingDir) + val finalFile = File(appDirPath, relative.path) + runCatching { + finalFile.parentFile?.mkdirs() + if (finalFile.exists()) { + finalFile.delete() + } + if (!staged.renameTo(finalFile)) { + staged.copyTo(finalFile, overwrite = true) + staged.delete() + } + }.onFailure { + Timber.w(it, "Failed to restore staged Steam update file ${staged.absolutePath}") + } + } + + if (stagingDir.exists() && stagingDir.list().isNullOrEmpty()) { + stagingDir.delete() + } +} diff --git a/app/src/main/feature/stores/steam/service/SteamServiceMetadata.kt b/app/src/main/feature/stores/steam/service/SteamServiceMetadata.kt new file mode 100644 index 000000000..113687ac9 --- /dev/null +++ b/app/src/main/feature/stores/steam/service/SteamServiceMetadata.kt @@ -0,0 +1,304 @@ +package com.winlator.cmod.feature.stores.steam.service +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.room.withTransaction +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.db.download.DownloadRecord +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.NetworkMonitor +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.stores.steam.data.AppInfo +import com.winlator.cmod.feature.stores.steam.data.CachedLicense +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadFailedException +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.WnDownloadTransientException +import com.winlator.cmod.feature.stores.steam.data.DownloadingAppInfo +import com.winlator.cmod.feature.stores.steam.data.EncryptedAppTicket +import com.winlator.cmod.feature.stores.steam.data.GameProcessInfo +import com.winlator.cmod.feature.stores.steam.data.LaunchInfo +import com.winlator.cmod.feature.stores.steam.data.ManifestInfo +import com.winlator.cmod.feature.stores.steam.data.OwnedGames +import com.winlator.cmod.feature.stores.steam.data.PostSyncInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.data.SteamControllerConfigDetail +import com.winlator.cmod.feature.stores.steam.data.SteamFriend +import com.winlator.cmod.feature.stores.steam.data.SteamFriendEntry +import com.winlator.cmod.feature.stores.steam.data.SteamLicense +import com.winlator.cmod.feature.stores.steam.data.UserFileInfo +import com.winlator.cmod.feature.stores.steam.db.dao.AppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.CachedLicenseDao +import com.winlator.cmod.feature.stores.steam.db.dao.ChangeNumbersDao +import com.winlator.cmod.feature.stores.steam.db.dao.DownloadingAppInfoDao +import com.winlator.cmod.feature.stores.steam.db.dao.EncryptedAppTicketDao +import com.winlator.cmod.feature.stores.steam.db.dao.FileChangeListsDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamAppDao +import com.winlator.cmod.feature.stores.steam.db.dao.SteamLicenseDao +import com.winlator.cmod.feature.stores.steam.enums.ControllerSupport +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.enums.GameSource +import com.winlator.cmod.feature.stores.steam.enums.Language +import com.winlator.cmod.feature.stores.steam.enums.LoginResult +import com.winlator.cmod.feature.stores.steam.enums.Marker +import com.winlator.cmod.feature.stores.steam.enums.OS +import com.winlator.cmod.feature.stores.steam.enums.OSArch +import com.winlator.cmod.feature.stores.steam.enums.SaveLocation +import com.winlator.cmod.feature.stores.steam.enums.SyncResult +import com.auth0.android.jwt.JWT +import com.winlator.cmod.feature.stores.common.StoreAuthStatus +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.common.StoreInstallPathSafety +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.SteamEvent +import com.winlator.cmod.feature.stores.steam.inventorygen.InventoryItemsGenerator +import com.winlator.cmod.feature.stores.steam.wnsteam.CaBundleExtractor +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnDownloadListener +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthResult +import com.winlator.cmod.feature.stores.steam.wnsteam.WnAuthenticator +import com.winlator.cmod.feature.stores.steam.wnsteam.WnLibraryStore +import com.winlator.cmod.feature.stores.steam.wnsteam.WnQrCallback +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamSession +import com.winlator.cmod.feature.stores.steam.wnsteam.WnSteamStateObserver +import com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.resume +import com.winlator.cmod.feature.stores.steam.statsgen.StatType +import com.winlator.cmod.feature.stores.steam.statsgen.StatsAchievementsGenerator +import com.winlator.cmod.feature.stores.steam.statsgen.VdfParser +import com.winlator.cmod.feature.stores.steam.utils.ContainerUtils +import com.winlator.cmod.feature.stores.steam.utils.LicenseSerializer +import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils +import com.winlator.cmod.feature.stores.steam.utils.Net +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.SteamUtils +import com.winlator.cmod.feature.stores.steam.utils.WnKeyValue +import com.winlator.cmod.feature.stores.steam.utils.generateSteamApp +import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.runtime.container.Container +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.system.GPUInformation +import com.winlator.cmod.runtime.system.SessionKeepAliveService +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.StorageUtils +import dagger.hilt.android.AndroidEntryPoint +import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag +import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags +import com.winlator.cmod.feature.stores.steam.enums.ELicenseType +import com.winlator.cmod.feature.stores.steam.enums.EPaymentMethod +import com.winlator.cmod.feature.stores.steam.enums.EOSType +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.feature.stores.steam.enums.EResult +import com.winlator.cmod.feature.stores.steam.data.AsyncJobFailedException +import com.winlator.cmod.feature.stores.steam.data.GamePlayedInfo +import com.winlator.cmod.feature.stores.steam.data.PICSRequest +import com.winlator.cmod.feature.stores.steam.data.SteamID +import com.winlator.cmod.feature.stores.steam.utils.KeyValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.future.await +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.FormBody +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.Paths +import java.util.Collections +import java.util.Date +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.seconds + +// Session/logon diagnostics + installed-metadata recovery, split out of SteamService.kt (behavior-identical). + +internal fun SteamService.Companion.recordLogonSuccess() { + if (logonGateUntilMs != 0L || consecutiveLogonFailures != 0) { + Timber.i("logon gate cleared (was open until ${logonGateUntilMs}, " + + "$consecutiveLogonFailures prior failure(s))") + } + logonGateUntilMs = 0L + lastLogonFailureEresult = 0 + consecutiveLogonFailures = 0 +} + +internal fun SteamService.Companion.recordLogonFailure(eresult: Int) { + if (eresult == 1) return + if (eresult == 67 || eresult == 88) return + consecutiveLogonFailures += 1 + lastLogonFailureEresult = eresult + val backoffMs = when (consecutiveLogonFailures) { + 1 -> 30_000L + 2 -> 120_000L + 3 -> 300_000L + 4 -> 900_000L + 5 -> 1_800_000L + else -> 3_600_000L + } + logonGateUntilMs = System.currentTimeMillis() + backoffMs + Timber.w("logon gate engaged: EResult=$eresult, " + + "consecutive=$consecutiveLogonFailures, backoff=${backoffMs / 1000}s") +} + +/** Tears down any prior wnSession at the top of every login entry so a retry doesn't leak the native handle (transport thread + heartbeat + TLS socket). */ +internal fun SteamService.Companion.teardownPriorWnSession() { + val prior = wnSession + wnSession = null + wnLoggedOnHandled = false + wnLibraryMirrorJob?.cancel() + wnLibraryMirrorJob = null + wnLibrary?.stopObserving() + wnLibrary = null + if (prior != null) { + Timber.i("Tearing down prior wnSession before relogin") + try { prior.disconnect() } catch (_: Throwable) {} + try { prior.close() } catch (_: Throwable) {} + } +} + +internal fun SteamService.Companion.getInstalledSelectableDlcAppIds(appId: Int): Set = + getSelectableDlcAppsOf(appId) + .mapNotNull { dlcApp -> + val dlcInfo = getInstalledApp(dlcApp.id) + if (dlcInfo?.isDownloaded == true) dlcApp.id else null + }.toSet() + + +internal fun SteamService.Companion.getTrustedInstalledAppInfo(appId: Int): AppInfo? { + val appInfo = getInstalledApp(appId) ?: tryRecoverInstalledAppInfo(appId) + if (appInfo?.isDownloaded != true) return null + + val dirPath = getAppDirPath(appId) + val dir = File(dirPath) + if (!dir.isDirectory) return null + if (!MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER)) return null + + return appInfo +} + +internal fun SteamService.Companion.tryRecoverInstalledAppInfo(appId: Int): AppInfo? { + val dirPath = getAppDirPath(appId) + if (dirPath.isBlank()) return null + val hasCompleteMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + val hasInProgressMarker = MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + if (!hasCompleteMarker || hasInProgressMarker) return null + + val dir = File(dirPath) + if (!dir.exists() || !dir.isDirectory) return null + + val downloadedDepotIds = runCatching { getMainAppDepots(appId).keys.sorted() }.getOrDefault(emptyList()) + val installedDlcAppIds = getInstalledSelectableDlcAppIds(appId) + val recovered = + AppInfo( + id = appId, + isDownloaded = true, + downloadedDepots = downloadedDepotIds, + dlcDepots = installedDlcAppIds.sorted(), + ) + + runBlocking(Dispatchers.IO) { + PluviaDatabase.getInstance().appInfoDao().insert(recovered) + } + Timber.i("Recovered Steam installed metadata from disk for appId=$appId at $dirPath") + return recovered +} + +internal fun SteamService.Companion.countCompletedInstallMarkers(maxCount: Int = Int.MAX_VALUE): Int { + var count = 0 + for (basePath in allInstallPaths) { + val baseDir = File(basePath) + val appDirs = baseDir.listFiles() ?: continue + for (appDir in appDirs) { + if (!appDir.isDirectory) continue + val hasCompleteMarker = File(appDir, Marker.DOWNLOAD_COMPLETE_MARKER.fileName).exists() + if (!hasCompleteMarker) continue + + val hasInProgressMarker = File(appDir, Marker.DOWNLOAD_IN_PROGRESS_MARKER.fileName).exists() + if (hasInProgressMarker) continue + + count++ + if (count >= maxCount) return count + } + } + return count +} + +internal fun SteamService.Companion.shouldRepairInstalledMetadata(): Boolean { + val db = + runCatching { PluviaDatabase.getInstance() }.getOrElse { + Timber.e(it, "Failed to access database for startup metadata repair gate") + return false + } + + val knownAppCount = + runBlocking(Dispatchers.IO) { + runCatching { db.steamAppDao().getAllAppIds().size }.getOrElse { + Timber.e(it, "Failed to load Steam app ids for startup metadata repair gate") + return@runBlocking 0 + } + } + if (knownAppCount == 0) return false + + val installedDbCount = + runBlocking(Dispatchers.IO) { + runCatching { db.appInfoDao().getAllInstalledAppIds().size }.getOrElse { + Timber.e(it, "Failed to load installed Steam app ids for startup metadata repair gate") + return@runBlocking 0 + } + } + + val diskInstallCount = countCompletedInstallMarkers(maxCount = installedDbCount + 1) + return diskInstallCount > installedDbCount +} From ba4f5c3c03b255e55d50f6b716ccab707ab27b12 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Tue, 14 Jul 2026 20:17:48 +0000 Subject: [PATCH 2/8] Split UnifiedActivity.kt into per-area extension-function files (behavior-neutral, 12.6k->1.3k lines) --- app/src/main/app/shell/UnifiedActivity.kt | 11597 +--------------- .../app/shell/UnifiedActivityDownloads.kt | 1839 +++ .../main/app/shell/UnifiedActivityDrawer.kt | 1208 ++ .../app/shell/UnifiedActivityGameDialogs.kt | 2912 ++++ app/src/main/app/shell/UnifiedActivityHub.kt | 2604 ++++ .../main/app/shell/UnifiedActivityInput.kt | 449 + .../main/app/shell/UnifiedActivityLaunch.kt | 1020 ++ .../app/shell/UnifiedActivityShortcuts.kt | 642 + .../main/app/shell/UnifiedActivityStartup.kt | 571 + .../main/app/shell/UnifiedActivityStores.kt | 2189 +++ .../app/shell/UnifiedActivityUpdateChecks.kt | 431 + 11 files changed, 13993 insertions(+), 11469 deletions(-) create mode 100644 app/src/main/app/shell/UnifiedActivityDownloads.kt create mode 100644 app/src/main/app/shell/UnifiedActivityDrawer.kt create mode 100644 app/src/main/app/shell/UnifiedActivityGameDialogs.kt create mode 100644 app/src/main/app/shell/UnifiedActivityHub.kt create mode 100644 app/src/main/app/shell/UnifiedActivityInput.kt create mode 100644 app/src/main/app/shell/UnifiedActivityLaunch.kt create mode 100644 app/src/main/app/shell/UnifiedActivityShortcuts.kt create mode 100644 app/src/main/app/shell/UnifiedActivityStartup.kt create mode 100644 app/src/main/app/shell/UnifiedActivityStores.kt create mode 100644 app/src/main/app/shell/UnifiedActivityUpdateChecks.kt diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index bd99fd0a6..ab661f60d 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -247,25 +247,25 @@ import javax.inject.Inject import kotlin.math.abs import kotlin.math.roundToInt -private val BgDark = Color(0xFF18181D) -private val SurfaceDark = Color(0xFF1E252E) -private val CardDark = Color(0xFF12121B) -private val CardBorder = Color(0xFF2A2A3A) -private val Accent = Color(0xFF1A9FFF) -private val AccentGlow = Color(0xFF58A6FF) -private val TextPrimary = Color(0xFFF0F4FF) -private val TextSecondary = Color(0xFF7A8FA8) -private val DangerRed = Color(0xFFFF6B6B) -private val StatusOnline = Color(0xFF3FB950) -private val StatusAway = Color(0xFFF0C040) +internal val BgDark = Color(0xFF18181D) +internal val SurfaceDark = Color(0xFF1E252E) +internal val CardDark = Color(0xFF12121B) +internal val CardBorder = Color(0xFF2A2A3A) +internal val Accent = Color(0xFF1A9FFF) +internal val AccentGlow = Color(0xFF58A6FF) +internal val TextPrimary = Color(0xFFF0F4FF) +internal val TextSecondary = Color(0xFF7A8FA8) +internal val DangerRed = Color(0xFFFF6B6B) +internal val StatusOnline = Color(0xFF3FB950) +internal val StatusAway = Color(0xFFF0C040) private val StatusOffline = Color(0xFF6E7681) -private val DownloadCardBlack = Color.Black.copy(alpha = 0.46f) -private val DownloadCardSelectedBlack = Color.Black.copy(alpha = 0.58f) -private val DownloadButtonBlack = Color.Black.copy(alpha = 0.38f) +internal val DownloadCardBlack = Color.Black.copy(alpha = 0.46f) +internal val DownloadCardSelectedBlack = Color.Black.copy(alpha = 0.58f) +internal val DownloadButtonBlack = Color.Black.copy(alpha = 0.38f) private val DownloadChaseBlue = Color(0xFF2196F3) private val DownloadChaseSky = Color(0xFF29B6F6) private val DownloadChaseCyan = Color(0xFF00E5FF) -private val DownloadChaseGradientStops = +internal val DownloadChaseGradientStops = arrayOf( 0.00f to DownloadChaseBlue, 0.125f to DownloadChaseSky, @@ -279,22 +279,22 @@ private val DownloadChaseGradientStops = ) private val TabScreenHorizontalPadding = 16.dp private val TabScreenBottomPadding = 8.dp -private val UnifiedTopBarHorizontalPadding = 12.dp -private val UnifiedTopBarTopPadding = 4.dp -private val UnifiedTopBarHeight = 56.dp -private val TabListContentPadding = PaddingValues(top = 4.dp, bottom = 12.dp) -private val TabGridContentPadding = PaddingValues(top = 8.dp, bottom = 16.dp) -private val TabGridTopPadding = 8.dp -private val TabCarouselTopPadding = 12.dp -private val TabCarouselBottomPadding = 20.dp -private val DownloadsHeaderTopPadding = 2.dp - -private fun Modifier.tabScreenPadding( +internal val UnifiedTopBarHorizontalPadding = 12.dp +internal val UnifiedTopBarTopPadding = 4.dp +internal val UnifiedTopBarHeight = 56.dp +internal val TabListContentPadding = PaddingValues(top = 4.dp, bottom = 12.dp) +internal val TabGridContentPadding = PaddingValues(top = 8.dp, bottom = 16.dp) +internal val TabGridTopPadding = 8.dp +internal val TabCarouselTopPadding = 12.dp +internal val TabCarouselBottomPadding = 20.dp +internal val DownloadsHeaderTopPadding = 2.dp + +internal fun Modifier.tabScreenPadding( top: Dp = 0.dp, bottom: Dp = TabScreenBottomPadding, ): Modifier = padding(start = TabScreenHorizontalPadding, top = top, end = TabScreenHorizontalPadding, bottom = bottom) -private val LIBRARY_NAME_SANITIZE_REGEX = "[^A-Za-z0-9 _-]".toRegex() +internal val LIBRARY_NAME_SANITIZE_REGEX = "[^A-Za-z0-9 _-]".toRegex() enum class LibraryLayoutMode { GRID_4, @@ -333,32 +333,32 @@ class UnifiedActivity : ActivityResultHost { @Inject lateinit var dbProvider: Lazy - private val db: PluviaDatabase + internal val db: PluviaDatabase get() = dbProvider.get() - private data class PendingNavigation( + internal data class PendingNavigation( val item: SettingsNavItem = SettingsNavItem.CONTAINERS, val profileId: Int = 0, val editContainerId: Int = 0, val returnToGameOnBack: Boolean = false, ) - private data class ControllerConnectionState( + internal data class ControllerConnectionState( val isConnected: Boolean = ControllerHelper.isControllerConnected(), val isPlayStation: Boolean = ControllerHelper.isPlayStationController(), ) - private var rootNavController: NavHostController? = null + internal var rootNavController: NavHostController? = null - private var pendingNavigation: PendingNavigation? = null + internal var pendingNavigation: PendingNavigation? = null // Absorb rapid Back presses during the settings exit animation. - private var isPoppingSettings: Boolean = false + internal var isPoppingSettings: Boolean = false - private var selectedSteamAppId: Int = 0 - private var selectedSteamAppName: String = "" - private var selectedLibrarySource: String = "" - private var selectedGogGameId: String = "" + internal var selectedSteamAppId: Int = 0 + internal var selectedSteamAppName: String = "" + internal var selectedLibrarySource: String = "" + internal var selectedGogGameId: String = "" var libraryRefreshSignal by mutableIntStateOf(0) @@ -366,213 +366,32 @@ class UnifiedActivity : private var hasCompletedInitialResume = false // Activity-level so task progress survives game-detail dialog teardown. - private var taskProgressInfo by mutableStateOf(null) - private var taskProgressGameName by mutableStateOf("") - private var taskProgressCompleteMsg by mutableStateOf("") - private var taskProgressFailedMsg by mutableStateOf("") - private var taskProgressShown by mutableStateOf(false) - private var taskDoneMessage by mutableStateOf(null) - private var taskDoneFailed by mutableStateOf(false) - private var taskCheckingShown by mutableStateOf(false) - private var taskCheckingGameName by mutableStateOf("") - - private var taskProgressCompleteAsToast by mutableStateOf(false) - - private fun showTaskProgressPopup( - info: DownloadInfo, - gameName: String, - completeMsg: String, - failedMsg: String, - completeAsToast: Boolean = false, - ) { - taskCheckingShown = false - taskProgressInfo = info - taskProgressGameName = gameName - taskProgressCompleteMsg = completeMsg - taskProgressFailedMsg = failedMsg - taskProgressCompleteAsToast = completeAsToast - taskProgressShown = true - taskDoneMessage = null - } + internal var taskProgressInfo by mutableStateOf(null) + internal var taskProgressGameName by mutableStateOf("") + internal var taskProgressCompleteMsg by mutableStateOf("") + internal var taskProgressFailedMsg by mutableStateOf("") + internal var taskProgressShown by mutableStateOf(false) + internal var taskDoneMessage by mutableStateOf(null) + internal var taskDoneFailed by mutableStateOf(false) + internal var taskCheckingShown by mutableStateOf(false) + internal var taskCheckingGameName by mutableStateOf("") + + internal var taskProgressCompleteAsToast by mutableStateOf(false) // Prevent overlapping checks after the checking pop-up is dismissed. - private var updateCheckInProgress = false - - // Runs a Steam update check behind the checking pop-up. - private fun startUpdateCheck(appId: Int, gameName: String) { - if (updateCheckInProgress) return - if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this, - getString(R.string.downloads_no_internet), - android.widget.Toast.LENGTH_SHORT, - ) - return - } - updateCheckInProgress = true - taskCheckingGameName = gameName - taskCheckingShown = true - taskDoneMessage = null - lifecycleScope.launch { - val result = - runCatching { - withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } - }.getOrNull() - try { - when { - result == null || result.message != null -> { - taskCheckingShown = false - taskDoneFailed = true - taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) - } - result.hasUpdate -> { - val started = - withContext(Dispatchers.IO) { - SteamService.downloadAppForUpdate(appId, result.depotIds) - } - if (started != null) { - showTaskProgressPopup( - started, - gameName, - getString(R.string.store_game_update_complete), - getString(R.string.store_game_update_failed_notice), - ) - } else { - // A download is already running — downloadApp showed its toast. - taskCheckingShown = false - } - } - else -> { - taskCheckingShown = false - taskDoneFailed = false - taskDoneMessage = getString(R.string.store_game_no_updates_notice) - } - } - } finally { - updateCheckInProgress = false - } - } - } - - private fun startGogUpdateCheck(gameId: String, gameName: String) { - if (updateCheckInProgress) return - if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this, - getString(R.string.downloads_no_internet), - android.widget.Toast.LENGTH_SHORT, - ) - return - } - updateCheckInProgress = true - taskCheckingGameName = gameName - taskCheckingShown = true - taskDoneMessage = null - lifecycleScope.launch { - val result = - runCatching { - withContext(Dispatchers.IO) { GOGService.checkForGameUpdate(this@UnifiedActivity, gameId) } - }.getOrNull() - try { - when { - result == null || result.message != null -> { - taskCheckingShown = false - taskDoneFailed = true - taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) - } - result.hasUpdate -> { - val started = - withContext(Dispatchers.IO) { - GOGService.updateGameFiles(this@UnifiedActivity, gameId) - } - if (started != null) { - showTaskProgressPopup( - started, - gameName, - getString(R.string.store_game_update_complete), - getString(R.string.store_game_update_failed_notice), - ) - } else { - taskCheckingShown = false - } - } - else -> { - taskCheckingShown = false - taskDoneFailed = false - taskDoneMessage = getString(R.string.store_game_no_updates_notice) - } - } - } finally { - updateCheckInProgress = false - } - } - } - - private fun startEpicUpdateCheck(appId: Int, gameName: String) { - if (updateCheckInProgress) return - if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this, - getString(R.string.downloads_no_internet), - android.widget.Toast.LENGTH_SHORT, - ) - return - } - updateCheckInProgress = true - taskCheckingGameName = gameName - taskCheckingShown = true - taskDoneMessage = null - lifecycleScope.launch { - val result = - runCatching { - withContext(Dispatchers.IO) { EpicService.checkForGameUpdate(this@UnifiedActivity, appId) } - }.getOrNull() - try { - when { - result == null || result.message != null -> { - taskCheckingShown = false - taskDoneFailed = true - taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) - } - result.hasUpdate -> { - val started = - withContext(Dispatchers.IO) { - EpicService.updateGameFiles(this@UnifiedActivity, appId) - } - if (started != null) { - showTaskProgressPopup( - started, - gameName, - getString(R.string.store_game_update_complete), - getString(R.string.store_game_update_failed_notice), - ) - } else { - taskCheckingShown = false - } - } - else -> { - taskCheckingShown = false - taskDoneFailed = false - taskDoneMessage = getString(R.string.store_game_no_updates_notice) - } - } - } finally { - updateCheckInProgress = false - } - } - } + internal var updateCheckInProgress = false // Avoid paying card border animation cost behind full-screen dialogs. - private val chasingBordersPaused = mutableStateOf(false) + internal val chasingBordersPaused = mutableStateOf(false) // Keep first composition light while prefs/auth state and Room warm up. - private var startupBootstrapReady by mutableStateOf(false) - private var startupLibraryLayoutMode by mutableStateOf(null) - private var startupStoreVisible: Map? = null - private var startupContentFilters: Map? = null + internal var startupBootstrapReady by mutableStateOf(false) + internal var startupLibraryLayoutMode by mutableStateOf(null) + internal var startupStoreVisible: Map? = null + internal var startupContentFilters: Map? = null // Lets kept-alive library cards skip animation while their tab is hidden. - private val libraryTabActive = mutableStateOf(true) + internal val libraryTabActive = mutableStateOf(true) val rightStickScrollState = kotlinx.coroutines.flow.MutableStateFlow(0f) val leftStickScrollState = kotlinx.coroutines.flow.MutableStateFlow(0f) @@ -585,46 +404,24 @@ class UnifiedActivity : val openFriendsSignal = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) val openGlassesSignal = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) - private var l2KeyDown = false - private var r2KeyDown = false - private var l2AxisDown = false - private var r2AxisDown = false - private var glassesComboArmed = true - - private fun updateGlassesCombo() { - val l2 = l2KeyDown || l2AxisDown - val r2 = r2KeyDown || r2AxisDown - if (l2 && r2) { - if (glassesComboArmed) { - glassesComboArmed = false - if (com.winlator.cmod.runtime.display.GlassesManager.isConnected()) { - openGlassesSignal.tryEmit(Unit) - } - } - } else { - glassesComboArmed = true - } - } + internal var l2KeyDown = false + internal var r2KeyDown = false + internal var l2AxisDown = false + internal var r2AxisDown = false + internal var glassesComboArmed = true val libraryFocusIndex = kotlinx.coroutines.flow.MutableStateFlow(0) var libraryItemCount: Int = 0 - private var currentLibraryLayoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4 + internal var currentLibraryLayoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4 // Coil model for the focused game's immersive background. val immersiveBackgroundRef = kotlinx.coroutines.flow.MutableStateFlow(null) private val defaultNavigationBarColor: Int = android.graphics.Color.TRANSPARENT - fun applyImmersiveSystemBars(enabled: Boolean) { - window.navigationBarColor = android.graphics.Color.TRANSPARENT - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - window.isNavigationBarContrastEnforced = false - } - } - val storeFocusIndex = kotlinx.coroutines.flow.MutableStateFlow(0) var storeItemCount: Int = 0 - private var storeColumns: Int = 4 + internal var storeColumns: Int = 4 var storeGridState: androidx.compose.foundation.lazy.grid.LazyGridState? = null @@ -674,91 +471,6 @@ class UnifiedActivity : wallpaperImagePickerLauncher.launch("image/*") } - private fun moveLibraryFocus( - left: Boolean, - right: Boolean, - up: Boolean, - down: Boolean, - ) { - val idx = libraryFocusIndex.value - val count = libraryItemCount - if (count <= 0) return - var newIdx = idx - when (currentLibraryLayoutMode) { - LibraryLayoutMode.GRID_4 -> { - if (left) newIdx = (idx - 1).coerceAtLeast(0) - if (right) newIdx = (idx + 1).coerceAtMost(count - 1) - if (up) newIdx = (idx - 4).coerceAtLeast(0) - if (down) newIdx = (idx + 4).coerceAtMost(count - 1) - } - - LibraryLayoutMode.CAROUSEL -> { - if (left) newIdx = (idx - 1).coerceAtLeast(0) - if (right) newIdx = (idx + 1).coerceAtMost(count - 1) - } - - LibraryLayoutMode.LIST -> { - if (up) newIdx = (idx - 1).coerceAtLeast(0) - if (down) newIdx = (idx + 1).coerceAtMost(count - 1) - } - } - libraryFocusIndex.value = newIdx - } - - private fun moveStoreFocus( - left: Boolean, - right: Boolean, - up: Boolean, - down: Boolean, - ) { - val count = storeItemCount - if (count <= 0) return - val cols = storeColumns - - // Snap to visible content before applying another store-grid move. - var idx = storeFocusIndex.value - val grid = storeGridState - if (grid != null) { - val visibleItems = grid.layoutInfo.visibleItemsInfo - if (visibleItems.isNotEmpty()) { - val firstVisible = visibleItems.first().index - val lastVisible = visibleItems.last().index - if (idx < firstVisible || idx > lastVisible) { - idx = firstVisible - storeFocusIndex.value = idx - return - } - } - } - - var newIdx = idx - if (left) newIdx = (idx - 1).coerceAtLeast(0) - if (right) newIdx = (idx + 1).coerceAtMost(count - 1) - if (up) newIdx = (idx - cols).coerceAtLeast(0) - if (down) newIdx = (idx + cols).coerceAtMost(count - 1) - storeFocusIndex.value = newIdx - } - - private fun routeDownloadsNav( - left: Boolean, - right: Boolean, - up: Boolean, - down: Boolean, - ) { - downloadsNavBridge.controllerActive = true - when { - left -> downloadsNavBridge.left() - right -> downloadsNavBridge.right() - up -> downloadsNavBridge.up() - down -> downloadsNavBridge.down() - } - } - - private fun gogPseudoId(gameId: String): Int { - val normalized = gameId.hashCode() and 0x1FFFFFFF - return 1_500_000_000 + normalized - } - // Avoid fragment tree traversal on every input event. private var cachedInputControlsFragment: InputControlsFragment? = null private val inputControlsFragmentTracker = @@ -1045,37 +757,22 @@ class UnifiedActivity : return super.dispatchGenericMotionEvent(event) } - private var currentTabKey: String = "library" + internal var currentTabKey: String = "library" @Volatile private var inSettingsRoute: Boolean = false @Volatile - private var drawerOpen: Boolean = false - private var rightDrawerOpen: Boolean = false - private val guideHandler = android.os.Handler(android.os.Looper.getMainLooper()) - private var guideHoldRunnable: Runnable? = null + internal var drawerOpen: Boolean = false + internal var rightDrawerOpen: Boolean = false + internal val guideHandler = android.os.Handler(android.os.Looper.getMainLooper()) + internal var guideHoldRunnable: Runnable? = null - private val menuNavActive: Boolean + internal val menuNavActive: Boolean get() = inSettingsRoute var storeItemClickCallback: ((Int) -> Unit)? = null - private fun injectKeyEvent(keyCode: Int) { - window.decorView.rootView.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, keyCode)) - window.decorView.rootView.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, keyCode)) - } - - private fun hideImeIfVisible(): Boolean { - val decor = window.decorView - val insets = androidx.core.view.ViewCompat.getRootWindowInsets(decor) ?: return false - if (!insets.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime())) return false - val target = currentFocus ?: decor - androidx.core.view.WindowInsetsControllerCompat(window, target) - .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) - return true - } - private fun dispatchMenuNavKey( event: android.view.KeyEvent, keyCode: Int, @@ -1147,78 +844,6 @@ class UnifiedActivity : return super.dispatchKeyEvent(event) } - private fun applySettingsSidebarNav(keyCode: Int) { - when (keyCode) { - android.view.KeyEvent.KEYCODE_DPAD_UP -> moveSettingsItem(-1) - android.view.KeyEvent.KEYCODE_DPAD_DOWN -> moveSettingsItem(1) - android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> enterSettingsContent() - } - } - - private fun moveSettingsItem(delta: Int) { - val items = SettingsNavItem.entries - val index = items.indexOf(settingsNavBridge.selectedItem) - val next = index + delta - if (next in items.indices) settingsNavBridge.onSelectItem?.invoke(items[next]) - } - - private fun enterSettingsContent() { - settingsNavBridge.zone = SettingsFocusZone.CONTENT - settingsNavBridge.contentControllerActive = true - } - - private fun navigateSettingsContent(code: Int) { - settingsNavBridge.contentControllerActive = true - when (code) { - android.view.KeyEvent.KEYCODE_DPAD_LEFT -> settingsNavBridge.contentNavLeft() - android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> settingsNavBridge.contentNavRight() - android.view.KeyEvent.KEYCODE_DPAD_UP -> settingsNavBridge.contentNavUp() - android.view.KeyEvent.KEYCODE_DPAD_DOWN -> settingsNavBridge.contentNavDown() - } - } - - private fun findVisibleFragmentContainer(view: android.view.View): android.view.View? { - if (view is androidx.fragment.app.FragmentContainerView && view.isShown && view.childCount > 0) { - return view - } - if (view is android.view.ViewGroup) { - for (i in 0 until view.childCount) { - findVisibleFragmentContainer(view.getChildAt(i))?.let { return it } - } - } - return null - } - - private fun handleSettingsStick(code: Int) { - if (settingsNavBridge.zone == SettingsFocusZone.SIDEBAR) { - applySettingsSidebarNav(code) - return - } - navigateSettingsContent(code) - } - - private fun handleGuideButton(action: Int, repeatCount: Int) { - when (action) { - android.view.KeyEvent.ACTION_DOWN -> { - if (repeatCount != 0) return - guideHoldRunnable?.let { guideHandler.removeCallbacks(it) } - guideHoldRunnable = null - if (rightDrawerOpen) { - val r = Runnable { openFriendsSignal.tryEmit(Unit) } - guideHoldRunnable = r - guideHandler.postDelayed(r, 400L) - } else if (!menuNavActive && !drawerOpen) { - openFriendsSignal.tryEmit(Unit) - } - } - - android.view.KeyEvent.ACTION_UP -> { - guideHoldRunnable?.let { guideHandler.removeCallbacks(it) } - guideHoldRunnable = null - } - } - } - private fun dispatchDrawerNavKey( event: android.view.KeyEvent, keyCode: Int, @@ -1279,77 +904,6 @@ class UnifiedActivity : return super.dispatchKeyEvent(event) } - private fun reapplyPreferredRefreshRate() { - if (isFinishing || isDestroyed) return - RefreshRateUtils.applyPreferredRefreshRate(this) - } - - private fun navigateToSettings( - item: SettingsNavItem = SettingsNavItem.CONTAINERS, - profileId: Int = 0, - editContainerId: Int = 0, - returnToGameOnBack: Boolean = false, - ) { - // In-activity settings navigation does not trigger Activity resume. - reapplyPreferredRefreshRate() - val route = buildSettingsRoute(item, profileId, editContainerId, returnToGameOnBack) - val nav = rootNavController - if (nav == null) { - pendingNavigation = PendingNavigation(item, profileId, editContainerId, returnToGameOnBack) - return - } - isPoppingSettings = false - nav.navigate(route) { - launchSingleTop = true - } - } - - private fun buildSettingsRoute( - item: SettingsNavItem = SettingsNavItem.CONTAINERS, - profileId: Int = 0, - editContainerId: Int = 0, - returnToGameOnBack: Boolean = false, - ): String = - "settings?item=${item.name}&profileId=$profileId&editContainerId=$editContainerId&returnToGameOnBack=$returnToGameOnBack" - - private fun extractSettingsNavigation(intent: Intent?): PendingNavigation? { - if (intent == null) return null - - val editContainerId = intent.getIntExtra("edit_container_id", 0) - if (editContainerId > 0) { - return PendingNavigation(SettingsNavItem.CONTAINERS, 0, editContainerId) - } - - if (intent.getBooleanExtra("edit_input_controls", false)) { - val profileId = intent.getIntExtra("selected_profile_id", 0) - val returnToGameOnBack = intent.getBooleanExtra("return_to_game_on_back", false) - return PendingNavigation(SettingsNavItem.INPUT_CONTROLS, profileId, 0, returnToGameOnBack) - } - - val selectedMenuItemId = intent.getIntExtra("selected_menu_item_id", 0) - if (selectedMenuItemId > 0) { - val target = SettingsNavItem.fromMenuId(selectedMenuItemId) ?: SettingsNavItem.CONTAINERS - return PendingNavigation(target, 0, 0) - } - - return null - } - - private fun consumeSettingsIntent(intent: Intent?) { - intent ?: return - intent.removeExtra("edit_container_id") - intent.removeExtra("edit_input_controls") - intent.removeExtra("selected_profile_id") - intent.removeExtra("selected_menu_item_id") - intent.removeExtra("return_to_game_on_back") - } - - private fun handleSettingsIntent(intent: Intent?) { - val request = extractSettingsNavigation(intent) ?: return - consumeSettingsIntent(intent) - navigateToSettings(request.item, request.profileId, request.editContainerId, request.returnToGameOnBack) - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -1357,157 +911,6 @@ class UnifiedActivity : handleSettingsIntent(intent) } - private fun maybeForwardFrontendLaunch(): Boolean { - val source = intent ?: return false - val path = resolveIncomingDesktopPath(source) ?: return false - startActivity( - Intent(this, XServerDisplayActivity::class.java).apply { - action = Intent.ACTION_VIEW - putExtra("shortcut_path", path) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - }, - ) - finish() - return true - } - - private fun resolveIncomingDesktopPath(source: Intent): String? { - materializeDesktop(source.data)?.let { return it } - source.clipData?.let { clip -> - for (i in 0 until clip.itemCount) { - materializeDesktop(clip.getItemAt(i).uri)?.let { return it } - materializeDesktop(clip.getItemAt(i).text?.toString())?.let { return it } - } - } - val extras = source.extras ?: return null - for (key in extras.keySet()) { - materializeDesktop(extras.get(key))?.let { return it } - } - return null - } - - private fun materializeDesktop(value: Any?): String? = - when (value) { - is android.net.Uri -> materializeDesktopUri(value) - is String -> - if (value.startsWith("content://") || value.startsWith("file://")) { - materializeDesktopUri(android.net.Uri.parse(value)) - } else { - java.io.File(value).takeIf { it.isFile && looksLikeDesktopFile(it) }?.absolutePath - } - else -> null - } - - private fun materializeDesktopUri(uri: android.net.Uri): String? { - when (uri.scheme?.lowercase()) { - "file" -> { - val file = uri.path?.let { java.io.File(it) } - if (file != null && file.isFile && looksLikeDesktopFile(file)) return file.absolutePath - } - "content" -> { - val resolved = com.winlator.cmod.shared.io.FileUtils.getFilePathFromUri(this, uri) - if (!resolved.isNullOrEmpty()) { - val file = java.io.File(resolved) - if (file.isFile && looksLikeDesktopFile(file)) return file.absolutePath - } - return copyUriToCacheDesktop(uri) - } - } - return null - } - - private fun copyUriToCacheDesktop(uri: android.net.Uri): String? = - runCatching { - val out = java.io.File(cacheDir, "frontend_launch.desktop") - val copied = - contentResolver.openInputStream(uri)?.use { input -> - out.outputStream().use { output -> input.copyTo(output) } - true - } ?: false - if (copied && out.isFile && looksLikeDesktopFile(out)) out.absolutePath else null - }.getOrNull() - - private fun looksLikeDesktopFile(file: java.io.File): Boolean { - if (!file.isFile || file.length() > 1_000_000L) return false - return runCatching { - val text = file.readText() - text.contains("[Desktop Entry]") || text.contains("container_id") - }.getOrDefault(false) - } - - private fun bootstrapStartupState() { - startupBootstrapReady = false - startupLibraryLayoutMode = null - startupStoreVisible = null - startupContentFilters = null - - lifecycleScope.launch(Dispatchers.IO) { - val appContext = applicationContext - val resolvedLayoutMode = - runCatching { - PrefManager.init(appContext) - LibraryLayoutMode.valueOf(PrefManager.libraryLayoutMode) - }.getOrElse { error -> - Log.w("UnifiedActivity", "Failed to resolve initial library layout", error) - LibraryLayoutMode.GRID_4 - } - - val resolvedStoreVisible = - runCatching { - val saved = PrefManager.libraryStoreVisible.split(",").toSet() - mapOf("steam" to ("steam" in saved), "epic" to ("epic" in saved), "gog" to ("gog" in saved)) - }.getOrElse { mapOf("steam" to true, "epic" to true, "gog" to true) } - - val resolvedContentFilters = - runCatching { - val saved = PrefManager.libraryContentFilters.split(",").toSet() - mapOf( - "games" to ("games" in saved), - "dlc" to ("dlc" in saved), - "applications" to ("applications" in saved), - "tools" to ("tools" in saved), - ) - }.getOrElse { mapOf("games" to true, "dlc" to false, "applications" to false, "tools" to false) } - - runCatching { dbProvider.get() } - .onFailure { Log.w("UnifiedActivity", "Database warmup failed", it) } - runCatching { EpicAuthManager.updateLoginStatus(appContext) } - .onFailure { Log.w("UnifiedActivity", "Epic auth warmup failed", it) } - runCatching { GOGAuthManager.updateLoginStatus(appContext) } - .onFailure { Log.w("UnifiedActivity", "GOG auth warmup failed", it) } - runCatching { SteamService.initLoginStatus(appContext) } - .onFailure { Log.w("UnifiedActivity", "Steam auth warmup failed", it) } - - withContext(Dispatchers.Main.immediate) { - startupLibraryLayoutMode = resolvedLayoutMode - currentLibraryLayoutMode = resolvedLayoutMode - startupStoreVisible = resolvedStoreVisible - startupContentFilters = resolvedContentFilters - startupBootstrapReady = true - } - } - } - - /** When the "Sign in to Google on launch" toggle is on, attempt a silent Play Games sign-in once per launch. */ - private fun maybeAutoSignInGoogleOnLaunch() { - if (!com.winlator.cmod.feature.sync.google.CloudSyncManager.isAutoSignInOnLaunchEnabled(this)) return - runCatching { - com.winlator.cmod.feature.sync.google.PlayGamesBootstrap.ensureInitialized(this) - com.google.android.gms.games.PlayGames - .getGamesSignInClient(this) - .signIn() - .addOnCompleteListener { task -> - val authed = task.isSuccessful && task.result?.isAuthenticated == true - if (authed) { - com.winlator.cmod.feature.sync.google.GameSaveBackupManager - .setDriveConnected(applicationContext, true) - } - } - }.onFailure { - timber.log.Timber.tag("UnifiedActivity").w(it, "Auto Google sign-in on launch failed") - } - } - override fun onCreate(savedInstanceState: Bundle?) { instance = this super.onCreate(savedInstanceState) @@ -1773,10829 +1176,85 @@ class UnifiedActivity : scheduleDeferredStoreBootstrap() } - private fun scheduleDeferredStoreBootstrap() { - window.decorView.post { - if (isFinishing || isDestroyed) return@post - lifecycleScope.launch(Dispatchers.IO) { - if (EpicService.hasStoredCredentials(this@UnifiedActivity)) { - EpicService.start(this@UnifiedActivity) - // Keep token validation off the first-frame path. - EpicAuthManager.getStoredCredentials(this@UnifiedActivity) - com.winlator.cmod.feature.stores.epic.service.EpicTokenRefreshWorker - .schedule(this@UnifiedActivity) - } - - if (SteamService.hasStoredCredentials(this@UnifiedActivity)) { - SteamService.start(this@UnifiedActivity) - } - - if (GOGAuthManager.isLoggedIn(this@UnifiedActivity)) { - GOGService.start(this@UnifiedActivity) - } - - SteamService.maybeRepairInstalledMetadataOnStartup(this@UnifiedActivity) - } - } - } - - private data class TabDef( + internal data class TabDef( val label: String, val key: String, ) - private fun buildTabs(storeVisible: Map): List { - val base = - mutableListOf( - TabDef(getString(R.string.common_ui_library), "library"), - TabDef(getString(R.string.common_ui_downloads), "downloads"), - ) - if (storeVisible["steam"] != false) base.add(TabDef("Steam", "steam")) - if (storeVisible["epic"] != false) base.add(TabDef("Epic", "epic")) - if (storeVisible["gog"] != false) base.add(TabDef("GOG", "gog")) - return base + internal enum class GameSettingsScreen { + Menu, + Shortcut, + CloudSaves, + Uninstall, } - @Composable - private fun rememberSteamInstallStateMap(apps: List): Map { - var installStateMap by remember { mutableStateOf>(emptyMap()) } + internal data class HomeShortcutUiState( + val shortcut: Shortcut? = null, + val isPinned: Boolean = false, + ) - LaunchedEffect(apps) { - installStateMap = - withContext(Dispatchers.IO) { - apps.associate { it.id to SteamService.isAppInstalled(it.id) } - } - } + internal data class ArtworkCacheId( + val store: String, + val gameId: String, + ) - return installStateMap - } + internal data class GameSettingsActionItem( + val title: String, + val icon: ImageVector, + val accentColor: Color = Accent, + val onClick: () -> Unit, + ) - @Composable - private fun rememberInstallPathStateMap(entries: List>): Map - where K : Any { - var installStateMap by remember { mutableStateOf>(emptyMap()) } + // Library Game Detail Dialog - LaunchedEffect(entries) { - installStateMap = - withContext(Dispatchers.IO) { - entries.associate { (key, path) -> - key to (path?.isNotBlank() == true && java.io.File(path).exists()) - } - } - } + internal enum class LibraryDetailScreen { Main, Shortcut, CloudSaves, Uninstall } - return installStateMap - } + internal enum class LibraryDetailPopup { CloudSaves } - @Composable - private fun rememberEpicInstallStateMap( - context: android.content.Context, - apps: List, - ): Map { - var installStateMap by remember { mutableStateOf>(emptyMap()) } - - LaunchedEffect(apps) { - installStateMap = - withContext(Dispatchers.IO) { - apps.associate { it.id to EpicService.isGameInstalled(context, it.id) } - } - } + internal enum class HeroLaunchPopup { BootToDesktop, RemoveShortcut } - return installStateMap - } + internal enum class HeroBootChoice { Desktop, Cube32, Cube64 } - @Composable - private fun rememberGogInstallStateMap(apps: List): Map { - var installStateMap by remember { mutableStateOf>(emptyMap()) } + internal data class DownloadCancelRequest( + val ids: List, + val isCancelAll: Boolean, + ) - LaunchedEffect(apps) { - installStateMap = - withContext(Dispatchers.IO) { - apps.associate { it.id to GOGService.isGameInstalled(it.id) } - } - } + internal fun Shortcut.hasExistingArtwork(extraKey: String): Boolean = + (getExtra(extraKey) + .takeIf { it.isNotBlank() } + ?.let { java.io.File(it).isFile } == true) - return installStateMap + internal fun buildWineExecCommand( + container: com.winlator.cmod.runtime.container.Container?, + gameInstallPath: String, + relativeExePath: String, + ): String { + val exeFile = java.io.File(gameInstallPath, relativeExePath.replace("\\", "/")) + return buildWineExecCommand(container, gameInstallPath, exeFile) } - @Composable - fun UnifiedHub() { - val horizontalNavigationInsets = - WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) - val initialLibraryLayoutMode = startupLibraryLayoutMode - val initialStoreVisible = startupStoreVisible ?: mapOf("steam" to true, "epic" to true, "gog" to true) - val initialContentFilters = startupContentFilters ?: mapOf("games" to true, "dlc" to false, "applications" to false, "tools" to false) - if (!startupBootstrapReady || initialLibraryLayoutMode == null) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(BgDark) - .windowInsetsPadding(horizontalNavigationInsets), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CircularProgressIndicator(color = Accent) - Text( - text = stringResource(R.string.common_ui_app_name), - color = TextPrimary, - style = MaterialTheme.typography.titleMedium, - ) - } + internal fun buildWineExecCommand( + container: com.winlator.cmod.runtime.container.Container?, + gameInstallPath: String, + exeFile: java.io.File, + ): String { + val windowsPath = + container?.let { + com.winlator.cmod.runtime.wine.WineUtils + .getDriveCGameWindowsPath( + it, + "CUSTOM", + gameInstallPath, + exeFile.absolutePath, + ) ?: com.winlator.cmod.runtime.wine.WineUtils + .getWindowsPath(it, exeFile.absolutePath) + } ?: run { + com.winlator.cmod.runtime.wine.WineUtils.getDosPath(exeFile.absolutePath) } - return - } + return "wine \"$windowsPath\"" + } - val storeVisible = remember { mutableStateMapOf(*initialStoreVisible.entries.map { it.key to it.value }.toTypedArray()) } - var showAddCustomGame by remember { mutableStateOf(false) } - var showExitDialog by remember { mutableStateOf(false) } - var searchQueryTfv by remember { mutableStateOf(TextFieldValue("")) } - val searchQuery = searchQueryTfv.text - var localLibraryRefreshKey by remember { mutableIntStateOf(0) } - var shortcutDataRefreshKey by remember { mutableIntStateOf(0) } - var iconRefreshKey by remember { mutableIntStateOf(0) } - - val currentRefreshSignal = this@UnifiedActivity.libraryRefreshSignal - val libraryRefreshKey = currentRefreshSignal + localLibraryRefreshKey - val shortcutRefreshKey = libraryRefreshKey + shortcutDataRefreshKey - val playtimeRefreshKey = this@UnifiedActivity.libraryPlaytimeRefreshSignal - - val contentFilters = remember { mutableStateMapOf(*initialContentFilters.entries.map { it.key to it.value }.toTypedArray()) } - var libraryLayoutMode by remember { - mutableStateOf( - runCatching { LibraryLayoutMode.valueOf(PrefManager.libraryLayoutMode) } - .getOrElse { initialLibraryLayoutMode }, - ) - } - var immersiveMode by remember { mutableStateOf(PrefManager.libraryImmersiveMode) } - var immersiveBlur by remember { mutableStateOf(PrefManager.libraryImmersiveBlur) } - val tabs = remember(storeVisible.toMap()) { buildTabs(storeVisible) } - var selectedIdx by rememberSaveable { mutableIntStateOf(0) } - var selectedDownloadId by remember { mutableStateOf(null) } - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - LaunchedEffect(drawerState.isOpen) { - drawerOpen = drawerState.isOpen - if (!drawerState.isOpen) drawerNavBridge.controllerActive = false - } - val isLoggedIn by SteamService.isLoggedInFlow.collectAsState() - val chatServiceEnabled by SteamService.chatServiceEnabledFlow.collectAsState() - val isEpicLoggedIn by EpicAuthManager.isLoggedInFlow.collectAsState() - val isGogLoggedIn by GOGAuthManager.isLoggedInFlow.collectAsState() - val steamApps by db.steamAppDao().getAllOwnedApps().collectAsState(initial = emptyList()) - val context = LocalContext.current - val persona by SteamService.instance?.localPersona?.collectAsState() - ?: remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val rightDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val friends by SteamService.instance?.friendsList?.collectAsState() - ?: remember { mutableStateOf(emptyList()) } - var chatFriend by remember { mutableStateOf(null) } - val friendsDrawerOpen = rightDrawerState.isOpen - LaunchedEffect(rightDrawerState.isOpen) { - rightDrawerOpen = rightDrawerState.isOpen - if (!rightDrawerState.isOpen) friendsDrawerNavBridge.controllerActive = false - } - LaunchedEffect(Unit) { - (context as? UnifiedActivity)?.openFriendsSignal?.collect { - if (rightDrawerState.isOpen) rightDrawerState.close() else rightDrawerState.open() - } - } - var installedFriendGameIds by remember { mutableStateOf>(emptySet()) } - LaunchedEffect(friends) { - val ids = friends.map { it.gameAppId }.filter { it > 0 }.distinct() - installedFriendGameIds = - withContext(Dispatchers.IO) { ids.filter { SteamService.isAppInstalled(it) }.toSet() } - } - LaunchedEffect(isLoggedIn, chatServiceEnabled) { - if (isLoggedIn && chatServiceEnabled) { - while (true) { - runCatching { SteamService.instance?.refreshFriends() } - kotlinx.coroutines.delay(30_000L) - } - } - } - LaunchedEffect(isLoggedIn, friendsDrawerOpen, chatServiceEnabled) { - if (isLoggedIn && friendsDrawerOpen && chatServiceEnabled) { - while (true) { - runCatching { SteamService.instance?.syncFriendsPresence() } - kotlinx.coroutines.delay(5_000L) - } - } - } - LaunchedEffect(isLoggedIn, chatServiceEnabled) { - if (isLoggedIn && chatServiceEnabled) { - runCatching { com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.start(context) } - } - } - - val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList()) - val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList()) - - val controllerState = rememberControllerConnectionState() - val isControllerConnected = controllerState.isConnected - val isPS = controllerState.isPlayStation - val isLibraryTab = tabs.getOrNull(selectedIdx)?.key == "library" - - val libraryRefreshListener = - remember { - object : EventDispatcher.JavaEventListener { - override fun onEvent(event: Any) { - when (event) { - is AndroidEvent.LibraryInstallStatusChanged -> { - localLibraryRefreshKey++ - shortcutDataRefreshKey++ - iconRefreshKey++ - } - is AndroidEvent.LibraryArtworkChanged -> { - shortcutDataRefreshKey++ - iconRefreshKey++ - } - } - } - } - } - DisposableEffect(libraryRefreshListener) { - PluviaApp.events.onJava(AndroidEvent.LibraryInstallStatusChanged::class, libraryRefreshListener) - PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, libraryRefreshListener) - onDispose { - PluviaApp.events.offJava(AndroidEvent.LibraryInstallStatusChanged::class, libraryRefreshListener) - PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, libraryRefreshListener) - } - } - - LaunchedEffect(isEpicLoggedIn) { - if (isEpicLoggedIn) { - EpicService.start(context) - } - } - - LaunchedEffect(isGogLoggedIn) { - if (isGogLoggedIn) { - GOGService.start(context) - } - } - - val epicLoginLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - val code = result.data?.getStringExtra(EpicOAuthActivity.EXTRA_AUTH_CODE) - if (code != null) { - scope.launch { - val authResult = EpicAuthManager.authenticateWithCode(context, code) - if (authResult.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.stores_accounts_logged_in_epic, - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.stores_accounts_epic_login_failed, authResult.exceptionOrNull()?.message), - android.widget.Toast.LENGTH_LONG, - ) - } - } - } - } - } - - val gogLoginLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - if (result.resultCode == android.app.Activity.RESULT_OK) { - val code = result.data?.getStringExtra(GOGOAuthActivity.EXTRA_AUTH_CODE) - if (!code.isNullOrBlank()) { - scope.launch { - val authResult = GOGAuthManager.authenticateWithCode(context, code) - if (authResult.isSuccess) { - GOGService.start(context) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.stores_accounts_logged_in_gog, - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.stores_accounts_gog_login_failed, authResult.exceptionOrNull()?.message), - android.widget.Toast.LENGTH_LONG, - ) - } - } - } - } - } - - val filteredSteamApps = - remember(steamApps, contentFilters.toMap()) { - steamApps.filter { app -> - when (app.type) { - com.winlator.cmod.feature.stores.steam.enums.AppType.game -> contentFilters["games"] == true - com.winlator.cmod.feature.stores.steam.enums.AppType.demo -> contentFilters["games"] == true - com.winlator.cmod.feature.stores.steam.enums.AppType.dlc -> contentFilters["dlc"] == true - com.winlator.cmod.feature.stores.steam.enums.AppType.application -> contentFilters["applications"] == true - com.winlator.cmod.feature.stores.steam.enums.AppType.tool -> contentFilters["tools"] == true - com.winlator.cmod.feature.stores.steam.enums.AppType.config -> contentFilters["tools"] == true - else -> contentFilters["games"] == true - } - } - } - - var globalSettingsApp by remember { mutableStateOf(null) } - var globalSettingsGogGame by remember { mutableStateOf(null) } - - LaunchedEffect(tabs.size) { if (selectedIdx >= tabs.size) selectedIdx = 0 } - LaunchedEffect(isLoggedIn, persona) { - if (isLoggedIn && persona == null) { - SteamService.requestUserPersona() - } - } - - val activity = LocalContext.current as? UnifiedActivity - - LaunchedEffect(tabs) { - activity?.keyEventFlow?.collect { event -> - val key = tabs.getOrNull(selectedIdx)?.key ?: "library" - when (event.keyCode) { - android.view.KeyEvent.KEYCODE_BUTTON_L1 -> { - selectedIdx = if (selectedIdx > 0) selectedIdx - 1 else tabs.size - 1 - } - - android.view.KeyEvent.KEYCODE_BUTTON_R1 -> { - selectedIdx = (selectedIdx + 1) % tabs.size - } - - android.view.KeyEvent.KEYCODE_BUTTON_START -> { - navigateToSettings(SettingsNavItem.STORES) - } - - android.view.KeyEvent.KEYCODE_BUTTON_SELECT -> { - if (key != "downloads") { - if (drawerState.isOpen) drawerState.close() else drawerState.open() - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_X -> { - if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { - activity?.openHeroForFocusedSignal?.tryEmit(Unit) - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_THUMBL -> { - if (key == "library") { - activity?.openSearchSignal?.tryEmit(Unit) - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_THUMBR -> { - if (key == "library") { - showAddCustomGame = true - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_B -> { - if (chatFriend != null) { - chatFriend = null - } else if (rightDrawerState.isOpen) { - rightDrawerState.close() - } else if (drawerState.isOpen) { - drawerState.close() - } else if (globalSettingsApp != null) { - globalSettingsApp = null - } else if (globalSettingsGogGame != null) { - globalSettingsGogGame = null - } else if (showAddCustomGame) { - showAddCustomGame = false - } else { - showExitDialog = true - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_Y -> { - if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { - if (selectedLibrarySource == "GOG") { - globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId } - return@collect - } - val isCustom = selectedSteamAppId < 0 - val epicId = if (selectedSteamAppId >= 2000000000) selectedSteamAppId - 2000000000 else 0 - - globalSettingsApp = ( - steamApps.find { it.id == selectedSteamAppId } - ?: if (isCustom) { - SteamApp(id = selectedSteamAppId, name = selectedSteamAppName, developer = "Custom") - } else if (epicId > 0) { - val epic = epicApps.find { it.id == epicId } - SteamApp( - id = selectedSteamAppId, - name = selectedSteamAppName, - developer = epic?.developer ?: "Epic Games", - gameDir = epic?.installPath ?: "", - ) - } else { - null - } - ) - } - } - - android.view.KeyEvent.KEYCODE_BUTTON_A, android.view.KeyEvent.KEYCODE_DPAD_CENTER -> { - if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { - val isCustom = selectedSteamAppId < 0 - val epicId = if (selectedSteamAppId >= 2000000000) selectedSteamAppId - 2000000000 else 0 - val containerManager = ContainerManager(context) - if (isCustom) { - launchCustomGame(context, containerManager, selectedSteamAppName) - } else if (selectedLibrarySource == "GOG") { - gogApps.find { it.id == selectedGogGameId }?.let { - launchGogGame(context, containerManager, it) - } - } else if (epicId > 0) { - val epic = epicApps.find { it.id == epicId } - if (epic != null && epic.isInstalled) { - val dummyApp = - SteamApp(id = selectedSteamAppId, name = selectedSteamAppName, gameDir = epic.installPath) - launchSteamGame(context, containerManager, dummyApp) - } - } else { - val steam = steamApps.find { it.id == selectedSteamAppId } - if (steam != null) { - launchSteamGame(context, containerManager, steam) - } - } - } else if (key != "library" && key != "downloads") { - storeItemClickCallback?.invoke(storeFocusIndex.value) - } - } - - } - } - } - - androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Rtl, - ) { - ModalNavigationDrawer( - drawerState = rightDrawerState, - drawerContent = { - androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, - ) { - com.winlator.cmod.feature.stores.steam.friends.FriendsDrawerContent( - isOpen = rightDrawerState.isOpen, - self = persona ?: com.winlator.cmod.feature.stores.steam.data.SteamFriend(), - friends = friends, - installedGameIds = installedFriendGameIds, - chatEnabled = chatServiceEnabled, - onSetState = { st -> scope.launch { SteamService.setPersonaState(st) } }, - onOpenChat = { f -> chatFriend = f; scope.launch { rightDrawerState.close() } }, - onJoinGame = { f -> - scope.launch { rightDrawerState.close() } - scope.launch { - val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) } - val installed = withContext(Dispatchers.IO) { SteamService.getInstalledApp(f.gameAppId) } - val label = f.gameName.ifBlank { context.getString(R.string.steam_join_the_game) } - if (app != null && installed != null) { - android.widget.Toast.makeText( - context, context.getString(R.string.steam_join_joining, f.name, label), android.widget.Toast.LENGTH_SHORT, - ).show() - launchSteamGame(context, ContainerManager(context), app, f.connectString) - } else { - android.widget.Toast.makeText( - context, - if (app != null) context.getString(R.string.steam_join_install, label, f.name) - else context.getString(R.string.steam_join_not_owned, label), - android.widget.Toast.LENGTH_LONG, - ).show() - } - } - }, - onPlayGame = { f -> - scope.launch { rightDrawerState.close() } - scope.launch { - val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) } - if (app != null) { - launchSteamGame(context, ContainerManager(context), app, null) - } - } - }, - ) - } - }, - scrimColor = Color.Black.copy(alpha = 0.5f), - gesturesEnabled = rightDrawerState.isOpen, - ) { - androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, - ) { - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - DrawerContent( - persona = persona, - isOpen = drawerState.isOpen, - context = context, - scope = scope, - storeVisible = storeVisible, - contentFilters = contentFilters, - libraryLayoutMode = libraryLayoutMode, - immersiveMode = immersiveMode, - immersiveBlur = immersiveBlur, - onLibraryLayoutSelected = { - libraryLayoutMode = it - PrefManager.libraryLayoutMode = it.name - }, - onStoreVisibleChanged = { key, value -> - storeVisible[key] = value - PrefManager.libraryStoreVisible = storeVisible.entries.filter { it.value }.joinToString(",") { it.key } - }, - onContentFiltersChanged = { key, value -> - contentFilters[key] = value - PrefManager.libraryContentFilters = contentFilters.entries.filter { it.value }.joinToString(",") { it.key } - }, - onImmersiveModeChanged = { - immersiveMode = it - PrefManager.libraryImmersiveMode = it - }, - onImmersiveBlurChanged = { - immersiveBlur = it - PrefManager.libraryImmersiveBlur = it - }, - onExportAll = { - scope.launch { - val count = - withContext(Dispatchers.IO) { - com.winlator.cmod.feature.shortcuts.FrontendExporter.exportAll(context) - } - val dir = com.winlator.cmod.feature.shortcuts.FrontendExporter.resolveExportDir(context) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (count > 0) { - context.getString(R.string.shortcuts_export_all_done, count, dir?.path ?: "") - } else { - context.getString(R.string.shortcuts_export_all_none) - }, - ) - } - }, - onExitApp = { - AppTerminationHelper.exitApplication(this@UnifiedActivity, "hub_drawer_exit") - }, - ) - }, - scrimColor = Color.Black.copy(alpha = 0.5f), - gesturesEnabled = drawerState.isOpen, - ) { - Box( - Modifier - .fillMaxSize() - .background(BgDark) - .windowInsetsPadding(horizontalNavigationInsets), - ) { - val currentTabKeyForImmersive = tabs.getOrNull(selectedIdx)?.key ?: "library" - val immersiveActive = immersiveMode && currentTabKeyForImmersive == "library" - DisposableEffect(immersiveActive) { - applyImmersiveSystemBars(immersiveActive) - onDispose { applyImmersiveSystemBars(false) } - } - if (immersiveMode && currentTabKeyForImmersive == "library") { - val immersiveModel by immersiveBackgroundRef.collectAsState() - val immersiveRequest = - remember(immersiveModel, immersiveBlur, context) { - val builder = ImageRequest.Builder(context).data(immersiveModel) - (immersiveModel as? java.io.File)?.takeIf { it.isFile }?.let { file -> - // Custom uploads can be overwritten in place. - val key = "library_immersive_bg:${file.absolutePath}:${file.lastModified()}" - builder.memoryCacheKey(if (immersiveBlur) "$key:blur" else key).diskCacheKey(key) - } - if (immersiveBlur) { - // Blur baked into the bitmap at decode (quarter-res + radius 2 ≈ 8px on screen), so drawing costs the same as a plain image. - val dm = context.resources.displayMetrics - builder - .size(dm.widthPixels / 4, dm.heightPixels / 4) - .scale(coil.size.Scale.FILL) - .transformations(BoxBlurTransformation(radius = 2)) - } - builder.crossfade(400).build() - } - AnimatedVisibility( - visible = immersiveModel != null, - enter = fadeIn(tween(400)), - exit = fadeOut(tween(400)), - modifier = Modifier.matchParentSize(), - ) { - Box(Modifier.matchParentSize()) { - AsyncImage( - model = immersiveRequest, - contentDescription = null, - modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop, - ) - Box( - Modifier - .matchParentSize() - .background(BgDark.copy(alpha = 0.5f)), - ) - } - } - } - val scaffoldContainer = if (immersiveMode && currentTabKeyForImmersive == "library") Color.Transparent else BgDark - val openFileManager: () -> Unit = { - val internalPath = android.os.Environment.getExternalStorageDirectory().absolutePath - val managedRoots = driveRoots(includeInternal = true) - val containerManager = com.winlator.cmod.runtime.container.ContainerManager(context) - val containers = - containerManager.getContainers().map { - DirectoryPickerDialog.ManagedContainer(it.id, it.getName()) - } - DirectoryPickerDialog.showManager( - activity = this@UnifiedActivity, - initialPath = internalPath, - managedRoots = managedRoots, - containers = containers, - onRunFile = { exePath, containerId -> - val container = containerManager.getContainerById(containerId) - if (container != null) { - val winePath = - com.winlator.cmod.runtime.wine.WineUtils - .hostPathToMappedWinePath(container, exePath) - startActivity( - android.content.Intent( - this@UnifiedActivity, - com.winlator.cmod.runtime.display.XServerDisplayActivity::class.java, - ).apply { - putExtra("container_id", container.id) - putExtra("boot_exe", winePath) - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - }, - ) - } - }, - onCreateShortcut = { exePath -> - val exeFile = java.io.File(exePath) - addCustomGame( - context, - exeFile.nameWithoutExtension, - exePath, - exeFile.parent ?: exePath, - ) - localLibraryRefreshKey++ - }, - ) - } - Scaffold( - containerColor = scaffoldContainer, - contentWindowInsets = WindowInsets(0, 0, 0, 0), - topBar = { - TopBar(tabs, selectedIdx, { - selectedIdx = it - }, persona, context, scope, isControllerConnected, isPS, isLibraryTab, searchQueryTfv, { - searchQueryTfv = - it - }, onFilterClicked = { scope.launch { drawerState.open() } }, onFriendsClicked = { scope.launch { rightDrawerState.open() } }) { - if (selectedLibrarySource == "GOG") { - globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId } - } else { - globalSettingsApp = ( - steamApps.find { it.id == selectedSteamAppId } - ?: if (selectedSteamAppId < 0) { - SteamApp( - id = selectedSteamAppId, - name = selectedSteamAppName, - developer = "Custom", - ) - } else if (selectedSteamAppId >= 2000000000) { - val epicId = selectedSteamAppId - 2000000000 - val epic = epicApps.find { it.id == epicId } - SteamApp( - id = selectedSteamAppId, - name = selectedSteamAppName, - developer = epic?.developer ?: "Epic Games", - gameDir = epic?.installPath ?: "", - ) - } else { - null - } - ) - } - } - }, - ) { padding -> - LaunchedEffect(selectedIdx, tabs) { - currentTabKey = tabs.getOrNull(selectedIdx)?.key ?: "library" - storeFocusIndex.value = 0 - downloadsNavBridge.controllerActive = false - } - - val key = tabs.getOrNull(selectedIdx)?.key ?: "library" - val innerBoxBg = if (immersiveMode && key == "library") Color.Transparent else BgDark - - Box(Modifier.padding(padding).fillMaxSize().background(innerBoxBg)) { - - LaunchedEffect(key) { libraryTabActive.value = (key == "library") } - - // Keep Library composed so its state survives tab switches. - Box( - Modifier.fillMaxSize().let { - if (key == "library") { - it - } else { - it.alpha(0f).pointerInput(Unit) { /* block ghost taps */ } - } - }, - ) { - LibraryCarousel( - isLoggedIn = isLoggedIn, - steamApps = filteredSteamApps, - epicApps = epicApps, - gogApps = gogApps, - layoutMode = libraryLayoutMode, - libraryRefreshKey = libraryRefreshKey, - shortcutRefreshKey = shortcutRefreshKey, - playtimeRefreshKey = playtimeRefreshKey, - iconRefreshKey = iconRefreshKey, - searchQuery = searchQuery, - isControllerConnected = isControllerConnected, - ) - } - - if (key != "library") { - AnimatedContent( - targetState = key, - transitionSpec = { - fadeIn(tween(200)) togetherWith fadeOut(tween(150)) - }, - label = "tabContent", - ) { animatedKey -> - when (animatedKey) { - "downloads" -> { - DownloadsTab( - selectedDownloadId, - animationsActive = key == "downloads", - onSelectDownload = { selectedDownloadId = it }, - ) - } - - "steam" -> { - SteamStoreTab(isLoggedIn, filteredSteamApps, searchQuery, LibraryLayoutMode.GRID_4) - } - - "epic" -> { - EpicStoreTab(isEpicLoggedIn, epicApps, searchQuery, LibraryLayoutMode.GRID_4) { - epicLoginLauncher.launch(Intent(this@UnifiedActivity, EpicOAuthActivity::class.java)) - } - } - - "gog" -> { - GOGStoreTab(isGogLoggedIn, gogApps, searchQuery, LibraryLayoutMode.GRID_4) { - gogLoginLauncher.launch(Intent(this@UnifiedActivity, GOGOAuthActivity::class.java)) - } - } - - else -> {} - } - } - } - - val configuration = LocalConfiguration.current - val libraryFabBase = minOf(configuration.screenWidthDp, configuration.screenHeightDp) - val addGameFabSize = (libraryFabBase * 0.125f).dp.coerceIn(56.dp, 64.dp) - val addGameFabMargin = (libraryFabBase * 0.035f).dp.coerceIn(12.dp, 20.dp) - val addGameFabIconSize = (libraryFabBase * 0.055f).dp.coerceIn(24.dp, 28.dp) - val fabNavInsets = WindowInsets.navigationBars.asPaddingValues() - val fabEndInset = - (20.dp - fabNavInsets.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr)) - .coerceAtLeast(4.dp) - val fabStartInset = - (20.dp - fabNavInsets.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr)) - .coerceAtLeast(4.dp) - - if (drawerState.isClosed) { - DrawerSwipeHotZone( - modifier = Modifier.align(Alignment.CenterStart), - onOpenDrawer = { scope.launch { drawerState.open() } }, - ) - } - if (rightDrawerState.isClosed) { - DrawerSwipeHotZone( - modifier = Modifier.align(Alignment.CenterEnd).padding(end = 22.dp), - isRightSide = true, - onOpenDrawer = { scope.launch { rightDrawerState.open() } }, - ) - } - - // Composed after the hot zones so the FAB stays on top for hit-testing. - if (key == "library") { - Column( - modifier = - Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), - ) - .padding(end = fabEndInset, bottom = addGameFabMargin), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (isControllerConnected) { - ControllerBadge("R3") - Spacer(Modifier.height(8.dp)) - } - Box( - modifier = - Modifier - .size(addGameFabSize) - .drawBehind { - drawCircle( - brush = - Brush.radialGradient( - colors = listOf(Accent.copy(alpha = 0.22f), Color.Transparent), - center = center, - radius = size.minDimension * 0.64f, - ), - radius = size.minDimension * 0.64f, - ) - } - .clip(CircleShape) - .background(Color.Transparent, CircleShape) - .border(1.5.dp, Accent.copy(alpha = 0.55f), CircleShape) - .focusProperties { canFocus = false } // No specific button for this, handle via long press or touch - .clickable( - interactionSource = null, - indication = androidx.compose.material3.ripple(color = Accent), - ) { showAddCustomGame = true }, - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.Add, - contentDescription = "Add Custom Game", - tint = Accent, - modifier = Modifier.size(addGameFabIconSize), - ) - } - } - } - - if (key == "library" || key == "downloads") { - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .windowInsetsPadding( - WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), - ) - .padding(start = fabStartInset, bottom = addGameFabMargin) - .size(addGameFabSize) - .drawBehind { - drawCircle( - brush = - Brush.radialGradient( - colors = listOf(Accent.copy(alpha = 0.22f), Color.Transparent), - center = center, - radius = size.minDimension * 0.64f, - ), - radius = size.minDimension * 0.64f, - ) - } - .clip(CircleShape) - .background(Color.Transparent, CircleShape) - .border(1.5.dp, Accent.copy(alpha = 0.55f), CircleShape) - .focusProperties { canFocus = false } - .clickable( - interactionSource = null, - indication = androidx.compose.material3.ripple(color = Accent), - ) { openFileManager() }, - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.FolderOpen, - contentDescription = "Files", - tint = Accent, - modifier = Modifier.size(addGameFabIconSize), - ) - } - } - } - } - } - } // end ModalNavigationDrawer - } // end inner LTR - } // end right friends ModalNavigationDrawer - } // end RTL provider - - if (globalSettingsApp != null) { - GameSettingsDialog( - app = globalSettingsApp!!, - onDismissRequest = { globalSettingsApp = null }, - ) - } - if (globalSettingsGogGame != null) { - GOGGameSettingsDialog( - app = globalSettingsGogGame!!, - onDismissRequest = { globalSettingsGogGame = null }, - ) - } - - if (showAddCustomGame) { - AddCustomGameDialog(onDismiss = { - showAddCustomGame = false - localLibraryRefreshKey++ - }) - } - - chatFriend?.let { cf -> - com.winlator.cmod.feature.stores.steam.friends.SteamChatScreen( - friend = friends.firstOrNull { it.steamId == cf.steamId } ?: cf, - onClose = { chatFriend = null }, - ) - } - - BackHandler(enabled = true) { - if (chatFriend != null) { - chatFriend = null - } else if (rightDrawerState.isOpen) { - scope.launch { rightDrawerState.close() } - } else if (drawerState.isOpen) { - scope.launch { drawerState.close() } - } else if (globalSettingsApp != null) { - globalSettingsApp = null - } else if (globalSettingsGogGame != null) { - globalSettingsGogGame = null - } else if (showAddCustomGame) { - showAddCustomGame = false - } else { - showExitDialog = true - } - } - - if (showExitDialog) { - Dialog( - onDismissRequest = { showExitDialog = false }, - properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), - ) { - Box( - modifier = - Modifier - .width(320.dp) - .clip(RoundedCornerShape(20.dp)) - .background(SurfaceDark) - .border(1.dp, Accent.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) - .padding(28.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.common_ui_exit_app_confirm), - style = MaterialTheme.typography.titleLarge, - color = TextPrimary, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(24.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlinedButton( - onClick = { showExitDialog = false }, - colors = ButtonDefaults.outlinedButtonColors(contentColor = TextSecondary), - border = androidx.compose.foundation.BorderStroke(1.dp, TextSecondary.copy(alpha = 0.5f)), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.common_ui_cancel), fontWeight = FontWeight.Medium) - } - Button( - onClick = { - AppTerminationHelper.exitApplication(this@UnifiedActivity, "hub_exit_menu") - }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.weight(1f), - ) { - Text(stringResource(R.string.common_ui_exit), color = Color.White, fontWeight = FontWeight.Bold) - } - } - } - } - } - } - } - - @Composable - private fun DrawerSwipeHotZone( - modifier: Modifier = Modifier, - isRightSide: Boolean = false, - onOpenDrawer: () -> Unit, - ) { - val density = LocalDensity.current - val openThresholdPx = with(density) { 36.dp.toPx() } - - Box( - modifier = - modifier - .fillMaxHeight() - .width(if (isRightSide) 30.dp else 40.dp) - .pointerInput(openThresholdPx, isRightSide) { - var accumulatedDrag = 0f - var opened = false - - detectHorizontalDragGestures( - onDragStart = { - accumulatedDrag = 0f - opened = false - }, - onHorizontalDrag = { change, dragAmount -> - val delta = if (isRightSide) -dragAmount else dragAmount - if (delta <= 0f || opened) return@detectHorizontalDragGestures - - accumulatedDrag += delta - change.consume() - - if (accumulatedDrag >= openThresholdPx) { - opened = true - onOpenDrawer() - } - }, - ) - }, - ) - } - - @Composable - private fun GlassesSettingsSheet(onDismiss: () -> Unit) { - val gm = com.winlator.cmod.runtime.display.GlassesManager - val settings by gm.settings.collectAsState() - val brightnessMax = gm.brightnessMax() - val volumeMax = gm.volumeMax() - val brightness = if (settings.brightness < 0) brightnessMax else settings.brightness - val volume = if (settings.volume < 0) volumeMax else settings.volume - val registry = remember { PaneNavRegistry() } - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - CompositionLocalProvider(LocalPaneNav provides registry) { - DialogPaneNav(registry, onDismiss = onDismiss) - androidx.compose.material3.Surface( - shape = RoundedCornerShape(24.dp), - color = SurfaceDark, - modifier = Modifier.fillMaxWidth(0.82f), - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 22.dp, vertical = 18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Eyeglasses2Icon, contentDescription = null, tint = Accent, modifier = Modifier.size(22.dp)) - Spacer(Modifier.width(10.dp)) - Text(gm.modelName(), color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold) - } - Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - GlassesLabel(stringResource(R.string.glasses_panel_refresh)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(60, 90, 120).forEach { hz -> - val selected = settings.refreshHz == hz - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(11.dp)) - .background(if (selected) Accent else TextSecondary.copy(alpha = 0.12f)) - .paneNavItem(cornerRadius = 11.dp, onActivate = { gm.setRefreshHz(hz) }, isEntry = hz == 60) - .clickable { gm.setRefreshHz(hz) } - .padding(vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text("$hz", color = if (selected) SurfaceDark else TextPrimary, - fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } - } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GlassesToggleTile(stringResource(R.string.glasses_panel_sunblock), - settings.sunblock, Modifier.weight(1f)) { gm.setSunblock(it) } - GlassesToggleTile(stringResource(R.string.session_drawer_output_3d), - settings.threeD, Modifier.weight(1f)) { gm.set3D(it) } - } - } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { - GlassesPercentSlider(stringResource(R.string.session_drawer_output_brightness), - brightness, brightnessMax) { gm.setBrightness(it) } - GlassesPercentSlider(stringResource(R.string.session_drawer_output_volume), - volume, volumeMax) { gm.setVolume(it) } - } - } - } - } - } - } - } - - @Composable - private fun GlassesLabel(text: String) { - Text(text, color = TextSecondary, fontSize = 13.sp, fontWeight = FontWeight.Medium) - } - - @Composable - private fun GlassesPercentSlider(label: String, level: Int, max: Int, onChange: (Int) -> Unit) { - val pct = if (max > 0) Math.round(level * 100f / max) else 0 - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - GlassesLabel(label) - Text("$pct%", color = Accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - } - androidx.compose.material3.Slider( - value = level.toFloat(), - onValueChange = { onChange(it.roundToInt()) }, - valueRange = 0f..max.toFloat(), - steps = (max - 1).coerceAtLeast(0), - modifier = Modifier.paneNavItem( - cornerRadius = 8.dp, - onAdjust = { dir -> onChange((level + dir).coerceIn(0, max)) }, - ), - colors = androidx.compose.material3.SliderDefaults.colors( - thumbColor = Accent, - activeTrackColor = Accent, - inactiveTrackColor = TextSecondary.copy(alpha = 0.2f), - ), - ) - } - } - - @Composable - private fun GlassesToggleTile(label: String, checked: Boolean, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) { - Column( - modifier = modifier - .clip(RoundedCornerShape(13.dp)) - .background(if (checked) Accent.copy(alpha = 0.16f) else TextSecondary.copy(alpha = 0.08f)) - .paneNavItem(cornerRadius = 13.dp, onActivate = { onChange(!checked) }) - .clickable { onChange(!checked) } - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text(label, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) - androidx.compose.material3.Switch( - checked = checked, - onCheckedChange = onChange, - colors = androidx.compose.material3.SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = Accent, - ), - ) - } - } - - @Composable - private fun TopBar( - tabs: List, - selectedIdx: Int, - onSelect: (Int) -> Unit, - persona: com.winlator.cmod.feature.stores.steam.data.SteamFriend?, - context: android.content.Context, - scope: kotlinx.coroutines.CoroutineScope, - isControllerConnected: Boolean, - isPS: Boolean, - isLibraryTab: Boolean, - searchQuery: TextFieldValue, - onSearchQueryChange: (TextFieldValue) -> Unit, - onFilterClicked: () -> Unit, - onFriendsClicked: () -> Unit = {}, - onGameSettingsClicked: () -> Unit, - ) { - var isSearchExpanded by remember { mutableStateOf(false) } - val searchFocusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - val isDownloadsTab = tabs.getOrNull(selectedIdx)?.key == "downloads" - val glassesConnected by com.winlator.cmod.runtime.display.GlassesManager.connected.collectAsState() - var showGlassesPanel by remember { mutableStateOf(false) } - - LaunchedEffect(selectedIdx) { - if (isSearchExpanded) { - onSearchQueryChange(TextFieldValue("")) - isSearchExpanded = false - } - } - - // Auto-focus the search field when expanded - LaunchedEffect(isSearchExpanded) { - if (isSearchExpanded) { - kotlinx.coroutines.delay(150) - searchFocusRequester.requestFocus() - } else if (searchQuery.text.isNotEmpty()) { - onSearchQueryChange(TextFieldValue("")) - } - } - - val controllerSearchActivity = LocalContext.current as? UnifiedActivity - LaunchedEffect(Unit) { - controllerSearchActivity?.openSearchSignal?.collect { - if (!isDownloadsTab) isSearchExpanded = true - } - } - LaunchedEffect(Unit) { - controllerSearchActivity?.openGlassesSignal?.collect { - if (glassesConnected) showGlassesPanel = true - } - } - - Column(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = UnifiedTopBarHorizontalPadding, - end = UnifiedTopBarHorizontalPadding, - top = UnifiedTopBarTopPadding, - ) - .height(UnifiedTopBarHeight), - ) { - // Center Block: Tabs (absolutely centered, unaffected by left/right content) - Row( - modifier = Modifier.align(Alignment.Center).zIndex(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - @Suppress("DEPRECATION") - CompositionLocalProvider( - androidx.compose.material3.LocalRippleConfiguration provides null, - ) { - val tabWidth = 100.dp - val tabSideGutter = 12.dp - val tabBarShape = RoundedCornerShape(18.dp) - val visibleCount = minOf(3, tabs.size) - val tabListState = rememberLazyListState() - val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = tabListState) - - LaunchedEffect(selectedIdx) { - val scrollTo = maxOf(0, selectedIdx - 1) - tabListState.animateScrollToItem(scrollTo) - } - - Box( - modifier = - Modifier - .width(tabWidth * visibleCount + tabSideGutter * 2) - .height(44.dp) - .shadow(8.dp, tabBarShape, spotColor = Color.Black.copy(alpha = 0.5f)) - .clip(tabBarShape) - .background(CardDark) - .border(1.dp, CardBorder, tabBarShape), - ) { - LazyRow( - state = tabListState, - flingBehavior = snapFlingBehavior, - modifier = - Modifier - .align(Alignment.Center) - .width(tabWidth * visibleCount) - .fillMaxHeight() - .focusProperties { canFocus = !isLibraryTab }, - userScrollEnabled = tabs.size > visibleCount, - ) { - itemsIndexed(tabs) { index, tab -> - val selected = selectedIdx == index - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val tabScale by animateFloatAsState( - targetValue = if (isPressed) 0.92f else 1f, - animationSpec = spring(stiffness = Spring.StiffnessHigh), - label = "tabScale", - ) - val textColor by animateColorAsState( - targetValue = if (selected) Accent else TextSecondary, - animationSpec = tween(280), - label = "tabTextColor", - ) - - Box( - modifier = - Modifier - .width(tabWidth) - .fillMaxHeight() - .focusProperties { canFocus = false } - .graphicsLayer { - scaleX = tabScale - scaleY = tabScale - }.clickable( - interactionSource = interactionSource, - indication = null, - ) { onSelect(index) }, - contentAlignment = Alignment.Center, - ) { - Text( - text = tab.label.uppercase(), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, - fontSize = 13.sp, - maxLines = 1, - color = textColor, - ) - } - } - } - if (isControllerConnected) { - ControllerBadge( - "L1", - Modifier.align(Alignment.CenterStart).padding(start = 4.dp), - compact = true, - ) - ControllerBadge( - "R1", - Modifier.align(Alignment.CenterEnd).padding(end = 4.dp), - compact = true, - ) - } - } - } - } - - Row( - modifier = Modifier.align(Alignment.CenterStart).fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.Transparent) - .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) - .focusProperties { canFocus = !isLibraryTab }, - contentAlignment = Alignment.Center, - ) { - @Suppress("DEPRECATION") - CompositionLocalProvider( - androidx.compose.material3.LocalRippleConfiguration provides - androidx.compose.material3.RippleConfiguration(color = Accent), - ) { - IconButton(onClick = { - navigateToSettings(SettingsNavItem.STORES) - }, modifier = Modifier.size(44.dp), enabled = true) { - Icon(Icons.Outlined.Settings, contentDescription = "Menu", tint = Accent, modifier = Modifier.size(24.dp)) - } - } - } - if (isControllerConnected) { - Spacer(Modifier.width(4.dp)) - ControllerBadge(if (isPS) "\u2261" else "Start") - } - - Spacer(Modifier.width(6.dp)) - - val searchIconRotation by animateFloatAsState( - targetValue = if (isSearchExpanded) 90f else 0f, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), - label = "searchIconRotation", - ) - - Box( - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background( - if (isSearchExpanded) { - Accent.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - ).border( - 1.dp, - Accent.copy(alpha = if (isDownloadsTab) 0.25f else 0.5f), - CircleShape, - ).focusProperties { canFocus = !isLibraryTab }, - contentAlignment = Alignment.Center, - ) { - @Suppress("DEPRECATION") - CompositionLocalProvider( - androidx.compose.material3.LocalRippleConfiguration provides - androidx.compose.material3.RippleConfiguration(color = Accent), - ) { - IconButton( - onClick = { - if (!isDownloadsTab) { - if (isSearchExpanded) { - onSearchQueryChange(TextFieldValue("")) - isSearchExpanded = false - } else { - isSearchExpanded = true - } - } - }, - modifier = Modifier.size(44.dp), - enabled = !isDownloadsTab, - ) { - Icon( - Icons.Outlined.Search, - contentDescription = "Search", - tint = - if (isDownloadsTab) { - TextSecondary.copy(alpha = 0.4f) - } else { - Accent - }, - modifier = - Modifier - .size(24.dp) - .graphicsLayer { rotationZ = searchIconRotation }, - ) - } - } - } - if (isControllerConnected) { - Spacer(Modifier.width(4.dp)) - ControllerBadge("L3") - } - } - - val topBarView = androidx.compose.ui.platform.LocalView.current - val topBarDensity = androidx.compose.ui.platform.LocalDensity.current - val topBarOrientation = androidx.compose.ui.platform.LocalConfiguration.current.orientation - val navRightInset = remember(topBarOrientation, topBarView) { - val px = androidx.core.view.ViewCompat.getRootWindowInsets(topBarView) - ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.right ?: 0 - with(topBarDensity) { px.toDp() } - } - Row( - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().zIndex(2f), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - - if (glassesConnected) { - Box( - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.Transparent) - .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) - .clickable { showGlassesPanel = true }, - contentAlignment = Alignment.Center, - ) { - Icon(Eyeglasses2Icon, contentDescription = "Glasses", tint = Accent, modifier = Modifier.size(24.dp)) - } - Spacer(Modifier.width(12.dp)) - } - - Box( - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.Transparent) - .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) - .focusProperties { canFocus = !isLibraryTab } - .clickable( - interactionSource = null, - indication = androidx.compose.material3.ripple(color = Accent), - ) { onFilterClicked() }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.FilterList, contentDescription = "Filter", tint = Accent, modifier = Modifier.size(24.dp)) - } - if (isControllerConnected) { - Spacer(Modifier.width(4.dp)) - ControllerBadge("Select") - } - - Spacer(Modifier.width(6.dp)) - - Box( - modifier = - Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.Transparent) - .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) - .clickable { onFriendsClicked() }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.People, contentDescription = "Friends", tint = Accent, modifier = Modifier.size(24.dp)) - } - if (isControllerConnected && navRightInset <= 0.dp) { - Spacer(Modifier.width(8.dp)) - Box( - modifier = - Modifier - .background(Color(0xFF394048), RoundedCornerShape(15.dp)) - .border(1.dp, Color(0xFF8B949E).copy(alpha = 0.5f), RoundedCornerShape(15.dp)) - .padding(horizontal = 7.dp, vertical = 3.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.SportsEsports, - contentDescription = "Guide", - tint = Color(0xFFE6EDF3), - modifier = Modifier.size(16.dp), - ) - } - } - } - - if (isControllerConnected && navRightInset > 0.dp) { - Box( - modifier = - Modifier - .align(Alignment.CenterEnd) - .offset(x = 38.dp) - .zIndex(2f) - .background(Color(0xFF394048), RoundedCornerShape(15.dp)) - .border(1.dp, Color(0xFF8B949E).copy(alpha = 0.5f), RoundedCornerShape(15.dp)) - .padding(horizontal = 7.dp, vertical = 3.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.SportsEsports, - contentDescription = "Guide", - tint = Color(0xFFE6EDF3), - modifier = Modifier.size(16.dp), - ) - } - } - } - - if (showGlassesPanel) GlassesSettingsSheet(onDismiss = { showGlassesPanel = false }) - - AnimatedVisibility( - visible = isSearchExpanded && !isDownloadsTab, - enter = - expandVertically( - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(200)), - exit = - shrinkVertically( - animationSpec = - spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(120)), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = - Modifier - .widthIn(max = 600.dp) - .fillMaxWidth(0.7f) - .height(44.dp) - .shadow(8.dp, RoundedCornerShape(24.dp), spotColor = Color.Black.copy(alpha = 0.4f)) - .clip(RoundedCornerShape(24.dp)) - .background(SurfaceDark), - contentAlignment = Alignment.CenterStart, - ) { - Row( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.Search, - contentDescription = null, - tint = Accent, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(12.dp)) - BasicTextField( - value = searchQuery, - onValueChange = onSearchQueryChange, - singleLine = true, - textStyle = - TextStyle( - color = TextPrimary, - fontSize = 15.sp, - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), - cursorBrush = Brush.verticalGradient(listOf(Accent, AccentGlow)), - modifier = - Modifier - .weight(1f) - .focusRequester(searchFocusRequester), - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterStart) { - if (searchQuery.text.isEmpty()) { - Text( - "Search games", - style = - TextStyle( - color = TextSecondary, - fontSize = 15.sp, - ), - ) - } - innerTextField() - } - }, - ) - if (searchQuery.text.isNotEmpty()) { - IconButton( - onClick = { onSearchQueryChange(TextFieldValue("")) }, - modifier = Modifier.size(32.dp), - ) { - Icon( - Icons.Outlined.Close, - contentDescription = "Clear", - tint = TextSecondary, - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } - } - } // end Column - } - - @Composable - fun LibraryCarousel( - isLoggedIn: Boolean, - steamApps: List, - epicApps: List, - gogApps: List, - layoutMode: LibraryLayoutMode, - libraryRefreshKey: Int = 0, - shortcutRefreshKey: Int = 0, - playtimeRefreshKey: Int = 0, - iconRefreshKey: Int = 0, - searchQuery: String = "", - isControllerConnected: Boolean = false, - ) { - val context = LocalContext.current - - var cachedShortcuts by remember { mutableStateOf>(emptyList()) } - var customApps by remember { mutableStateOf>(emptyList()) } - var localLibraryRefreshKey by remember { mutableIntStateOf(0) } - var shortcutsLoaded by remember { mutableStateOf(false) } - var pullRefreshing by remember { mutableStateOf(false) } - LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey) { - shortcutsLoaded = false - - val shortcutScanResult = - runCatching { - withContext(Dispatchers.IO) { - val cm = ContainerManager(context) - cm.upgradeShortcuts { - localLibraryRefreshKey++ - } - val allShortcuts = cm.loadShortcuts() - val apps = - allShortcuts - .mapNotNull { shortcut -> - if (!LibraryShortcutUtils.isCustomLibraryShortcut(shortcut)) { - return@mapNotNull null - } - - val displayName = - shortcut - .getExtra("custom_name", shortcut.name) - .ifBlank { shortcut.name } - - val uuid = shortcut.getExtra("uuid") - val customId = if (uuid.isNotEmpty()) { - -(uuid.hashCode().and(0x7FFFFFFF) + 1) - } else { - -(displayName.hashCode().and(0x7FFFFFFF) + 1) - } - - SteamApp( - id = customId, - name = displayName, - developer = "Custom", - gameDir = - shortcut.getExtra( - "game_install_path", - shortcut.getExtra("custom_game_folder", ""), - ), - ) - } - - allShortcuts to apps - } - }.getOrNull() - - if (shortcutScanResult != null) { - cachedShortcuts = shortcutScanResult.first - customApps = shortcutScanResult.second - } - - shortcutsLoaded = true - } - - // Move library filtering and file checks off the main thread. - var mergedInstalledApps by remember { mutableStateOf>(emptyList()) } - var installedApps by remember { mutableStateOf>(emptyList()) } - var stableInstalledApps by remember { mutableStateOf>(emptyList()) } - var gogByPseudoId by remember { mutableStateOf>(emptyMap()) } - var epicByPseudoId by remember { mutableStateOf>(emptyMap()) } - var stableGogByPseudoId by remember { mutableStateOf>(emptyMap()) } - var stableEpicByPseudoId by remember { mutableStateOf>(emptyMap()) } - var customArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var customGridArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var customCarouselArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var customListArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var customIconPathByAppId by remember { mutableStateOf>(emptyMap()) } - var stableCustomArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var stableCustomGridArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var stableCustomCarouselArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var stableCustomListArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } - var stableCustomIconPathByAppId by remember { mutableStateOf>(emptyMap()) } - var artworkCacheRefreshKey by remember { mutableIntStateOf(0) } - var libraryLoaded by remember { mutableStateOf(false) } - // Suppress transient empty states before background recomputation starts. - val scanInputToken = - remember(steamApps, epicApps, gogApps, customApps, libraryRefreshKey, localLibraryRefreshKey) { Any() } - var processedScanToken by remember { mutableStateOf(null) } - - LaunchedEffect(scanInputToken) { - withContext(Dispatchers.IO) { - val steamInstalled = steamApps.filter { SteamService.isAppInstalled(it.id) } - - val epicInstalled = epicApps.filter { it.isInstalled } - - // Match Epic's DB-backed install filter during verify/update. - val gogInstalled = gogApps.filter { it.isInstalled } - - val gogMap = gogInstalled.associateBy { gogPseudoId(it.id) } - val epicMap = epicInstalled.associateBy { 2000000000 + it.id } - - val playtimePrefs = context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) - val allPlaytime = playtimePrefs.all - val mappedEpic = - epicInstalled.map { epic -> - SteamApp( - id = 2000000000 + epic.id, - name = epic.title, - developer = epic.developer, - gameDir = epic.installPath, - ) - } - val mappedGog = - gogInstalled.map { gog -> - SteamApp( - id = gogPseudoId(gog.id), - name = gog.title, - developer = gog.developer, - gameDir = gog.installPath, - ) - } - val merged = steamInstalled + customApps + mappedEpic + mappedGog - val sorted = - merged.sortedByDescending { app -> - val searchKey = - if (app.id >= 2000000000 || app.id < 0) { - app.name - } else { - app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") - } - (allPlaytime["${searchKey}_last_played"] as? Long) ?: 0L - } - - withContext(Dispatchers.Main) { - gogByPseudoId = gogMap - epicByPseudoId = epicMap - mergedInstalledApps = merged - installedApps = sorted - if (sorted.isNotEmpty()) { - stableInstalledApps = sorted - stableGogByPseudoId = gogMap - stableEpicByPseudoId = epicMap - } - libraryLoaded = true - processedScanToken = scanInputToken - pullRefreshing = false - } - } - } - - LaunchedEffect(installedApps, gogByPseudoId, cachedShortcuts, iconRefreshKey) { - val appsSnapshot = installedApps - val gogSnapshot = gogByPseudoId - val shortcutsSnapshot = cachedShortcuts - - val artworkPaths = - withContext(Dispatchers.IO) { - buildMap { - appsSnapshot.forEach { app -> - val gogGame = gogSnapshot[app.id] - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - val shortcut = - if (gogGame != null) { - shortcutsSnapshot.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - } else { - findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) - } - val customPath = - shortcut - ?.getExtra("customLibraryIconPath") - ?.ifBlank { shortcut.getExtra("customCoverArtPath") } - if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { - put(app.id, customPath) - } - } - } - } - - val gridArtworkPaths = - withContext(Dispatchers.IO) { - buildMap { - appsSnapshot.forEach { app -> - val gogGame = gogSnapshot[app.id] - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - val shortcut = - if (gogGame != null) { - shortcutsSnapshot.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - } else { - findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) - } - val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.GRID.extraKey) - if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { - put(app.id, customPath) - } - } - } - } - - val carouselArtworkPaths = - withContext(Dispatchers.IO) { - buildMap { - appsSnapshot.forEach { app -> - val gogGame = gogSnapshot[app.id] - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - val shortcut = - if (gogGame != null) { - shortcutsSnapshot.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - } else { - findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) - } - val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL.extraKey) - if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { - put(app.id, customPath) - } - } - } - } - - val listArtworkPaths = - withContext(Dispatchers.IO) { - buildMap { - appsSnapshot.forEach { app -> - val gogGame = gogSnapshot[app.id] - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - val shortcut = - if (gogGame != null) { - shortcutsSnapshot.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - } else { - findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) - } - val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.LIST.extraKey) - if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { - put(app.id, customPath) - } - } - } - } - - val customIconPaths = - withContext(Dispatchers.IO) { - buildMap { - appsSnapshot.forEach { app -> - if (app.id >= 0) return@forEach - val safeName = app.name.replace("/", "_").replace("\\", "_") - val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") - if (iconFile.exists()) { - put(app.id, iconFile.absolutePath) - } - } - } - } - - customArtworkPathByAppId = artworkPaths - customGridArtworkPathByAppId = gridArtworkPaths - customCarouselArtworkPathByAppId = carouselArtworkPaths - customListArtworkPathByAppId = listArtworkPaths - customIconPathByAppId = customIconPaths - if (appsSnapshot.isNotEmpty()) { - stableCustomArtworkPathByAppId = artworkPaths - stableCustomGridArtworkPathByAppId = gridArtworkPaths - stableCustomCarouselArtworkPathByAppId = carouselArtworkPaths - stableCustomListArtworkPathByAppId = listArtworkPaths - stableCustomIconPathByAppId = customIconPaths - } - } - - LaunchedEffect(mergedInstalledApps, playtimeRefreshKey) { - if (mergedInstalledApps.isEmpty()) { - installedApps = emptyList() - return@LaunchedEffect - } - - val sorted = - withContext(Dispatchers.IO) { - val playtimePrefs = context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) - val allPlaytime = playtimePrefs.all - mergedInstalledApps.sortedByDescending { app -> - val searchKey = - if (app.id >= 2000000000 || app.id < 0) { - app.name - } else { - app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") - } - (allPlaytime["${searchKey}_last_played"] as? Long) ?: 0L - } - } - - installedApps = sorted - } - - val awaitingShortcutScan = installedApps.isEmpty() && !shortcutsLoaded - val keepPreviousLibraryVisible = - installedApps.isEmpty() && - stableInstalledApps.isNotEmpty() && - (processedScanToken !== scanInputToken || awaitingShortcutScan) - val visibleInstalledApps = if (keepPreviousLibraryVisible) stableInstalledApps else installedApps - val visibleGogByPseudoId = if (keepPreviousLibraryVisible) stableGogByPseudoId else gogByPseudoId - val visibleEpicByPseudoId = if (keepPreviousLibraryVisible) stableEpicByPseudoId else epicByPseudoId - val visibleCustomArtworkPathByAppId = - if (keepPreviousLibraryVisible) stableCustomArtworkPathByAppId else customArtworkPathByAppId - val visibleCustomGridArtworkPathByAppId = - if (keepPreviousLibraryVisible) stableCustomGridArtworkPathByAppId else customGridArtworkPathByAppId - val visibleCustomCarouselArtworkPathByAppId = - if (keepPreviousLibraryVisible) stableCustomCarouselArtworkPathByAppId else customCarouselArtworkPathByAppId - val visibleCustomListArtworkPathByAppId = - if (keepPreviousLibraryVisible) stableCustomListArtworkPathByAppId else customListArtworkPathByAppId - val visibleCustomIconPathByAppId = - if (keepPreviousLibraryVisible) stableCustomIconPathByAppId else customIconPathByAppId - - val displayedApps = - remember(visibleInstalledApps, searchQuery) { - if (searchQuery.isBlank()) { - visibleInstalledApps - } else { - visibleInstalledApps.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - } - - LaunchedEffect( - visibleInstalledApps, - visibleGogByPseudoId, - visibleEpicByPseudoId, - visibleCustomArtworkPathByAppId, - visibleCustomGridArtworkPathByAppId, - visibleCustomCarouselArtworkPathByAppId, - visibleCustomListArtworkPathByAppId, - cachedShortcuts, - ) { - var deletedCustomOverrides = false - val refs = - visibleInstalledApps.flatMap { app -> - val gogGame = visibleGogByPseudoId[app.id] - val epicGame = visibleEpicByPseudoId[app.id] - val overriddenSlots = - customArtworkOverrideSlots( - app = app, - gogGame = gogGame, - epicGame = epicGame, - hasDefaultCustomArt = visibleCustomArtworkPathByAppId[app.id] != null, - hasGridCustomArt = visibleCustomGridArtworkPathByAppId[app.id] != null, - hasCarouselCustomArt = visibleCustomCarouselArtworkPathByAppId[app.id] != null, - hasListCustomArt = visibleCustomListArtworkPathByAppId[app.id] != null, - hasHeroCustomArt = - findLibraryArtworkShortcut(cachedShortcuts, app, gogGame, epicGame) - ?.hasExistingArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD.extraKey) == true, - ) - - if (overriddenSlots.isNotEmpty()) { - val cacheId = artworkCacheId(app, gogGame, epicGame) - if (cacheId != null) { - val deleted = - withContext(Dispatchers.IO) { - StoreArtworkCache.deleteSlots(context, cacheId.store, cacheId.gameId, overriddenSlots) - } - deletedCustomOverrides = deletedCustomOverrides || deleted - } - } - - StoreArtworkCache - .libraryRefs( - app = app, - gogGame = gogGame, - epicGame = epicGame, - ).filterNot { it.slot in overriddenSlots } - } - val cachedAny = - withContext(Dispatchers.IO) { - StoreArtworkCache.cacheAll(context, refs) - } - if (cachedAny || deletedCustomOverrides) artworkCacheRefreshKey++ - } - - // The startup bootstrap screen already masks the first frame. Do not - // force an extra minimum spinner duration here or the library visibly - // bounces through two loading states on launch. - // A logged-in store whose owned-apps list is still empty hasn't finished - // its initial library fetch yet — keep the spinner up instead of flashing - // "No games installed". This resolves itself once the store populates its - // DB (steamApps/epicApps/gogApps become non-empty) or if other sources - // (custom apps, other stores) already have installed games. - val awaitingStoreSync = - installedApps.isEmpty() && ( - (isLoggedIn && steamApps.isEmpty()) || - (epicApps.isEmpty() && EpicService.hasStoredCredentials(context)) || - (gogApps.isEmpty() && GOGAuthManager.isLoggedIn(context)) - ) - // Only block the surface while the first library result is unresolved. - // After that, keep the current content/empty state visible during - // background refreshes so the UI does not flicker back to a spinner. - val initialLibraryLoadPending = !libraryLoaded - val waitingForFirstEmptyStateResolution = - installedApps.isEmpty() && (processedScanToken !== scanInputToken || awaitingStoreSync || awaitingShortcutScan) - val showLoading = initialLibraryLoadPending || waitingForFirstEmptyStateResolution - if (showLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val spinAlpha by animateFloatAsState( - targetValue = 1f, - animationSpec = tween(durationMillis = 600), - label = "loaderFade", - ) - CircularProgressIndicator( - color = Accent, - strokeWidth = 3.dp, - modifier = Modifier.size(48.dp).alpha(spinAlpha), - ) - } - return - } - - if (visibleInstalledApps.isEmpty()) { - val epicLoggedIn by EpicAuthManager.isLoggedInFlow.collectAsState() - val gogLoggedIn by GOGAuthManager.isLoggedInFlow.collectAsState() - val anyLoggedIn = isLoggedIn || epicLoggedIn || gogLoggedIn - val hasAnyCredentials = - anyLoggedIn || - SteamService.hasStoredCredentials(context) || - EpicService.hasStoredCredentials(context) || - GOGAuthManager.isLoggedIn(context) - if (!anyLoggedIn && !hasAnyCredentials) { - LoginRequiredScreen("Library") { - navigateToSettings(SettingsNavItem.STORES) - } - } else if (anyLoggedIn) { - PullToRefreshBox( - isRefreshing = pullRefreshing, - onRefresh = { - pullRefreshing = true - localLibraryRefreshKey++ - }, - modifier = Modifier.fillMaxSize(), - ) { - Box( - Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - contentAlignment = Alignment.Center, - ) { - EmptyStateMessage(stringResource(R.string.library_games_no_games_installed)) - } - } - } - return - } - - var selectedAppForSettings by remember { mutableStateOf(null) } - var selectedGogGameForSettings by remember { mutableStateOf(null) } - var detailApp by remember { mutableStateOf(null) } - var detailGogGame by remember { mutableStateOf(null) } - val gridState = rememberLazyGridState() - val carouselState = rememberLazyListState() - val activity = LocalContext.current as? UnifiedActivity - - // Pause chasing borders on library cards while any dialog is open. - LaunchedEffect(selectedAppForSettings, selectedGogGameForSettings, detailApp) { - chasingBordersPaused.value = - selectedAppForSettings != null || selectedGogGameForSettings != null || detailApp != null - } - DisposableEffect(Unit) { - onDispose { chasingBordersPaused.value = false } - } - - LaunchedEffect(layoutMode) { - currentLibraryLayoutMode = layoutMode - } - - // Keep activity's item count in sync - LaunchedEffect(displayedApps.size) { - activity?.libraryItemCount = displayedApps.size - val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) - if (activity != null && displayedApps.isNotEmpty() && activity.libraryFocusIndex.value > lastIndex) { - activity.libraryFocusIndex.value = lastIndex - } - } - - // FocusRequesters for each grid item - val focusRequesters = - remember(displayedApps.size) { - List(displayedApps.size) { FocusRequester() } - } - - // Observe focus index changes from the activity and request focus on the target item - val focusIndex by (activity?.libraryFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() - LaunchedEffect(focusIndex, focusRequesters.size, layoutMode) { - if (searchQuery.isEmpty() && - layoutMode == LibraryLayoutMode.GRID_4 && - focusRequesters.isNotEmpty() && - focusIndex in focusRequesters.indices - ) { - gridState.animateScrollToItem(focusIndex) - try { - focusRequesters[focusIndex].requestFocus() - } catch (_: Exception) { - } - } - } - - // Track selected app for the top-right Game Settings button - LaunchedEffect(focusIndex, displayedApps) { - val app = displayedApps.getOrNull(focusIndex) ?: displayedApps.firstOrNull() - selectedSteamAppId = app?.id ?: 0 - selectedSteamAppName = app?.name ?: "" - val gogGame = app?.let { visibleGogByPseudoId[it.id] } - selectedLibrarySource = - when { - gogGame != null -> "GOG" - app == null -> "" - app.id >= 2000000000 -> "EPIC" - app.id < 0 -> "CUSTOM" - else -> "STEAM" - } - selectedGogGameId = gogGame?.id.orEmpty() - } - - val heroApps = rememberUpdatedState(displayedApps) - val heroFocus = rememberUpdatedState(focusIndex) - val heroGogMap = rememberUpdatedState(visibleGogByPseudoId) - LaunchedEffect(Unit) { - activity?.openHeroForFocusedSignal?.collect { - val list = heroApps.value - val app = list.getOrNull(heroFocus.value) ?: list.firstOrNull() - if (app != null) { - detailGogGame = heroGogMap.value[app.id] - detailApp = app - } - } - } - - // Publish the focused game's hero art (custom card > store hero > grid capsule) for the immersive background; shortcuts load once per refresh signal, not per focus move. - var immersiveShortcuts by remember { mutableStateOf?>(null) } - LaunchedEffect(shortcutRefreshKey, libraryRefreshKey, artworkCacheRefreshKey) { - immersiveShortcuts = - withContext(Dispatchers.IO) { ContainerManager(context).loadShortcuts() } - } - - LaunchedEffect(focusIndex, displayedApps, immersiveShortcuts) { - val shortcuts = immersiveShortcuts ?: return@LaunchedEffect - val app = displayedApps.getOrNull(focusIndex) ?: displayedApps.firstOrNull() - if (app == null) { - activity?.immersiveBackgroundRef?.value = null - return@LaunchedEffect - } - // Debounce so scrubbing the grid doesn't decode every intermediate hero. - delay(200) - val gogGame = visibleGogByPseudoId[app.id] - val epicGame = visibleEpicByPseudoId[app.id] - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - - val shortcut = - when { - gogGame != null -> - shortcuts.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - else -> findShortcutForGame(shortcuts, app, isCustom, isEpic, epicId) - } - val customHeroFile = - withContext(Dispatchers.IO) { - shortcut - ?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD.extraKey) - ?.takeIf { it.isNotBlank() } - ?.let { java.io.File(it) } - ?.takeIf { it.isFile } - } - - activity?.immersiveBackgroundRef?.value = - customHeroFile - ?: run { - val ref = - StoreArtworkCache.heroRef(app, gogGame, epicGame) - ?: StoreArtworkCache.primaryRef( - app, - gogGame, - epicGame, - useLibraryCapsule = false, - listMode = false, - ) - StoreArtworkCache.imageModel(context, ref) - } - } - - val openSettingsForApp: (Int, SteamApp) -> Unit = { index, app -> - activity?.libraryFocusIndex?.value = index - selectedSteamAppId = app.id - selectedSteamAppName = app.name - val gogGame = visibleGogByPseudoId[app.id] - selectedLibrarySource = - when { - gogGame != null -> "GOG" - app.id >= 2000000000 -> "EPIC" - app.id < 0 -> "CUSTOM" - else -> "STEAM" - } - selectedGogGameId = gogGame?.id.orEmpty() - - if (gogGame != null) { - selectedGogGameForSettings = gogGame - } else { - selectedAppForSettings = app - } - } - - PullToRefreshBox( - isRefreshing = pullRefreshing, - onRefresh = { - pullRefreshing = true - localLibraryRefreshKey++ - }, - modifier = Modifier.fillMaxSize(), - ) { - when (layoutMode) { - LibraryLayoutMode.GRID_4 -> { - FourByTwoGridView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - gridState = gridState, - contentPadding = TabGridContentPadding, - clipContent = false, - keyOf = { it.id }, - ) { app, index, rowHeight -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = index == focusIndex, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomGridArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app - }, - onLongClick = { - openSettingsForApp(index, app) - }, - modifier = - Modifier - .height(rowHeight) - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) - } - } - - LibraryLayoutMode.CAROUSEL -> { - CarouselView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(top = TabCarouselTopPadding, bottom = TabCarouselBottomPadding), - listState = carouselState, - selectedIndex = focusIndex, - onCenteredIndexChanged = { centeredIndex -> - if (activity != null && activity.libraryFocusIndex.value != centeredIndex) { - activity.libraryFocusIndex.value = centeredIndex - } - }, - ) { app, index, isSelected, cardWidth, cardHeight -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = isSelected, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomCarouselArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app - }, - onLongClick = { openSettingsForApp(index, app) }, - useLibraryCapsule = true, - modifier = - Modifier - .fillMaxSize() - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) - } - } - - LibraryLayoutMode.LIST -> { - val listViewState = rememberLazyListState() - ListView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - listState = listViewState, - contentPadding = TabListContentPadding, - selectedIndex = focusIndex, - onSelectedIndexChanged = { newIdx -> - activity?.libraryFocusIndex?.value = newIdx - }, - keyOf = { it.id }, - ) { app, index, isSelected -> - GameCapsule( - app = app, - gogGame = visibleGogByPseudoId[app.id], - epicGame = visibleEpicByPseudoId[app.id], - iconRefreshKey = iconRefreshKey, - artworkCacheRefreshKey = artworkCacheRefreshKey, - isFocusedOverride = isSelected, - isControllerActive = isControllerConnected, - customArtworkPath = visibleCustomListArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], - customIconPath = visibleCustomIconPathByAppId[app.id], - onClick = { - detailGogGame = visibleGogByPseudoId[app.id] - detailApp = app - }, - onLongClick = { openSettingsForApp(index, app) }, - listMode = true, - modifier = - Modifier - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) - } - JoystickListScroll( - listState = listViewState, - stickFlow = activity?.rightStickScrollState, - minSpeed = 2.5f, - maxSpeed = 16f, - quadratic = true, - ) - } - } - } - - if (selectedAppForSettings != null) { - GameSettingsDialog( - app = selectedAppForSettings!!, - onDismissRequest = { selectedAppForSettings = null }, - ) - } - if (selectedGogGameForSettings != null) { - GOGGameSettingsDialog( - app = selectedGogGameForSettings!!, - onDismissRequest = { selectedGogGameForSettings = null }, - ) - } - if (detailApp != null) { - LibraryGameDetailDialog( - app = detailApp!!, - gogGame = detailGogGame, - onDismissRequest = { - detailApp = null - detailGogGame = null - }, - ) - } - } - - private enum class GameSettingsScreen { - Menu, - Shortcut, - CloudSaves, - Uninstall, - } - - private data class HomeShortcutUiState( - val shortcut: Shortcut? = null, - val isPinned: Boolean = false, - ) - - private data class ArtworkCacheId( - val store: String, - val gameId: String, - ) - - private data class GameSettingsActionItem( - val title: String, - val icon: ImageVector, - val accentColor: Color = Accent, - val onClick: () -> Unit, - ) - - @Composable - private fun LibraryDetailPopupFrame( - title: String, - onDismissRequest: () -> Unit, - wide: Boolean = false, - content: @Composable ColumnScope.() -> Unit, - ) { - val dismissInteractionSource = remember { MutableInteractionSource() } - val panelInteractionSource = remember { MutableInteractionSource() } - val registry = remember { PaneNavRegistry() } - - CompositionLocalProvider(LocalPaneNav provides registry) { - DialogPaneNav(registry, onDismiss = onDismissRequest) - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.58f)) - .clickable( - interactionSource = dismissInteractionSource, - indication = null, - onClick = onDismissRequest, - ), - ) { - BoxWithConstraints( - modifier = - Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - val panelMaxWidth = if (wide) 440.dp else 360.dp - val panelWidthFraction = if (wide) 0.72f else 0.58f - val panelMaxHeight = (maxHeight - 16.dp).coerceAtLeast(240.dp) - - Surface( - modifier = - Modifier - .fillMaxWidth(panelWidthFraction) - .widthIn(max = panelMaxWidth) - .heightIn(max = panelMaxHeight) - .clickable( - interactionSource = panelInteractionSource, - indication = null, - onClick = {}, - ), - shape = RoundedCornerShape(16.dp), - color = CardDark, - border = BorderStroke(1.dp, CardBorder), - tonalElevation = 8.dp, - shadowElevation = 12.dp, - ) { - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontSize = 13.sp, - color = TextPrimary, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = onDismissRequest, - modifier = - Modifier - .size(34.dp) - .paneNavItem(cornerRadius = 8.dp, onActivate = onDismissRequest), - ) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.common_ui_close), - tint = TextSecondary, - modifier = Modifier.size(20.dp), - ) - } - } - HorizontalDivider(color = CardBorder, thickness = 0.5.dp) - Column( - modifier = - Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - ) { - content() - } - } - } - } - } - } - } - - @Composable - private fun GameSettingsDialogFrame( - title: String, - onDismissRequest: () -> Unit, - wide: Boolean = false, - contentKey: Any? = null, - content: @Composable ColumnScope.() -> Unit, - ) { - val registry = remember { PaneNavRegistry() } - LaunchedEffect(contentKey) { registry.reset() } - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - CompositionLocalProvider(LocalPaneNav provides registry) { - DialogPaneNav(registry, onDismiss = onDismissRequest) - BoxWithConstraints( - modifier = - Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars), - contentAlignment = Alignment.Center, - ) { - val widthModifier = - if (wide) { - Modifier.widthIn(min = 320.dp, max = (maxWidth - 32.dp).coerceAtMost(560.dp)) - } else { - Modifier.widthIn(min = 200.dp, max = 280.dp) - } - val maxContentHeight = (maxHeight - 48.dp).coerceAtLeast(320.dp) - Surface( - modifier = widthModifier.heightIn(max = maxContentHeight), - shape = RoundedCornerShape(14.dp), - color = CardDark, - border = BorderStroke(1.dp, CardBorder), - tonalElevation = 8.dp, - ) { - Column( - modifier = - Modifier - .padding(vertical = 6.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - modifier = Modifier - .weight(1f) - .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), - style = MaterialTheme.typography.titleSmall, - color = TextPrimary, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - IconButton( - onClick = onDismissRequest, - modifier = Modifier - .padding(end = 4.dp) - .size(34.dp) - .paneNavItem(cornerRadius = 17.dp, onActivate = onDismissRequest, pinTop = true), - ) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.common_ui_close), - tint = TextSecondary, - modifier = Modifier.size(20.dp), - ) - } - } - HorizontalDivider(color = CardBorder, thickness = 0.5.dp) - Column( - modifier = - Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - ) { - content() - } - } - } - } - } - } - } - - @Composable - private fun GameSettingsActionGrid( - actions: List, - modifier: Modifier = Modifier, - ) { - Column(modifier = modifier) { - actions.forEachIndexed { index, action -> - if (index > 0) { - HorizontalDivider( - color = CardBorder.copy(alpha = 0.5f), - thickness = 0.5.dp, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - GameSettingsActionCard(action = action, isEntry = index == 0) - } - } - } - - @Composable - private fun GameSettingsActionCard( - action: GameSettingsActionItem, - modifier: Modifier = Modifier, - isEntry: Boolean = false, - ) { - val isDanger = action.accentColor == DangerRed - val iconColor = if (isDanger) DangerRed else TextSecondary - val textColor = if (isDanger) DangerRed else TextPrimary - - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.96f else 1f, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - label = "actionCardScale", - ) - Row( - modifier = - modifier - .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - }.paneNavItem(cornerRadius = 0.dp, onActivate = action.onClick, isEntry = isEntry) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = action.onClick, - ).padding(horizontal = 16.dp, vertical = 11.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = action.icon, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(18.dp), - ) - Text( - text = action.title, - style = MaterialTheme.typography.bodyMedium, - color = textColor, - fontWeight = FontWeight.Medium, - maxLines = 1, - ) - } - } - - @Composable - private fun GameSettingsInfoCard( - message: String, - accentColor: Color = Accent, - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = Icons.Outlined.Warning, - contentDescription = null, - tint = accentColor.copy(alpha = 0.7f), - modifier = Modifier.size(18.dp), - ) - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - lineHeight = 18.sp, - textAlign = TextAlign.Center, - ) - } - } - - /** - * Shared uninstall/remove confirmation UI used by GameSettingsDialog, - * GOGGameSettingsDialog, and LibraryGameDetailDialog. - */ - @Composable - private fun UninstallConfirmation( - message: String, - confirmLabel: String = stringResource(R.string.common_ui_uninstall), - onConfirm: () -> Unit, - onCancel: () -> Unit, - ) { - var isUninstalling by remember { mutableStateOf(false) } - - GameSettingsInfoCard(message = message, accentColor = DangerRed) - - if (isUninstalling) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = DangerRed) - } - } else { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = { - isUninstalling = true - onConfirm() - }, - modifier = Modifier.paneNavItem( - cornerRadius = 8.dp, - onActivate = { isUninstalling = true; onConfirm() }, - isEntry = true, - ), - border = BorderStroke(1.dp, DangerRed.copy(alpha = 0.5f)), - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed), - ) { - Text( - confirmLabel, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - } - Spacer(Modifier.width(8.dp)) - TextButton( - onClick = onCancel, - modifier = Modifier.paneNavItem(cornerRadius = 8.dp, onActivate = onCancel), - ) { - Text(stringResource(R.string.common_ui_cancel), color = TextSecondary, style = MaterialTheme.typography.bodySmall) - } - } - } - } - - @Composable - private fun ShortcutRemovalConfirmation( - message: String, - onConfirm: () -> Unit, - onCancel: () -> Unit, - ) { - var isRemoving by remember { mutableStateOf(false) } - - GameSettingsInfoCard(message = message, accentColor = DangerRed) - - if (isRemoving) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = DangerRed) - } - } else { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = { - isRemoving = true - onConfirm() - }, - modifier = Modifier.paneNavItem( - cornerRadius = 8.dp, - onActivate = { isRemoving = true; onConfirm() }, - isEntry = true, - ), - border = BorderStroke(1.dp, DangerRed.copy(alpha = 0.5f)), - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed), - ) { - Text( - stringResource(R.string.common_ui_remove), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - } - Spacer(Modifier.width(8.dp)) - TextButton( - onClick = onCancel, - modifier = Modifier.paneNavItem(cornerRadius = 8.dp, onActivate = onCancel), - ) { - Text(stringResource(R.string.common_ui_cancel), color = TextSecondary, style = MaterialTheme.typography.bodySmall) - } - } - } - } - - @Composable - private fun HeroLaunchConfirmFooter( - onCancel: () -> Unit, - onContinue: () -> Unit, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - PaneFooterAction( - label = stringResource(R.string.common_ui_cancel), - textColor = DangerRed, - onClick = onCancel, - ) - PaneFooterAction( - label = stringResource(R.string.common_ui_continue), - textColor = StatusOnline, - onClick = onContinue, - isEntry = true, - ) - } - } - - @Composable - private fun PaneFooterAction( - label: String, - textColor: Color, - onClick: () -> Unit, - isEntry: Boolean = false, - ) { - Box( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .paneNavItem( - cornerRadius = 8.dp, - onActivate = onClick, - tapToSelect = true, - isEntry = isEntry, - ).padding(horizontal = 10.dp, vertical = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = textColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) - } - } - - @Composable - private fun HeroBootDialog( - onConfirm: (HeroBootChoice) -> Unit, - onDismissRequest: () -> Unit, - ) { - var choice by remember { mutableStateOf(HeroBootChoice.Desktop) } - val graphicsTest = stringResource(R.string.hero_graphics_tests_title) - val test32 = graphicsTest + " " + stringResource(R.string.hero_graphics_test_32) - val test64 = graphicsTest + " " + stringResource(R.string.hero_graphics_test_64) - val title = - when (choice) { - HeroBootChoice.Desktop -> stringResource(R.string.hero_boot_to_desktop_title) - HeroBootChoice.Cube32 -> test32 - HeroBootChoice.Cube64 -> test64 - } - val registry = remember { PaneNavRegistry() } - Dialog(onDismissRequest = onDismissRequest) { - CompositionLocalProvider(LocalPaneNav provides registry) { - DialogPaneNav(registry, onDismiss = onDismissRequest, onStart = { onConfirm(choice) }) - PopupDialog( - title = title, - icon = Icons.Outlined.DesktopWindows, - accentColor = Accent, - modifier = Modifier.widthIn(min = 220.dp, max = 290.dp), - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - HeroBootOptionRow( - label = stringResource(R.string.hero_boot_to_desktop_title), - selected = choice == HeroBootChoice.Desktop, - onClick = { choice = HeroBootChoice.Desktop }, - ) - HeroBootOptionRow( - label = test32, - selected = choice == HeroBootChoice.Cube32, - onClick = { choice = HeroBootChoice.Cube32 }, - ) - HeroBootOptionRow( - label = test64, - selected = choice == HeroBootChoice.Cube64, - onClick = { choice = HeroBootChoice.Cube64 }, - ) - } - }, - footer = { - HeroLaunchConfirmFooter(onCancel = onDismissRequest, onContinue = { onConfirm(choice) }) - }, - ) - } - } - } - - @Composable - private fun HeroBootOptionRow( - label: String, - selected: Boolean, - onClick: () -> Unit, - ) { - val glassBlue = Accent - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(glassBlue.copy(alpha = if (selected) 0.26f else 0.05f)) - .border(1.dp, glassBlue.copy(alpha = if (selected) 0.65f else 0.12f), RoundedCornerShape(8.dp)) - .paneNavItem(cornerRadius = 8.dp, onActivate = onClick, tapToSelect = true) - .padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = if (selected) Color.White else glassBlue.copy(alpha = 0.5f), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - ) - } - } - - @Composable - private fun HeroRemoveShortcutDialog( - gameName: String, - onConfirm: () -> Unit, - onDismissRequest: () -> Unit, - ) { - val registry = remember { PaneNavRegistry() } - var isRemoving by remember { mutableStateOf(false) } - Dialog(onDismissRequest = onDismissRequest) { - CompositionLocalProvider(LocalPaneNav provides registry) { - DialogPaneNav(registry, onDismiss = onDismissRequest) - PopupDialog( - title = stringResource(R.string.common_ui_shortcut), - message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, gameName), - icon = Icons.Outlined.Home, - accentColor = DangerRed, - confirmButtonColor = DangerRed, - progressLabel = stringResource(R.string.common_ui_working), - modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), - footer = { - if (isRemoving) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator(color = DangerRed, strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) - Text( - stringResource(R.string.common_ui_working), - color = TextPrimary, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - PaneFooterAction( - label = stringResource(R.string.common_ui_cancel), - textColor = TextSecondary, - onClick = onDismissRequest, - ) - PaneFooterAction( - label = stringResource(R.string.common_ui_remove), - textColor = DangerRed, - onClick = { - isRemoving = true - onConfirm() - }, - isEntry = true, - ) - } - } - }, - ) - } - } - } - - @Composable - private fun GameSettingsDialog( - app: SteamApp, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - var currentTab by remember { mutableStateOf(GameSettingsScreen.Menu) } - val scope = rememberCoroutineScope() - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val epicId = if (isEpic) app.id - 2000000000 else 0 - var shortcutRefreshKey by remember(app.id, isCustom, isEpic, epicId) { mutableStateOf(0) } - var pinnedShortcutOverride by remember(app.id, isCustom, isEpic, epicId) { mutableStateOf(null) } - val epicArtworkUrl by produceState(initialValue = null, key1 = isEpic, key2 = epicId) { - value = - if (isEpic) { - val epicGame = db.epicGameDao().getById(epicId) - epicGame?.primaryImageUrl ?: epicGame?.iconUrl - } else { - null - } - } - val currentRefreshSignal = this@UnifiedActivity.libraryRefreshSignal - val homeShortcutState by produceState( - HomeShortcutUiState(), - app.id, - isCustom, - isEpic, - epicId, - currentRefreshSignal, - shortcutRefreshKey, - ) { - value = - withContext(Dispatchers.IO) { - val shortcut = findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) - HomeShortcutUiState( - shortcut = shortcut, - isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, - ) - } - } - val artworkRefreshListener = - remember(app.id, isCustom, isEpic, epicId) { - object : EventDispatcher.JavaEventListener { - override fun onEvent(event: Any) { - if (event is AndroidEvent.LibraryArtworkChanged) { - shortcutRefreshKey++ - } - } - } - } - DisposableEffect(artworkRefreshListener) { - PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) - onDispose { - PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) - } - } - val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned - - val exportLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> - if (uri != null) { - scope.launch(kotlinx.coroutines.Dispatchers.IO) { - try { - val os = context.contentResolver.openOutputStream(uri) ?: return@launch - val zos = java.util.zip.ZipOutputStream(java.io.BufferedOutputStream(os)) - - val containerManager = - com.winlator.cmod.runtime.container - .ContainerManager(context) - val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - - val dirsToZip = mutableListOf() - - val goldbergSaves = java.io.File(SteamService.getAppDirPath(app.id), "steam_settings/saves") - if (goldbergSaves.exists() && goldbergSaves.isDirectory) { - dirsToZip.add(goldbergSaves) - } - - if (shortcut != null) { - val prefixDir = java.io.File(shortcut.container.getRootDir(), ".wine/drive_c/users/xuser") - val docs = java.io.File(prefixDir, "Documents") - val savedGames = java.io.File(prefixDir, "Saved Games") - val appData = java.io.File(prefixDir, "AppData") - if (docs.exists()) dirsToZip.add(docs) - if (savedGames.exists()) dirsToZip.add(savedGames) - if (appData.exists()) dirsToZip.add(appData) - } - - fun zipDir( - dir: java.io.File, - baseName: String, - ) { - val children = dir.listFiles() ?: return - for (child in children) { - val name = if (baseName.isEmpty()) child.name else "$baseName/${child.name}" - if (child.isDirectory) { - zos.putNextEntry(java.util.zip.ZipEntry("$name/")) - zos.closeEntry() - zipDir(child, name) - } else { - zos.putNextEntry(java.util.zip.ZipEntry(name)) - val fis = java.io.FileInputStream(child) - val buf = ByteArray(1024 * 8) - var len: Int - while (fis.read(buf).also { len = it } > 0) { - zos.write(buf, 0, len) - } - fis.close() - zos.closeEntry() - } - } - } - - for (dir in dirsToZip) { - val baseName = dir.name - zos.putNextEntry(java.util.zip.ZipEntry("$baseName/")) - zos.closeEntry() - zipDir(dir, baseName) - } - - zos.close() - withContext(kotlinx.coroutines.Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.saves_import_export_exported, - android.widget.Toast.LENGTH_SHORT, - ) - onDismissRequest() - } - } catch (e: Exception) { - e.printStackTrace() - withContext(kotlinx.coroutines.Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.saves_import_export_exported_failed, e.message), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - } - } - - val importLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - scope.launch(kotlinx.coroutines.Dispatchers.IO) { - try { - val `is` = context.contentResolver.openInputStream(uri) ?: return@launch - val zis = java.util.zip.ZipInputStream(java.io.BufferedInputStream(`is`)) - - val containerManager = - com.winlator.cmod.runtime.container - .ContainerManager(context) - val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - - val goldbergSavesParent = - java.io.File( - if (isEpic) app.gameDir else SteamService.getAppDirPath(app.id), - if (isEpic) "" else "steam_settings", - ) - val prefixDir = shortcut?.let { java.io.File(it.container.getRootDir(), ".wine/drive_c/users/xuser") } - - var ze: java.util.zip.ZipEntry? - while (zis.nextEntry.also { ze = it } != null) { - val entry = ze!! - val name = entry.name - var destFile: java.io.File? = null - if (name.startsWith("saves/")) { - destFile = java.io.File(goldbergSavesParent, name) - } else if (prefixDir != null) { - if (name.startsWith("Documents/") || name.startsWith("Saved Games/") || name.startsWith("AppData/")) { - destFile = java.io.File(prefixDir, name) - } - } - - if (destFile != null) { - if (entry.isDirectory) { - destFile.mkdirs() - } else { - destFile.parentFile?.mkdirs() - val fos = java.io.FileOutputStream(destFile) - val buf = ByteArray(1024 * 8) - var len: Int - while (zis.read(buf).also { len = it } > 0) { - fos.write(buf, 0, len) - } - fos.close() - } - } - zis.closeEntry() - } - zis.close() - withContext(kotlinx.coroutines.Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.saves_import_export_imported, - android.widget.Toast.LENGTH_SHORT, - ) - onDismissRequest() - } - } catch (e: Exception) { - e.printStackTrace() - withContext(kotlinx.coroutines.Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.saves_import_export_imported_failed, e.message), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - } - } - - GameSettingsDialogFrame( - title = app.name, - onDismissRequest = onDismissRequest, - wide = currentTab == GameSettingsScreen.CloudSaves, - contentKey = currentTab, - ) { - when (currentTab) { - GameSettingsScreen.Menu -> { - val actions = - listOf( - GameSettingsActionItem( - title = stringResource(R.string.common_ui_settings), - icon = Icons.Outlined.Settings, - onClick = { - val containerManager = ContainerManager(context) - val shortcut = - findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - ?: if (isCustom) { - null - } else { - ShortcutSettingsComposeDialog.createLibraryShortcut( - context = context, - containerManager = containerManager, - source = if (isEpic) "EPIC" else "STEAM", - appId = if (isEpic) epicId else app.id, - gogId = null, - appName = app.name, - ) - } - if (shortcut != null) { - ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() - } - onDismissRequest() - }, - ), - GameSettingsActionItem( - title = stringResource(R.string.hero_boot_to_desktop_title), - icon = Icons.Outlined.DesktopWindows, - onClick = { - val shortcut = - findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) - if (shortcut != null) { - context.startActivity( - Intent(context, XServerDisplayActivity::class.java) - .putExtra("container_id", shortcut.container.id), - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show(context, R.string.shortcuts_list_not_available) - } - onDismissRequest() - }, - ), - GameSettingsActionItem( - title = - stringResource( - if (hasPinnedShortcut) { - R.string.common_ui_remove - } else { - R.string.common_ui_shortcut - }, - ), - icon = Icons.Outlined.Home, - accentColor = if (hasPinnedShortcut) DangerRed else Accent, - onClick = { - if (hasPinnedShortcut) { - currentTab = GameSettingsScreen.Shortcut - } else { - scope.launch { - val created = - withContext(Dispatchers.IO) { - addLibraryShortcutToHomeScreen( - context, - app, - isCustom, - isEpic, - epicId, - epicArtworkUrl, - ) - } - if (!created) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - context.getString( - R.string.library_games_failed_to_create_shortcut, - app.name, - ), - ) - } - } - } - }, - ), - GameSettingsActionItem( - title = stringResource(R.string.cloud_saves_title), - icon = Icons.Outlined.CloudSync, - onClick = { currentTab = GameSettingsScreen.CloudSaves }, - ), - - GameSettingsActionItem( - title = - if (isCustom) { - stringResource( - R.string.common_ui_remove, - ) - } else { - stringResource(R.string.common_ui_uninstall) - }, - icon = Icons.Outlined.Delete, - accentColor = DangerRed, - onClick = { currentTab = GameSettingsScreen.Uninstall }, - ), - ) - - GameSettingsActionGrid(actions = actions) - } - - GameSettingsScreen.Shortcut -> { - ShortcutRemovalConfirmation( - message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, app.name), - onConfirm = { - scope.launch { - val removed = - withContext(Dispatchers.IO) { - homeShortcutState.shortcut?.let { - LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) - } == true - } - pinnedShortcutOverride = if (removed) false else hasPinnedShortcut - shortcutRefreshKey++ - currentTab = GameSettingsScreen.Menu - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (removed) { - context.getString(R.string.shortcuts_list_removed) - } else { - context.getString(R.string.common_ui_unknown_error) - }, - ) - } - }, - onCancel = { currentTab = GameSettingsScreen.Menu }, - ) - } - - GameSettingsScreen.CloudSaves -> { - var isWorking by remember { mutableStateOf(false) } - val shortcut = - remember(app.id, epicId, isCustom, isEpic) { - findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) - } - var cloudSyncEnabled by remember(shortcut?.file?.absolutePath) { - mutableStateOf(isShortcutCloudSyncEnabled(shortcut)) - } - var offlineModeEnabled by remember(shortcut?.file?.absolutePath) { - mutableStateOf(isShortcutOfflineMode(shortcut)) - } - - val gameSource = - when { - isEpic -> GameSaveBackupManager.GameSource.EPIC - isCustom -> GameSaveBackupManager.GameSource.CUSTOM - else -> GameSaveBackupManager.GameSource.STEAM - } - val gameIdStr = - when { - isEpic -> epicId.toString() - isCustom -> shortcut?.let { GameSaveBackupManager.customGameId(it) } ?: app.name - else -> app.id.toString() - } - val providerLabel = - when (gameSource) { - GameSaveBackupManager.GameSource.EPIC -> - stringResource(R.string.preloader_platform_epic) - GameSaveBackupManager.GameSource.CUSTOM -> - stringResource(R.string.preloader_platform_custom) - else -> - stringResource(R.string.preloader_platform_steam) - } - - CloudSavesContent( - activity = this@UnifiedActivity, - isWorking = isWorking, - cloudSyncEnabled = cloudSyncEnabled, - offlineModeEnabled = offlineModeEnabled, - gameSource = gameSource, - gameId = gameIdStr, - gameName = app.name, - shortcut = shortcut, - onCloudSyncToggle = { enabled -> - cloudSyncEnabled = enabled - setShortcutCloudSyncEnabled(shortcut, enabled) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (enabled) { - context.getString(R.string.cloud_sync_enabled_summary) - } else { - context.getString(R.string.cloud_sync_disabled_summary) - }, - android.widget.Toast.LENGTH_SHORT, - ) - }, - onOfflineModeToggle = { enabled -> - offlineModeEnabled = enabled - setShortcutOfflineMode(shortcut, enabled) - }, - onSyncFromCloud = { - if (!isWorking) { - isWorking = true - scope.launch(Dispatchers.IO) { - val ok = - CloudSyncHelper.downloadCloudSaves( - context, - gameSource, - gameIdStr, - shortcut, - ) - withContext(Dispatchers.Main) { - isWorking = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (ok) { - context.getString( - R.string.cloud_saves_sync_from_provider_success, - providerLabel, - ) - } else { - context.getString( - R.string.cloud_saves_sync_from_provider_failed, - providerLabel, - ) - }, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onBack = { currentTab = GameSettingsScreen.Menu }, - ) - } - - GameSettingsScreen.Uninstall -> { - UninstallConfirmation( - message = - if (isCustom) { - getString(R.string.library_games_remove_confirm, app.name) - } else { - getString(R.string.library_games_uninstall_confirm, app.name) - }, - confirmLabel = - if (isCustom) { - stringResource( - R.string.common_ui_remove, - ) - } else { - stringResource(R.string.common_ui_uninstall) - }, - onConfirm = { - if (isCustom) { - scope.launch(Dispatchers.IO) { - val cm = ContainerManager(context) - val sc = findLibraryShortcutForGame(cm, app, isCustom, isEpic, epicId) - sc?.let { LibraryShortcutUtils.deleteShortcutArtifacts(context, it) } - PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(app.id)) - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_removed, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - onDismissRequest() - } - } - } else if (isEpic) { - scope.launch(Dispatchers.IO) { - val result = EpicService.deleteGame(context, epicId) - withContext(Dispatchers.Main) { - if (result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message - ?: getString(R.string.common_ui_unknown_error), - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - } else { - SteamService.uninstallApp(app.id) { success -> - if (success) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_failed_to_uninstall), - android.widget.Toast.LENGTH_SHORT, - ) - } - onDismissRequest() - } - } - }, - onCancel = { currentTab = GameSettingsScreen.Menu }, - ) - } - } - } - } - - @Composable - private fun GOGGameSettingsDialog( - app: GOGGame, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - var currentTab by remember { mutableStateOf(GameSettingsScreen.Menu) } - val scope = rememberCoroutineScope() - var shortcutRefreshKey by remember(app.id) { mutableStateOf(0) } - var pinnedShortcutOverride by remember(app.id) { mutableStateOf(null) } - val currentRefreshSignal = this@UnifiedActivity.libraryRefreshSignal - val homeShortcutState by produceState( - HomeShortcutUiState(), - app.id, - currentRefreshSignal, - shortcutRefreshKey, - ) { - value = - withContext(Dispatchers.IO) { - val shortcut = - ContainerManager(context).loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id - } - HomeShortcutUiState( - shortcut = shortcut, - isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, - ) - } - } - val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned - - GameSettingsDialogFrame( - title = app.title, - onDismissRequest = onDismissRequest, - wide = currentTab == GameSettingsScreen.CloudSaves, - contentKey = currentTab, - ) { - when (currentTab) { - GameSettingsScreen.Menu -> { - GameSettingsActionGrid( - actions = - listOf( - GameSettingsActionItem( - title = stringResource(R.string.common_ui_settings), - icon = Icons.Outlined.Settings, - onClick = { - val containerManager = ContainerManager(context) - val shortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id - } ?: ShortcutSettingsComposeDialog.createLibraryShortcut( - context = context, - containerManager = containerManager, - source = "GOG", - appId = gogPseudoId(app.id), - gogId = app.id, - appName = app.title, - ) - if (shortcut != null) { - ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() - } - onDismissRequest() - }, - ), - GameSettingsActionItem( - title = - stringResource( - if (hasPinnedShortcut) { - R.string.common_ui_remove - } else { - R.string.common_ui_shortcut - }, - ), - icon = Icons.Outlined.Home, - accentColor = if (hasPinnedShortcut) DangerRed else Accent, - onClick = { - if (hasPinnedShortcut) { - currentTab = GameSettingsScreen.Shortcut - } else { - scope.launch { - val artworkUrl = app.imageUrl.ifEmpty { app.iconUrl } - val created = - withContext(Dispatchers.IO) { - addGogShortcutToHomeScreen(context, app, artworkUrl) - } - if (!created) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - context.getString( - R.string.library_games_failed_to_create_shortcut, - app.title, - ), - ) - } - } - } - }, - ), - GameSettingsActionItem( - title = stringResource(R.string.cloud_saves_title), - icon = Icons.Outlined.CloudSync, - onClick = { currentTab = GameSettingsScreen.CloudSaves }, - ), - GameSettingsActionItem( - title = stringResource(R.string.common_ui_uninstall), - icon = Icons.Outlined.Delete, - accentColor = DangerRed, - onClick = { currentTab = GameSettingsScreen.Uninstall }, - ), - ), - ) - } - - GameSettingsScreen.Shortcut -> { - ShortcutRemovalConfirmation( - message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, app.title), - onConfirm = { - scope.launch { - val removed = - withContext(Dispatchers.IO) { - homeShortcutState.shortcut?.let { - LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) - } == true - } - pinnedShortcutOverride = if (removed) false else hasPinnedShortcut - shortcutRefreshKey++ - currentTab = GameSettingsScreen.Menu - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (removed) { - context.getString(R.string.shortcuts_list_removed) - } else { - context.getString(R.string.common_ui_unknown_error) - }, - android.widget.Toast.LENGTH_SHORT, - ) - } - }, - onCancel = { currentTab = GameSettingsScreen.Menu }, - ) - } - - GameSettingsScreen.CloudSaves -> { - var isWorking by remember { mutableStateOf(false) } - val shortcut = - remember(app.id) { - ContainerManager(context).loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id - } - } - var cloudSyncEnabled by remember(shortcut?.file?.absolutePath) { - mutableStateOf(isShortcutCloudSyncEnabled(shortcut)) - } - var offlineModeEnabled by remember(shortcut?.file?.absolutePath) { - mutableStateOf(isShortcutOfflineMode(shortcut)) - } - - val gogProviderLabel = stringResource(R.string.preloader_platform_gog) - - CloudSavesContent( - activity = this@UnifiedActivity, - isWorking = isWorking, - cloudSyncEnabled = cloudSyncEnabled, - offlineModeEnabled = offlineModeEnabled, - gameSource = GameSaveBackupManager.GameSource.GOG, - gameId = app.id, - gameName = app.title, - shortcut = shortcut, - onCloudSyncToggle = { enabled -> - cloudSyncEnabled = enabled - setShortcutCloudSyncEnabled(shortcut, enabled) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (enabled) { - context.getString(R.string.cloud_sync_enabled_summary) - } else { - context.getString(R.string.cloud_sync_disabled_summary) - }, - android.widget.Toast.LENGTH_SHORT, - ) - }, - onOfflineModeToggle = { enabled -> - offlineModeEnabled = enabled - setShortcutOfflineMode(shortcut, enabled) - }, - onSyncFromCloud = { - if (!isWorking) { - isWorking = true - scope.launch(Dispatchers.IO) { - val ok = - CloudSyncHelper.downloadCloudSaves( - context, - GameSaveBackupManager.GameSource.GOG, - app.id, - shortcut, - ) - withContext(Dispatchers.Main) { - isWorking = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (ok) { - context.getString( - R.string.cloud_saves_sync_from_provider_success, - gogProviderLabel, - ) - } else { - context.getString( - R.string.cloud_saves_sync_from_provider_failed, - gogProviderLabel, - ) - }, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onBack = { currentTab = GameSettingsScreen.Menu }, - ) - } - - GameSettingsScreen.Uninstall -> { - UninstallConfirmation( - message = getString(R.string.library_games_uninstall_confirm, app.title), - onConfirm = { - scope.launch(Dispatchers.IO) { - val result = GOGService.deleteGame( - context, - LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG), - ) - withContext(Dispatchers.Main) { - if (result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.title), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message - ?: getString(R.string.common_ui_unknown_error), - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - }, - onCancel = { currentTab = GameSettingsScreen.Menu }, - ) - } - } - } - } - - // Library Game Detail Dialog - - private enum class LibraryDetailScreen { Main, Shortcut, CloudSaves, Uninstall } - - private enum class LibraryDetailPopup { CloudSaves } - - private enum class HeroLaunchPopup { BootToDesktop, RemoveShortcut } - - private enum class HeroBootChoice { Desktop, Cube32, Cube64 } - - @Composable - private fun LibraryGameDetailDialog( - app: SteamApp, - gogGame: GOGGame? = null, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var currentScreen by remember { mutableStateOf(LibraryDetailScreen.Main) } - var activePopup by remember { mutableStateOf(null) } - var showAchievements by remember(app.id) { mutableStateOf(false) } - var shortcutRefreshKey by remember(app.id, gogGame?.id) { mutableStateOf(0) } - var pinnedShortcutOverride by remember(app.id, gogGame?.id) { mutableStateOf(null) } - var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } - - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val isGog = gogGame != null - val epicId = if (isEpic) app.id - 2000000000 else 0 - - val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( - initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), - ) - val hasBlockingSteamDownloadForLibrary = - !isCustom && !isEpic && !isGog && - libraryDownloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && - it.storeGameId == app.id.toString() && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - val hasBlockingEpicDownloadForLibrary = - isEpic && - libraryDownloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_EPIC && - it.storeGameId == epicId.toString() && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - val hasBlockingGogDownloadForLibrary = - isGog && - libraryDownloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_GOG && - it.storeGameId == gogGame?.id && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - - val epicGame by produceState(initialValue = null, key1 = epicId) { - value = if (isEpic) db.epicGameDao().getById(epicId) else null - } - - val epicArtworkUrl by produceState(initialValue = null, key1 = isEpic, key2 = epicId) { - value = - if (isEpic) { - val eg = db.epicGameDao().getById(epicId) - eg?.primaryImageUrl ?: eg?.iconUrl - } else { - null - } - } - val currentRefreshSignal = this@UnifiedActivity.libraryRefreshSignal - val homeShortcutState by produceState( - HomeShortcutUiState(), - app.id, - gogGame?.id, - isCustom, - isEpic, - isGog, - epicId, - currentRefreshSignal, - shortcutRefreshKey, - ) { - value = - withContext(Dispatchers.IO) { - val shortcut = - when { - isGog -> { - ContainerManager(context).loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id - } - } - - else -> { - findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) - } - } - HomeShortcutUiState( - shortcut = shortcut, - isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, - ) - } - } - val artworkRefreshListener = - remember(app.id, gogGame?.id) { - object : EventDispatcher.JavaEventListener { - override fun onEvent(event: Any) { - if (event is AndroidEvent.LibraryArtworkChanged) { - shortcutRefreshKey++ - } - } - } - } - DisposableEffect(artworkRefreshListener) { - PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) - onDispose { - PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) - } - } - val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned - - BackHandler(enabled = activePopup != null) { - activePopup = null - } - - // Hero image - val customHeroImageFile = - homeShortcutState.shortcut - ?.getExtra("customLibraryHeroArtPath") - ?.takeIf { it.isNotBlank() } - ?.let { java.io.File(it) } - ?.takeIf { it.exists() } - val customHeroImageCacheKey = - customHeroImageFile?.let { - "library_custom_hero:${it.absolutePath}:${it.lastModified()}" - } - val heroImageUrl: Any? = - customHeroImageFile ?: when { - isGog -> { - StoreArtworkCache.imageModel(context, StoreArtworkCache.gogHeroRef(gogGame!!)) - } - - isEpic -> { - epicGame?.let { StoreArtworkCache.imageModel(context, StoreArtworkCache.epicHeroRef(it)) } - } - - isCustom -> { - val customCoverArt = - homeShortcutState.shortcut - ?.getExtra("customCoverArtPath") - ?.takeIf { it.isNotBlank() } - ?.let { java.io.File(it) } - ?.takeIf { it.exists() } - customCoverArt ?: run { - val safeName = app.name.replace("/", "_").replace("\\", "_") - val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") - if (iconFile.exists()) iconFile else null - } - } - - else -> { - val heroUrl = app.getHeroUrl() - StoreArtworkCache.imageModel(context, StoreArtworkCache.steamRef(app, "hero", heroUrl)) - } - } - - val subtitle = - when { - isGog -> { - gogGame!!.developer - } - - isCustom -> { - stringResource(R.string.library_games_custom_game) - } - - isEpic -> { - epicGame?.developer ?: "" - } - - else -> { - listOfNotNull( - app.developer.takeIf { it.isNotBlank() }, - app.publisher.takeIf { it.isNotBlank() }, - ).distinctBy { it.trim().lowercase() }.joinToString(" • ") - } - } - - // Playtime info - val playtimePrefs = - remember { - context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) - } - val searchKey = - remember(app) { - if (app.id >= 2000000000 || app.id < 0) { - app.name - } else { - app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") - } - } - val lastPlayed = playtimePrefs.getLong("${searchKey}_last_played", 0L) - val totalPlaytime = playtimePrefs.getLong("${searchKey}_playtime", 0L) - val playCount = playtimePrefs.getInt("${searchKey}_play_count", 0) - - val sourceLabel = - when { - isGog -> "GOG" - isEpic -> "Epic Games" - isCustom -> "Custom" - else -> "Steam" - } - - // Install path - val installPath = - remember(app, gogGame) { - when { - isGog -> { - gogGame!!.installPath - } - - isEpic -> { - epicGame?.installPath ?: "" - } - - isCustom -> { - app.gameDir - } - - else -> { - try { - SteamService.getAppDirPath(app.id) - } catch (_: Exception) { - "" - } - } - } - } - - // Install size (computed async) - val installSizeText by produceState(initialValue = null, key1 = installPath) { - value = - if (installPath.isNotBlank()) { - withContext(Dispatchers.IO) { - try { - val bytes = StorageUtils.getFolderSize(installPath) - if (bytes > 0) StorageUtils.formatBinarySize(bytes) else null - } catch (_: Exception) { - null - } - } - } else { - null - } - } - - // Export / Import launchers (reuse GameSettingsDialog pattern) - - val exportLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> - if (uri != null) { - scope.launch(Dispatchers.IO) { - try { - val os = context.contentResolver.openOutputStream(uri) ?: return@launch - val zos = java.util.zip.ZipOutputStream(java.io.BufferedOutputStream(os)) - val containerManager = ContainerManager(context) - val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - val dirsToZip = mutableListOf() - val goldbergSaves = java.io.File(SteamService.getAppDirPath(app.id), "steam_settings/saves") - if (goldbergSaves.exists() && goldbergSaves.isDirectory) dirsToZip.add(goldbergSaves) - if (shortcut != null) { - val prefixDir = java.io.File(shortcut.container.getRootDir(), ".wine/drive_c/users/xuser") - listOf("Documents", "Saved Games", "AppData").forEach { name -> - val dir = java.io.File(prefixDir, name) - if (dir.exists()) dirsToZip.add(dir) - } - } - - fun zipDir( - dir: java.io.File, - baseName: String, - ) { - val children = dir.listFiles() ?: return - for (child in children) { - val name = if (baseName.isEmpty()) child.name else "$baseName/${child.name}" - if (child.isDirectory) { - zos.putNextEntry(java.util.zip.ZipEntry("$name/")) - zos.closeEntry() - zipDir(child, name) - } else { - zos.putNextEntry(java.util.zip.ZipEntry(name)) - child.inputStream().use { it.copyTo(zos) } - zos.closeEntry() - } - } - } - for (dir in dirsToZip) { - zos.putNextEntry(java.util.zip.ZipEntry("${dir.name}/")) - zos.closeEntry() - zipDir(dir, dir.name) - } - zos.close() - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.saves_import_export_exported, - android.widget.Toast.LENGTH_SHORT, - ) - } - } catch (e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.saves_import_export_exported_failed, e.message), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - } - } - - val importLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - scope.launch(Dispatchers.IO) { - try { - val inputStream = context.contentResolver.openInputStream(uri) ?: return@launch - val zis = java.util.zip.ZipInputStream(java.io.BufferedInputStream(inputStream)) - val containerManager = ContainerManager(context) - val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - val goldbergSavesParent = - java.io.File( - if (isEpic) app.gameDir else SteamService.getAppDirPath(app.id), - if (isEpic) "" else "steam_settings", - ) - val prefixDir = shortcut?.let { java.io.File(it.container.getRootDir(), ".wine/drive_c/users/xuser") } - var ze: java.util.zip.ZipEntry? - while (zis.nextEntry.also { ze = it } != null) { - val entry = ze!! - val name = entry.name - var destFile: java.io.File? = null - if (name.startsWith("saves/")) { - destFile = java.io.File(goldbergSavesParent, name) - } else if (prefixDir != null && - (name.startsWith("Documents/") || name.startsWith("Saved Games/") || name.startsWith("AppData/")) - ) { - destFile = java.io.File(prefixDir, name) - } - if (destFile != null) { - if (entry.isDirectory) { - destFile.mkdirs() - } else { - destFile.parentFile?.mkdirs() - java.io.FileOutputStream(destFile).use { fos -> zis.copyTo(fos) } - } - } - zis.closeEntry() - } - zis.close() - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.saves_import_export_imported, - android.widget.Toast.LENGTH_SHORT, - ) - } - } catch (e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.saves_import_export_imported_failed, e.message), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - } - } - - val uninstallGame: () -> Unit = { - if (isGog) { - scope.launch(Dispatchers.IO) { - val result = GOGService.deleteGame( - context, - LibraryItem( - "GOG_${gogGame!!.id}", - gogGame.title, - com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG, - ), - ) - withContext(Dispatchers.Main) { - if (result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message ?: getString(R.string.common_ui_unknown_error), - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - } else if (isCustom) { - scope.launch(Dispatchers.IO) { - val cm = ContainerManager(context) - val sc = findLibraryShortcutForGame(cm, app, isCustom, isEpic, epicId) - sc?.let { LibraryShortcutUtils.deleteShortcutArtifacts(context, it) } - java.io - .File( - context.filesDir, - "custom_icons/${app.name.replace("/", "_")}.png", - ).delete() - PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(app.id)) - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_removed, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - onDismissRequest() - } - } - } else if (isEpic) { - scope.launch(Dispatchers.IO) { - val result = EpicService.deleteGame(context, epicId) - withContext(Dispatchers.Main) { - if (result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message ?: "", - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - } else { - SteamService.uninstallApp(app.id) { success -> - if (success) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_game_uninstalled, app.name), - android.widget.Toast.LENGTH_SHORT, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.library_games_failed_to_uninstall), - android.widget.Toast.LENGTH_SHORT, - ) - } - onDismissRequest() - } - } - } - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - shape = RectangleShape, - color = Color.Black, - ) { - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - val showHero = currentScreen == LibraryDetailScreen.Main - val subScreenTitle = - when (currentScreen) { - LibraryDetailScreen.Shortcut -> stringResource(R.string.common_ui_shortcut) - LibraryDetailScreen.Uninstall -> - stringResource( - if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall, - ) - else -> "" - } - // Sub-screens get a compact title bar. The main launch view owns the full - // screen and draws artwork edge-to-edge in its content branch. - if (!showHero) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(SurfaceDark) - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = { currentScreen = LibraryDetailScreen.Main }) { - Icon( - Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.common_ui_back), - tint = TextPrimary, - ) - } - Text( - subScreenTitle, - style = MaterialTheme.typography.titleMedium, - color = TextPrimary, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f).padding(start = 4.dp), - ) - Text( - app.name, - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 16.dp), - ) - } - HorizontalDivider(color = CardBorder, thickness = 0.5.dp) - } - - // Bottom content - when (currentScreen) { - LibraryDetailScreen.Main -> { - // Lock Play while VERIFY / UPDATE is rewriting depots in place - // for this game — launching mid-write can corrupt the install. - val activePlayBlockingTask = - if (isCustom) { - null - } else if (isGog) { - val gogIdStr = gogGame!!.id - libraryDownloadRecords.firstOrNull { rec -> - rec.store == com.winlator.cmod.app.db.download - .DownloadRecord.STORE_GOG && - rec.storeGameId == gogIdStr && - rec.status == - com.winlator.cmod.app.db.download - .DownloadRecord.STATUS_DOWNLOADING && - ( - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_VERIFY || - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_UPDATE - ) - }?.taskType - } else if (isEpic) { - val appIdStr = epicId.toString() - libraryDownloadRecords.firstOrNull { rec -> - rec.store == com.winlator.cmod.app.db.download - .DownloadRecord.STORE_EPIC && - rec.storeGameId == appIdStr && - rec.status == - com.winlator.cmod.app.db.download - .DownloadRecord.STATUS_DOWNLOADING && - ( - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_VERIFY || - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_UPDATE - ) - }?.taskType - } else { - val appIdStr = app.id.toString() - libraryDownloadRecords.firstOrNull { rec -> - rec.store == com.winlator.cmod.app.db.download - .DownloadRecord.STORE_STEAM && - rec.storeGameId == appIdStr && - rec.status == - com.winlator.cmod.app.db.download - .DownloadRecord.STATUS_DOWNLOADING && - ( - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_VERIFY || - rec.taskType == - com.winlator.cmod.app.db.download - .DownloadRecord.TASK_UPDATE - ) - }?.taskType - } - val playEnabled = activePlayBlockingTask == null - val playDisabledLabel = - when (activePlayBlockingTask) { - com.winlator.cmod.app.db.download.DownloadRecord.TASK_VERIFY -> - stringResource(R.string.downloads_queue_phase_verifying) - com.winlator.cmod.app.db.download.DownloadRecord.TASK_UPDATE -> - stringResource(R.string.downloads_queue_phase_updating) - else -> null - } - val launchAppName = - when { - isEpic -> epicGame?.title?.takeIf { it.isNotBlank() } ?: app.name - isGog -> gogGame?.title?.takeIf { it.isNotBlank() } ?: app.name - else -> app.name - } - val heroToastAnchor = LocalView.current - var heroPopup by remember { mutableStateOf(null) } - var bootShortcut by remember { mutableStateOf(null) } - val resolveOrCreateShortcut: () -> com.winlator.cmod.runtime.container.Shortcut? = { - val containerManager = ContainerManager(context) - when { - isGog -> - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "GOG" && - it.getExtra("gog_id") == gogGame!!.id - } ?: ShortcutSettingsComposeDialog.createLibraryShortcut( - context = context, - containerManager = containerManager, - source = "GOG", - appId = gogPseudoId(gogGame!!.id), - gogId = gogGame.id, - appName = app.name, - ) - isCustom -> findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - else -> - findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - ?: ShortcutSettingsComposeDialog.createLibraryShortcut( - context = context, - containerManager = containerManager, - source = if (isEpic) "EPIC" else "STEAM", - appId = if (isEpic) epicId else app.id, - gogId = null, - appName = app.name, - ) - } - } - LibraryGameLaunchScreen( - appName = launchAppName, - subtitle = subtitle, - sourceLabel = sourceLabel, - heroImageUrl = heroImageUrl, - customHeroImageCacheKey = customHeroImageCacheKey, - releaseDateEpochSeconds = app.releaseDate, - totalPlaytimeMillis = totalPlaytime, - playCount = playCount, - lastPlayedMillis = lastPlayed, - installSizeText = installSizeText, - isCustom = isCustom, - hasPinnedShortcut = hasPinnedShortcut, - playEnabled = playEnabled, - playDisabledLabel = playDisabledLabel, - onBack = onDismissRequest, - onPlay = { - val containerManager = ContainerManager(context) - if (isCustom) { - launchCustomGame(context, containerManager, app.name) - } else if (isGog) { - launchGogGame(context, containerManager, gogGame!!) - } else if (isEpic) { - epicGame?.let { launchEpicGame(context, containerManager, it) } - } else { - launchSteamGame(context, containerManager, app) - } - onDismissRequest() - }, - onSettings = { - val shortcut = resolveOrCreateShortcut() - if (shortcut != null) { - // Layer the settings dialog on top; keep the detail dialog open underneath. - ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() - } - }, - onBootToDesktop = { - val shortcut = resolveOrCreateShortcut() - if (shortcut != null) { - bootShortcut = shortcut - heroPopup = HeroLaunchPopup.BootToDesktop - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.shortcuts_list_not_available, - heroToastAnchor, - ) - } - }, - onAchievements = if (!isCustom && !isEpic && !isGog) { - { showAchievements = true } - } else null, - onShortcut = { - if (hasPinnedShortcut) { - heroPopup = HeroLaunchPopup.RemoveShortcut - } else { - scope.launch { - val created = - withContext(Dispatchers.IO) { - if (isGog) { - val artworkUrl = gogGame!!.imageUrl.ifEmpty { gogGame.iconUrl } - addGogShortcutToHomeScreen(context, gogGame, artworkUrl) - } else { - addLibraryShortcutToHomeScreen( - context, - app, - isCustom, - isEpic, - epicId, - epicArtworkUrl, - ) - } - } - if (!created) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - context.getString( - R.string.library_games_failed_to_create_shortcut, - app.name, - ), - ) - } - } - } - }, - onCloudSaves = { activePopup = LibraryDetailPopup.CloudSaves }, - onUninstall = uninstallGame, - // Store source tag actions. Steam exposes verify/update/workshop; - // Epic and GOG expose verify/update for installed games. - steamMenuEnabled = !isCustom && - (!isEpic || epicGame?.isInstalled == true) && - (!isGog || gogGame?.isInstalled == true), - showVerifyFiles = !isCustom && - (!isEpic || epicGame?.isInstalled == true) && - (!isGog || gogGame?.isInstalled == true), - showCheckForUpdate = !isCustom && - (!isEpic || epicGame?.isInstalled == true) && - (!isGog || gogGame?.isInstalled == true), - showWorkshop = !isEpic && !isGog, - areSteamActionsEnabled = - when { - isEpic -> !hasBlockingEpicDownloadForLibrary - isGog -> !hasBlockingGogDownloadForLibrary - else -> !hasBlockingSteamDownloadForLibrary - }, - onVerifyFiles = { - context.runIfOnlineOrToast { - scope.launch { - val started = - withContext(Dispatchers.IO) { - when { - isEpic -> EpicService.verifyGameFiles(context, epicId) - isGog -> GOGService.verifyGameFiles(context, gogGame!!.id) - else -> SteamService.downloadAppForVerify(app.id) - } - } - if (started != null) { - // Hand off to the activity-root host so the - // pop-up + completion notice outlive this dialog. - showTaskProgressPopup( - started, - if (isGog) gogGame!!.title else app.name, - getString(R.string.store_game_verify_complete), - getString(R.string.store_game_verify_failed_notice), - completeAsToast = true, - ) - } - if (started == null) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_download_already_active), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onCheckForUpdate = { - when { - isEpic -> startEpicUpdateCheck(epicId, app.name) - isGog -> startGogUpdateCheck(gogGame!!.id, gogGame.title) - else -> startUpdateCheck(app.id, app.name) - } - }, - onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true }, - ) - - when (heroPopup) { - HeroLaunchPopup.BootToDesktop -> - HeroBootDialog( - onConfirm = { choice -> - heroPopup = null - bootShortcut?.let { sc -> - val intent = - Intent(context, XServerDisplayActivity::class.java) - .putExtra("container_id", sc.container.id) - when (choice) { - HeroBootChoice.Desktop -> {} - HeroBootChoice.Cube32 -> - intent - .putExtra("shortcut_path", sc.file.absolutePath) - .putExtra("boot_exe", "C:\\ProgramData\\Microsoft\\Windows\\Graphics-Test-32bit.exe") - HeroBootChoice.Cube64 -> - intent - .putExtra("shortcut_path", sc.file.absolutePath) - .putExtra("boot_exe", "C:\\ProgramData\\Microsoft\\Windows\\Graphics-Test-64bit.exe") - } - context.startActivity(intent) - onDismissRequest() - } - }, - onDismissRequest = { heroPopup = null }, - ) - HeroLaunchPopup.RemoveShortcut -> - HeroRemoveShortcutDialog( - gameName = if (isGog) gogGame!!.title else app.name, - onConfirm = { - scope.launch { - val removed = - withContext(Dispatchers.IO) { - homeShortcutState.shortcut?.let { - LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) - } == true - } - pinnedShortcutOverride = if (removed) false else hasPinnedShortcut - shortcutRefreshKey++ - heroPopup = null - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (removed) { - context.getString(R.string.shortcuts_list_removed) - } else { - context.getString(R.string.common_ui_unknown_error) - }, - ) - } - }, - onDismissRequest = { heroPopup = null }, - ) - null -> {} - } - } - - LibraryDetailScreen.Shortcut -> { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - stringResource(R.string.common_ui_shortcut), - style = MaterialTheme.typography.labelMedium, - color = TextSecondary, - fontWeight = FontWeight.Bold, - letterSpacing = 1.1.sp, - ) - - Spacer(Modifier.weight(1f)) - - ShortcutRemovalConfirmation( - message = - stringResource( - R.string.shortcuts_list_remove_game_shortcut_message, - if (isGog) gogGame!!.title else app.name, - ), - onConfirm = { - scope.launch { - val removed = - withContext(Dispatchers.IO) { - homeShortcutState.shortcut?.let { - LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) - } == true - } - pinnedShortcutOverride = if (removed) false else hasPinnedShortcut - shortcutRefreshKey++ - currentScreen = LibraryDetailScreen.Main - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (removed) { - context.getString(R.string.shortcuts_list_removed) - } else { - context.getString(R.string.common_ui_unknown_error) - }, - ) - } - }, - onCancel = { currentScreen = LibraryDetailScreen.Main }, - ) - } - } - - LibraryDetailScreen.CloudSaves -> { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .navigationBarsPadding(), - ) { - var isWorking by remember { mutableStateOf(false) } - - val detailGameSource = - when { - isGog -> GameSaveBackupManager.GameSource.GOG - isEpic -> GameSaveBackupManager.GameSource.EPIC - else -> GameSaveBackupManager.GameSource.STEAM - } - val detailGameId = - when { - isGog -> gogGame!!.id - isEpic -> epicId.toString() - else -> app.id.toString() - } - val detailShortcut = - remember(app.id, gogGame?.id, epicId, isGog, isEpic, isCustom) { - val containerManager = ContainerManager(context) - when { - isGog -> { - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id - } - } - - else -> { - findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - } - } - } - var cloudSyncEnabled by remember(detailShortcut?.file?.absolutePath) { - mutableStateOf(isShortcutCloudSyncEnabled(detailShortcut)) - } - var offlineModeEnabled by remember(detailShortcut?.file?.absolutePath) { - mutableStateOf(isShortcutOfflineMode(detailShortcut)) - } - - val detailProviderLabel = - when (detailGameSource) { - GameSaveBackupManager.GameSource.GOG -> - stringResource(R.string.preloader_platform_gog) - GameSaveBackupManager.GameSource.EPIC -> - stringResource(R.string.preloader_platform_epic) - GameSaveBackupManager.GameSource.CUSTOM -> - stringResource(R.string.preloader_platform_custom) - GameSaveBackupManager.GameSource.STEAM -> - stringResource(R.string.preloader_platform_steam) - } - - CloudSavesContent( - activity = this@UnifiedActivity, - isWorking = isWorking, - cloudSyncEnabled = cloudSyncEnabled, - offlineModeEnabled = offlineModeEnabled, - gameSource = detailGameSource, - gameId = detailGameId, - gameName = app.name, - shortcut = detailShortcut, - onCloudSyncToggle = { enabled -> - cloudSyncEnabled = enabled - setShortcutCloudSyncEnabled(detailShortcut, enabled) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (enabled) { - context.getString(R.string.cloud_sync_enabled_summary) - } else { - context.getString(R.string.cloud_sync_disabled_summary) - }, - android.widget.Toast.LENGTH_SHORT, - ) - }, - onOfflineModeToggle = { enabled -> - offlineModeEnabled = enabled - setShortcutOfflineMode(detailShortcut, enabled) - }, - onSyncFromCloud = { - if (!isWorking) { - isWorking = true - scope.launch(Dispatchers.IO) { - val ok = - CloudSyncHelper.downloadCloudSaves( - context, - detailGameSource, - detailGameId, - detailShortcut, - ) - withContext(Dispatchers.Main) { - isWorking = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (ok) { - context.getString( - R.string.cloud_saves_sync_from_provider_success, - detailProviderLabel, - ) - } else { - context.getString( - R.string.cloud_saves_sync_from_provider_failed, - detailProviderLabel, - ) - }, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - showBottomBack = false, - onBack = { currentScreen = LibraryDetailScreen.Main }, - ) - } - } - - LibraryDetailScreen.Uninstall -> { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 24.dp, vertical = 20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - stringResource( - if (isCustom) R.string.library_games_remove_game else R.string.library_games_uninstall_game, - ), - style = MaterialTheme.typography.labelMedium, - color = TextSecondary, - fontWeight = FontWeight.Bold, - letterSpacing = 1.1.sp, - ) - - Spacer(Modifier.weight(1f)) - - UninstallConfirmation( - message = - if (isCustom) { - getString(R.string.library_games_remove_confirm, app.name) - } else { - getString(R.string.library_games_uninstall_confirm, app.name) - }, - confirmLabel = - stringResource( - if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall, - ), - onConfirm = uninstallGame, - onCancel = { currentScreen = LibraryDetailScreen.Main }, - ) - } - } - } - } - - if (showAchievements) { - Dialog( - onDismissRequest = { showAchievements = false }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - com.winlator.cmod.feature.stores.steam.achievements.SteamAchievementsScreen( - appId = app.id, - appName = app.name, - onClose = { showAchievements = false }, - ) - } - } - - activePopup?.let { popup -> - LibraryDetailPopupFrame( - title = - when (popup) { - LibraryDetailPopup.CloudSaves -> - stringResource( - R.string.cloud_saves_title_for_provider, - when { - isGog -> stringResource(R.string.preloader_platform_gog) - isEpic -> stringResource(R.string.preloader_platform_epic) - isCustom -> stringResource(R.string.preloader_platform_custom) - else -> stringResource(R.string.preloader_platform_steam) - }, - app.name, - ) - }, - wide = popup == LibraryDetailPopup.CloudSaves, - onDismissRequest = { activePopup = null }, - ) { - when (popup) { - LibraryDetailPopup.CloudSaves -> { - var isWorking by remember { mutableStateOf(false) } - - val detailGameSource = - when { - isGog -> GameSaveBackupManager.GameSource.GOG - isEpic -> GameSaveBackupManager.GameSource.EPIC - isCustom -> GameSaveBackupManager.GameSource.CUSTOM - else -> GameSaveBackupManager.GameSource.STEAM - } - val detailShortcut = - remember(app.id, gogGame?.id, epicId, isGog, isEpic, isCustom) { - val containerManager = ContainerManager(context) - when { - isGog -> { - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id - } - } - - else -> { - findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) - } - } - } - val detailGameId = - when { - isGog -> gogGame!!.id - isEpic -> epicId.toString() - isCustom -> - detailShortcut?.let { GameSaveBackupManager.customGameId(it) } - ?: app.name - else -> app.id.toString() - } - var cloudSyncEnabled by remember(detailShortcut?.file?.absolutePath) { - mutableStateOf(isShortcutCloudSyncEnabled(detailShortcut)) - } - var offlineModeEnabled by remember(detailShortcut?.file?.absolutePath) { - mutableStateOf(isShortcutOfflineMode(detailShortcut)) - } - - val detailProviderLabel = - when (detailGameSource) { - GameSaveBackupManager.GameSource.GOG -> - stringResource(R.string.preloader_platform_gog) - GameSaveBackupManager.GameSource.EPIC -> - stringResource(R.string.preloader_platform_epic) - GameSaveBackupManager.GameSource.CUSTOM -> - stringResource(R.string.preloader_platform_custom) - GameSaveBackupManager.GameSource.STEAM -> - stringResource(R.string.preloader_platform_steam) - } - - CloudSavesContent( - activity = this@UnifiedActivity, - isWorking = isWorking, - cloudSyncEnabled = cloudSyncEnabled, - offlineModeEnabled = offlineModeEnabled, - gameSource = detailGameSource, - gameId = detailGameId, - gameName = app.name, - shortcut = detailShortcut, - onCloudSyncToggle = { enabled -> - cloudSyncEnabled = enabled - setShortcutCloudSyncEnabled(detailShortcut, enabled) - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (enabled) { - context.getString(R.string.cloud_sync_enabled_summary) - } else { - context.getString(R.string.cloud_sync_disabled_summary) - }, - android.widget.Toast.LENGTH_SHORT, - ) - }, - onOfflineModeToggle = { enabled -> - offlineModeEnabled = enabled - setShortcutOfflineMode(detailShortcut, enabled) - }, - onSyncFromCloud = { - if (!isWorking) { - isWorking = true - scope.launch(Dispatchers.IO) { - val ok = - CloudSyncHelper.downloadCloudSaves( - context, - detailGameSource, - detailGameId, - detailShortcut, - ) - withContext(Dispatchers.Main) { - isWorking = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - if (ok) { - context.getString( - R.string.cloud_saves_sync_from_provider_success, - detailProviderLabel, - ) - } else { - context.getString( - R.string.cloud_saves_sync_from_provider_failed, - detailProviderLabel, - ) - }, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - showTitle = false, - showBottomBack = false, - onBack = { activePopup = null }, - ) - } - } - } - } - - if ( - currentScreen != LibraryDetailScreen.Main && - currentScreen != LibraryDetailScreen.CloudSaves - ) { - // Close button overlay - IconButton( - onClick = onDismissRequest, - modifier = - Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - .size(42.dp) - .shadow(8.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.35f)) - .clip(CircleShape) - .background(BgDark.copy(alpha = 0.7f)), - ) { - Icon(Icons.Outlined.Close, contentDescription = "Close", tint = TextPrimary) - } - } - } - - if (showWorkshopDialog) { - WorkshopDialog( - appId = app.id, - gameTitle = app.name, - onDismissRequest = { showWorkshopDialog = false }, - ) - } - } - } - } - - @Composable - private fun CompactActionButton( - icon: ImageVector, - label: String, - tint: Color = TextPrimary, - bgColor: Color = SurfaceDark, - modifier: Modifier = Modifier, - height: Dp = 36.dp, - fontSize: TextUnit = 13.sp, - onClick: () -> Unit, - ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.93f else 1f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 800f), - label = "btnScale", - ) - val glowAlpha by animateFloatAsState( - targetValue = if (isPressed) 0.18f else 0f, - animationSpec = tween(durationMillis = 120), - label = "btnGlow", - ) - Surface( - modifier = - modifier - .fillMaxWidth() - .height(height) - .graphicsLayer { - scaleX = scale - scaleY = scale - }.clip(RoundedCornerShape(10.dp)) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ), - color = bgColor, - shape = RoundedCornerShape(10.dp), - border = BorderStroke(1.dp, tint.copy(alpha = glowAlpha)), - ) { - Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) - Spacer(Modifier.width(6.dp)) - Text( - label, - color = tint, - fontSize = fontSize, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - - // Single game capsule for carousel / grid / list - @Composable - @OptIn(ExperimentalFoundationApi::class) - private fun GameCapsule( - app: SteamApp, - gogGame: GOGGame? = null, - epicGame: EpicGame? = null, - iconRefreshKey: Int = 0, - artworkCacheRefreshKey: Int = 0, - isFocusedOverride: Boolean = false, - isControllerActive: Boolean = false, - customArtworkPath: String? = null, - customIconPath: String? = null, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, - useLibraryCapsule: Boolean = false, - listMode: Boolean = false, - modifier: Modifier = Modifier, - ) { - val context = LocalContext.current - val isCustom = app.id < 0 - val isEpic = app.id >= 2000000000 - val defaultClick: () -> Unit = { - val containerManager = - com.winlator.cmod.runtime.container - .ContainerManager(context) - if (isCustom) { - launchCustomGame(context, containerManager, app.name) - } else if (gogGame != null) { - launchGogGame(context, containerManager, gogGame) - } else if (isEpic) { - epicGame?.let { launchEpicGame(context, containerManager, it) } - } else { - launchSteamGame(context, containerManager, app) - } - } - val clickInteraction = remember { MutableInteractionSource() } - val isPressed by clickInteraction.collectIsPressedAsState() - val isFocused = isControllerActive && isFocusedOverride - val glowAlpha by animateFloatAsState( - targetValue = if (isPressed) 0.7f else 0f, - animationSpec = if (isPressed) tween(100) else tween(400), - label = "capsuleGlow", - ) - val clickModifier = - Modifier - .then( - if (glowAlpha > 0f) { - Modifier.drawWithContent { - drawContent() - drawRoundRect( - color = AccentGlow, - alpha = glowAlpha * 0.25f, - cornerRadius = CornerRadius(12.dp.toPx()), - ) - } - } else { - Modifier - }, - ).combinedClickable( - interactionSource = clickInteraction, - indication = null, - onClick = onClick ?: defaultClick, - onLongClick = onLongClick, - ) - - @Composable - fun ArtContent(artModifier: Modifier) { - val customArtworkFile = - customArtworkPath - ?.let { java.io.File(it) } - - if (customArtworkFile != null) { - val customArtworkCacheKey = - "library_custom_icon:${customArtworkFile.absolutePath}:${customArtworkFile.lastModified()}" - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(customArtworkFile) - .memoryCacheKey(customArtworkCacheKey) - .diskCacheKey(customArtworkCacheKey) - .crossfade(300) - .build(), - contentDescription = app.name, - modifier = artModifier, - contentScale = ContentScale.Crop, - ) - } else if (isCustom) { - val iconFile = customIconPath?.let { path -> java.io.File(path) } - if (iconFile != null) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(iconFile) - .crossfade(300) - .build(), - contentDescription = app.name, - modifier = artModifier, - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = artModifier.background(SurfaceDark), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Outlined.SportsEsports, - contentDescription = app.name, - tint = Accent.copy(alpha = 0.6f), - modifier = Modifier.size(48.dp), - ) - } - } - } else { - val imageModel = - remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, artworkCacheRefreshKey) { - StoreArtworkCache.imageModel( - context, - StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode), - ) - } - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(imageModel) - .crossfade(300) - .build(), - contentDescription = app.name, - modifier = artModifier, - contentScale = ContentScale.Crop, - ) - } - } - - if (listMode) { - // Horizontal row card with hero background - val heroRef = if (!isCustom && gogGame == null && !isEpic) StoreArtworkCache.heroRef(app, null, null) else null - val heroModel = - remember(app.id, heroRef, artworkCacheRefreshKey) { - StoreArtworkCache.imageModel(context, heroRef) - } - - Box( - modifier = - modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .then( - if (isControllerActive && !isFocused) { - Modifier.border(1.dp, CardBorder, RoundedCornerShape(14.dp)) - } else { - Modifier - }, - ).chasingBorder( - isFocused = isFocused, - paused = chasingBordersPaused.value || !libraryTabActive.value, - cornerRadius = 14.dp, - ).background(CardDark, RoundedCornerShape(14.dp)) - .focusable() - .then(clickModifier), - ) { - // Hero background layer (falls back to CardDark if image fails) - if (heroRef != null) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(heroModel) - .crossfade(300) - .build(), - contentDescription = null, - modifier = - Modifier - .matchParentSize() - .graphicsLayer { alpha = 0.25f }, - contentScale = ContentScale.Crop, - ) - } - - // Foreground content - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 11.dp), - horizontalArrangement = Arrangement.Center, - ) { - Box( - modifier = - Modifier - .height(52.dp) - .aspectRatio(462f / 174f) - .clip(RoundedCornerShape(8.dp)), - ) { - ArtContent(Modifier.fillMaxSize()) - } - - Spacer(Modifier.width(14.dp)) - - Text( - text = app.name, - modifier = - Modifier - .weight(1f) - .then(if (isFocused) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } else { - // Vertical card: art on top, title below - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - modifier - .fillMaxWidth() - .then( - if (isFocused) { - Modifier - } else { - Modifier.border(1.dp, CardDark, RoundedCornerShape(12.dp)) - }, - ).chasingBorder( - isFocused = isFocused, - paused = chasingBordersPaused.value || !libraryTabActive.value, - cornerRadius = 12.dp, - ).background(CardDark, RoundedCornerShape(12.dp)) - .focusable() - .then(clickModifier), - ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), - ) { - ArtContent(Modifier.fillMaxSize()) - } - - Text( - text = app.name, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) - .then(if (isFocused) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - style = MaterialTheme.typography.bodySmall, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - } - } - - // Epic Store Tab - @Composable - fun EpicStoreTab( - isLoggedIn: Boolean, - epicApps: List, - searchQuery: String = "", - layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, - onLoginClick: () -> Unit, - ) { - val context = LocalContext.current - - if (!isLoggedIn) { - LoginRequiredScreen("Epic Games", onLoginClick) - return - } - - val selectedAppId = remember { mutableStateOf(null) } - val gridState = rememberLazyGridState() - val activity = LocalContext.current as? UnifiedActivity - - // Ensure library updates from cloud - LaunchedEffect(Unit) { - if (epicApps.isEmpty()) { - EpicService.triggerLibrarySync(context) - } - } - - val displayedApps = - remember(epicApps, searchQuery) { - if (searchQuery.isBlank()) { - epicApps - } else { - epicApps.filter { it.title.contains(searchQuery, ignoreCase = true) } - } - } - val installStateById = rememberEpicInstallStateMap(context, displayedApps) - - // Sync store focus infrastructure - LaunchedEffect(displayedApps.size) { - activity?.storeItemCount = displayedApps.size - val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) - if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { - activity.storeFocusIndex.value = lastIndex - } - } - DisposableEffect(displayedApps) { - val clickCallback: (Int) -> Unit = { idx -> - displayedApps.getOrNull(idx)?.let { selectedAppId.value = it.id } - } - activity?.storeItemClickCallback = clickCallback - activity?.storeGridState = gridState - onDispose { - if (activity?.storeItemClickCallback === clickCallback) { - activity?.storeItemClickCallback = null - activity?.storeGridState = null - } - } - } - - if (layoutMode == LibraryLayoutMode.LIST) { - val listViewState = rememberLazyListState() - JoystickListScroll(listViewState, activity?.rightStickScrollState) - ListView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - listState = listViewState, - contentPadding = TabListContentPadding, - keyOf = { it.id }, - ) { app, _, _ -> - EpicStoreCapsule( - app, - isInstalled = installStateById[app.id] == true, - listMode = true, - isControllerActive = ControllerHelper.isControllerConnected(), - ) { - selectedAppId.value = - app.id - } - } - } else { - val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() - val focusRequesters = - remember(displayedApps.size) { - List(displayedApps.size) { FocusRequester() } - } - LaunchedEffect(focusIndex, focusRequesters.size) { - if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { - gridState.animateScrollToItem(focusIndex) - try { - focusRequesters[focusIndex].requestFocus() - } catch (_: Exception) { - } - } - } - JoystickGridScroll(gridState, activity?.rightStickScrollState) - FourByTwoGridView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), - gridState = gridState, - keyOf = { it.id }, - ) { app, index, rowHeight -> - Box( - Modifier.height(rowHeight).then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) { - EpicStoreCapsule( - app, - isInstalled = installStateById[app.id] == true, - isFocusedOverride = index == focusIndex, - isControllerActive = ControllerHelper.isControllerConnected(), - ) { - selectedAppId.value = - app.id - } - } - } - } - - val selectedApp = epicApps.find { it.id == selectedAppId.value } - if (selectedApp != null) { - EpicGameManagerDialog( - app = selectedApp, - onDismissRequest = { selectedAppId.value = null }, - ) - } - } - - @Composable - private fun StoreInstalledBadge( - modifier: Modifier = Modifier, - compact: Boolean = false, - attachedCorner: Boolean = false, - ) { - val shape = - if (attachedCorner) { - RoundedCornerShape(topStart = 8.dp) - } else { - RoundedCornerShape(4.dp) - } - Box( - modifier = - modifier - .background(StatusOnline, shape) - .border(1.dp, Color.White.copy(alpha = 0.34f), shape) - .padding( - start = if (compact) 6.dp else 9.dp, - end = if (compact) 6.dp else 9.dp, - top = if (compact) 2.dp else 4.dp, - bottom = if (compact) 1.dp else 2.dp, - ), - ) { - Text( - stringResource(R.string.library_games_installed_badge), - color = Color(0xFF06140A), - fontSize = if (compact) 9.sp else 11.sp, - fontWeight = FontWeight.Black, - letterSpacing = 0.6.sp, - maxLines = 1, - ) - } - } - - @Composable - fun EpicStoreCapsule( - app: com.winlator.cmod.feature.stores.epic.data.EpicGame, - isInstalled: Boolean, - listMode: Boolean = false, - isFocusedOverride: Boolean = false, - isControllerActive: Boolean = false, - onClick: () -> Unit, - ) { - val context = LocalContext.current - var isFocused by remember { mutableStateOf(false) } - val clickInteraction = remember { MutableInteractionSource() } - val isPressed by clickInteraction.collectIsPressedAsState() - val glowAlpha by animateFloatAsState( - targetValue = if (isPressed) 0.7f else 0f, - animationSpec = if (isPressed) tween(100) else tween(400), - label = "epicCapsuleGlow", - ) - val effectiveFocus = isControllerActive && (isFocusedOverride || isFocused) - val imageUrl = app.primaryImageUrl ?: app.iconUrl - - val borderColor = if (isControllerActive) CardBorder else Color.Transparent - - if (listMode) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .border(1.dp, borderColor, RoundedCornerShape(14.dp)) - .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 14.dp) - .background(CardDark, RoundedCornerShape(14.dp)) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .then( - if (glowAlpha > 0f) { - Modifier.drawWithContent { - drawContent() - drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(14.dp.toPx())) - } - } else { - Modifier - }, - ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 11.dp), - horizontalArrangement = Arrangement.Center, - ) { - Box( - Modifier - .height(52.dp) - .aspectRatio(462f / 174f) - .clip(RoundedCornerShape(8.dp)), - ) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(imageUrl) - .crossfade(300) - .build(), - contentDescription = app.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), - compact = true, - ) - } - } - Spacer(Modifier.width(14.dp)) - Text( - app.title, - modifier = - Modifier - .weight(1f) - .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .fillMaxSize() - .border(1.dp, borderColor, RoundedCornerShape(16.dp)) - .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 16.dp) - .background(CardDark, RoundedCornerShape(16.dp)) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .then( - if (glowAlpha > 0f) { - Modifier.drawWithContent { - drawContent() - drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(16.dp.toPx())) - } - } else { - Modifier - }, - ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), - ) { - Box( - Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), - ) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(imageUrl) - .crossfade(300) - .build(), - contentDescription = app.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd), - attachedCorner = true, - ) - } - } - - Text( - app.title, - modifier = - Modifier - .padding(horizontal = 4.dp, vertical = 4.dp) - .fillMaxWidth() - .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - style = MaterialTheme.typography.bodySmall, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - } - } - - @Composable - fun EpicGameManagerDialog( - app: EpicGame, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - val installed = EpicService.isGameInstalled(context, app.id) - val scope = rememberCoroutineScope() - - var isLoading by remember { mutableStateOf(!installed) } - var manifestSizes by remember { mutableStateOf(null) } - var dlcApps by remember { mutableStateOf>(emptyList()) } - val selectedDlcIds = remember { mutableStateListOf() } - var customPath by remember { mutableStateOf(null) } - var showCustomPathWarning by remember { mutableStateOf(false) } - var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } - var updateInfo by remember(app.id) { mutableStateOf(null) } - var updateStatusText by remember(app.id) { mutableStateOf(null) } - val epicDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( - initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), - ) - val hasBlockingEpicDownload = - epicDownloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_EPIC && - it.storeGameId == app.id.toString() && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - val updateActionEnabled = !hasBlockingEpicDownload - val activeEpicDownloadText = stringResource(R.string.store_game_download_already_active) - val noUpdateAvailableText = stringResource(R.string.store_game_no_update_available) - val updateAvailableText = stringResource(R.string.store_game_update_available) - val updateFailedText = stringResource(R.string.store_game_update_check_failed) - - if (showCustomPathWarning) { - CustomPathWarningDialog( - onDismiss = { showCustomPathWarning = false }, - onProceed = { - showCustomPathWarning = false - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName), - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - }, - ) - } - - LaunchedEffect(app.id, installed) { - if (!installed) { - val (sizes, sizedDlcs) = - withContext(Dispatchers.IO) { - val baseSizes = EpicService.fetchManifestSizes(context, app.id) - val dlcs = EpicService.getDLCForGameSuspend(app.id) - val dlcsWithSizes = - dlcs - .map { dlc -> - async { - if (dlc.downloadSize > 0L || dlc.installSize > 0L) { - dlc - } else { - val dlcSizes = EpicService.fetchManifestSizes(context, dlc.id) - dlc.copy( - downloadSize = dlcSizes.downloadSize, - installSize = dlcSizes.installSize, - ) - } - } - }.awaitAll() - baseSizes to dlcsWithSizes - } - manifestSizes = sizes - dlcApps = sizedDlcs - isLoading = false - } else { - dlcApps = - withContext(Dispatchers.IO) { - EpicService - .getDLCForGameSuspend(app.id) - .map { dlc -> - async { - if (dlc.downloadSize > 0L || dlc.installSize > 0L) { - dlc - } else { - val dlcSizes = EpicService.fetchManifestSizes(context, dlc.id) - dlc.copy( - downloadSize = dlcSizes.downloadSize, - installSize = dlcSizes.installSize, - ) - } - } - }.awaitAll() - } - } - } - - val baseDownloadSize = manifestSizes?.downloadSize ?: 0L - val baseInstallSize = manifestSizes?.installSize ?: 0L - val selectedDlcDownloadBytes = - dlcApps.filter { it.id in selectedDlcIds }.sumOf { it.downloadSize.coerceAtLeast(0L) } - val selectedDlcInstallBytes = - dlcApps.filter { it.id in selectedDlcIds }.sumOf { it.installSize.coerceAtLeast(0L) } - val totalDownloadSize = baseDownloadSize + selectedDlcDownloadBytes - val totalInstallSize = baseInstallSize + selectedDlcInstallBytes - val defaultPathSet = - if (PrefManager.useSingleDownloadFolder) { - PrefManager.defaultDownloadFolder.isNotEmpty() - } else { - PrefManager.epicDownloadFolder - .isNotEmpty() - } - val effectivePath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName) - val availableBytes = - try { - StorageUtils.getAvailableSpace(effectivePath) - } catch (e: Exception) { - 0L - } - // Installed game: base content is on disk, so only gate on the newly-selected DLC bytes. - val requiredBytes = if (installed) selectedDlcInstallBytes else totalInstallSize - val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes - val installActionEnabled = isInstallEnabled && !hasBlockingEpicDownload - val installPathDisplay = if (installed) app.installPath else (customPath ?: EpicConstants.defaultEpicGamesPath(context)) - - val dlcItems = - remember(dlcApps) { - dlcApps.map { dlc -> - val size = - dlc.downloadSize.takeIf { it > 0L } - ?: dlc.installSize - StoreDlcItem(id = dlc.id, name = dlc.title, downloadSize = size, isInstalled = dlc.isInstalled) - } - } - val customPathLabel = - when { - customPath != null -> stringResource(R.string.common_ui_custom) - defaultPathSet -> stringResource(R.string.common_ui_already_set) - else -> stringResource(R.string.common_ui_custom) - } - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - shape = RectangleShape, - color = Color.Black, - ) { - StoreGameDetailScreen( - title = app.title, - subtitle = - listOfNotNull( - app.developer.takeIf { it.isNotBlank() }, - app.publisher.takeIf { - it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) - }, - ).joinToString(" • "), - sourceLabel = "Epic Games", - heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.epicHeroRef(app)), - isLoading = isLoading, - isInstalled = installed, - installPathDisplay = installPathDisplay, - downloadSize = totalDownloadSize, - installSize = totalInstallSize, - availableBytes = availableBytes, - isInstallEnabled = isInstallEnabled, - isDownloadActionEnabled = installActionEnabled, - customPathLabel = customPathLabel, - showCustomPath = true, - showCloudSync = false, - showUninstall = false, - showUpdateCheck = installed, - isCheckingForUpdate = isCheckingForUpdate, - isUpdateAvailable = updateInfo?.hasUpdate == true, - updateDownloadSize = updateInfo?.downloadSize ?: 0L, - updateStatusText = updateStatusText, - isUpdateActionEnabled = updateActionEnabled, - showVerifyFiles = installed, - areSteamActionsEnabled = !hasBlockingEpicDownload, - dlcs = dlcItems, - selectedDlcIds = selectedDlcIds.toSet(), - onBack = onDismissRequest, - onInstall = { - if (hasBlockingEpicDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeEpicDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - val installPath = - if (customPath != null) { - val sanitizedTitle = app.title.replace(Regex("[^a-zA-Z0-9 \\-_]"), "").trim() - java.io.File(customPath!!, sanitizedTitle).absolutePath - } else { - EpicConstants.getGameInstallPath(context, app.title) - } - context.runIfOnlineOrToast { - EpicService.downloadGame(context, app.id, selectedDlcIds.toList(), installPath, "en-US") - onDismissRequest() - } - }, - onCloudSync = { - scope.launch(Dispatchers.IO) { - EpicCloudSavesManager.syncCloudSaves(context, app.id, "auto") - } - onDismissRequest() - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - context.getString(R.string.google_cloud_sync_started), - android.widget.Toast.LENGTH_SHORT, - ) - }, - onVerifyFiles = { - if (hasBlockingEpicDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeEpicDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch { - val started = - withContext(Dispatchers.IO) { - EpicService.verifyGameFiles(context, app.id) - } - if (started != null) { - showTaskProgressPopup( - started, - app.title, - getString(R.string.store_game_verify_complete), - getString(R.string.store_game_verify_failed_notice), - completeAsToast = true, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeEpicDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onCheckForUpdate = { - if (hasBlockingEpicDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeEpicDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch { - isCheckingForUpdate = true - updateStatusText = null - val latest = - withContext(Dispatchers.IO) { - EpicService.checkForGameUpdate(context, app.id) - } - updateInfo = latest - updateStatusText = - when { - latest.hasUpdate -> updateAvailableText - latest.message != null -> updateFailedText - else -> null - } - isCheckingForUpdate = false - if (!latest.hasUpdate && latest.message == null) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - noUpdateAvailableText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onDownloadUpdate = { - if (!updateActionEnabled || updateInfo?.hasUpdate != true) return@StoreGameDetailScreen - context.runIfOnlineOrToast { - scope.launch { - val latest = - withContext(Dispatchers.IO) { - EpicService.checkForGameUpdate(context, app.id) - } - updateInfo = latest - updateStatusText = - when { - latest.hasUpdate -> updateAvailableText - latest.message != null -> updateFailedText - else -> null - } - if (!latest.hasUpdate) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - noUpdateAvailableText, - android.widget.Toast.LENGTH_SHORT, - ) - return@launch - } - val started = - withContext(Dispatchers.IO) { - EpicService.updateGameFiles(context, app.id) - } - if (started != null) { - onDismissRequest() - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeEpicDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onUninstall = { - scope.launch(Dispatchers.IO) { - val result = EpicService.deleteGame(context, app.id) - withContext(Dispatchers.Main) { - if (!result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message - ?: getString(R.string.common_ui_unknown_error), - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - }, - onCustomPath = { - if (customPath == null && defaultPathSet) { - showCustomPathWarning = true - } else { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName), - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - } - }, - onToggleDlc = { id -> - if (selectedDlcIds.contains(id)) { - selectedDlcIds.remove(id) - } else { - selectedDlcIds.add(id) - } - }, - onToggleSelectAllDlcs = { - val all = dlcItems.isNotEmpty() && dlcItems.all { it.id in selectedDlcIds } - if (all) { - selectedDlcIds.clear() - } else { - dlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } - } - }, - ) - } - } - } - - @Composable - fun GOGStoreTab( - isLoggedIn: Boolean, - gogApps: List, - searchQuery: String = "", - layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, - onLoginClick: () -> Unit, - ) { - if (!isLoggedIn) { - LoginRequiredScreen("GOG", onLoginClick) - return - } - - val selectedGameId = remember { mutableStateOf(null) } - val gridState = rememberLazyGridState() - val activity = LocalContext.current as? UnifiedActivity - - val displayedApps = - remember(gogApps, searchQuery) { - if (searchQuery.isBlank()) { - gogApps - } else { - gogApps.filter { it.title.contains(searchQuery, ignoreCase = true) } - } - } - val installStateById = rememberGogInstallStateMap(displayedApps) - - // Sync store focus infrastructure - LaunchedEffect(displayedApps.size) { - activity?.storeItemCount = displayedApps.size - val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) - if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { - activity.storeFocusIndex.value = lastIndex - } - } - DisposableEffect(displayedApps) { - val clickCallback: (Int) -> Unit = { idx -> - displayedApps.getOrNull(idx)?.let { selectedGameId.value = it.id } - } - activity?.storeItemClickCallback = clickCallback - activity?.storeGridState = gridState - onDispose { - if (activity?.storeItemClickCallback === clickCallback) { - activity?.storeItemClickCallback = null - activity?.storeGridState = null - } - } - } - - val isControllerActive = ControllerHelper.isControllerConnected() - val gogBorderColor = if (isControllerActive) CardBorder else Color.Transparent - - if (layoutMode == LibraryLayoutMode.LIST) { - val listViewState = rememberLazyListState() - ListView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - listState = listViewState, - contentPadding = TabListContentPadding, - keyOf = { it.id }, - ) { app, _, _ -> - val isInstalled = installStateById[app.id] == true - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .border(1.dp, gogBorderColor, RoundedCornerShape(14.dp)) - .background(CardDark, RoundedCornerShape(14.dp)) - .clickable { selectedGameId.value = app.id } - .padding(horizontal = 14.dp, vertical = 11.dp), - horizontalArrangement = Arrangement.Center, - ) { - Box( - Modifier - .height(52.dp) - .aspectRatio(462f / 174f) - .clip(RoundedCornerShape(8.dp)), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(app.imageUrl.ifEmpty { app.iconUrl }) - .crossfade(300) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), - compact = true, - ) - } - } - Spacer(Modifier.width(14.dp)) - Text( - text = app.title, - modifier = Modifier.weight(1f), - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } else { - val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() - val focusRequesters = - remember(displayedApps.size) { - List(displayedApps.size) { FocusRequester() } - } - LaunchedEffect(focusIndex, focusRequesters.size) { - if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { - gridState.animateScrollToItem(focusIndex) - try { - focusRequesters[focusIndex].requestFocus() - } catch (_: Exception) { - } - } - } - FourByTwoGridView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), - gridState = gridState, - keyOf = { it.id }, - ) { app, index, rowHeight -> - val isInstalled = installStateById[app.id] == true - val isItemFocused = isControllerActive && index == focusIndex - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .fillMaxWidth() - .height(rowHeight) - .then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ).border(1.dp, gogBorderColor, RoundedCornerShape(16.dp)) - .chasingBorder(isFocused = isItemFocused, paused = chasingBordersPaused.value, cornerRadius = 16.dp) - .background(CardDark, RoundedCornerShape(16.dp)) - .clickable { selectedGameId.value = app.id }, - ) { - Box( - Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(app.imageUrl.ifEmpty { app.iconUrl }) - .crossfade(300) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd), - attachedCorner = true, - ) - } - } - - Text( - text = app.title, - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), - style = MaterialTheme.typography.bodySmall, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - } - } - - selectedGameId.value?.let { gameId -> - val app = gogApps.firstOrNull { it.id == gameId } - if (app != null) { - GOGGameManagerDialog(app = app) { selectedGameId.value = null } - } - } - } - - @Composable - fun GOGGameManagerDialog( - app: GOGGame, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - val installed = GOGService.isGameInstalled(app.id) - val scope = rememberCoroutineScope() - var isLoading by remember(app.id) { mutableStateOf(true) } - var selectedManifestSizes by remember(app.id) { mutableStateOf(GOGManifestSizes()) } - var dlcSizes by remember(app.id) { mutableStateOf>(emptyMap()) } - var customPath by remember { mutableStateOf(null) } - var showCustomPathWarning by remember { mutableStateOf(false) } - var dlcApps by remember(app.id) { mutableStateOf>(emptyList()) } - val selectedDlcIds = remember(app.id) { mutableStateListOf() } - var isCheckingForGogUpdate by remember(app.id) { mutableStateOf(false) } - var gogUpdateInfo by remember(app.id) { mutableStateOf(null) } - var gogUpdateStatusText by remember(app.id) { mutableStateOf(null) } - val gogDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( - initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), - ) - val hasBlockingGogDownload = - gogDownloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_GOG && - it.storeGameId == app.id && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - val gogUpdateActionEnabled = !hasBlockingGogDownload - val activeGogDownloadText = stringResource(R.string.store_game_download_already_active) - val gogNoUpdateAvailableText = stringResource(R.string.store_game_no_update_available) - val gogUpdateAvailableText = stringResource(R.string.store_game_update_available) - val gogUpdateFailedText = stringResource(R.string.store_game_update_check_failed) - - if (showCustomPathWarning) { - CustomPathWarningDialog( - onDismiss = { showCustomPathWarning = false }, - onProceed = { - showCustomPathWarning = false - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: GOGConstants.defaultGOGGamesPath, - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - }, - ) - } - - data class GogInstallLoadData( - val dlcs: List, - val dlcSizes: Map, - val baseManifestSizes: GOGManifestSizes, - ) - - LaunchedEffect(app.id, PrefManager.containerLanguage) { - isLoading = true - val loadData = - withContext(Dispatchers.IO) { - val dlcs = GOGService.getDLCForGameSuspend(app.id, PrefManager.containerLanguage) - val perDlcSizes = - dlcs.mapNotNull { dlc -> - val id = dlc.id.toIntOrNull() ?: return@mapNotNull null - id to - GOGManifestSizes( - installSize = dlc.installSize, - downloadSize = dlc.downloadSize, - ) - }.toMap() - GogInstallLoadData( - dlcs = dlcs, - dlcSizes = perDlcSizes, - baseManifestSizes = - GOGService.getInstallableSelectedManifestSizes( - app.id, - PrefManager.containerLanguage, - ), - ) - } - dlcApps = loadData.dlcs - dlcSizes = loadData.dlcSizes - selectedManifestSizes = loadData.baseManifestSizes - selectedDlcIds.clear() - loadData.dlcs - .filterNot { it.isInstalled } - .mapNotNull { it.id.toIntOrNull() } - .forEach { selectedDlcIds.add(it) } - isLoading = false - } - - LaunchedEffect(app.id, PrefManager.containerLanguage, selectedDlcIds.toList()) { - selectedManifestSizes = - withContext(Dispatchers.IO) { - GOGService.getInstallableSelectedManifestSizes( - app.id, - PrefManager.containerLanguage, - selectedDlcIds.toList(), - ) - } - } - - val defaultPathSet = - if (PrefManager.useSingleDownloadFolder) { - PrefManager.defaultDownloadFolder.isNotEmpty() - } else { - PrefManager.gogDownloadFolder - .isNotEmpty() - } - val installRootPath = customPath ?: GOGConstants.defaultGOGGamesPath - val installPathDisplay = - if (installed) { - app.installPath - } else if (customPath != null) { - java.io.File(customPath!!, GOGConstants.getSanitizedGameFolderName(app.title)).absolutePath - } else { - GOGConstants.getGameInstallPath(app.title) - } - val dlcItems = - remember(dlcApps, dlcSizes) { - dlcApps.mapNotNull { dlc -> - val id = dlc.id.toIntOrNull() ?: return@mapNotNull null - val manifestSize = dlcSizes[id] - val size = - manifestSize - ?.downloadSize - ?.takeIf { it > 0L } - ?: manifestSize?.installSize?.takeIf { it > 0L } - ?: dlc.downloadSize.takeIf { it > 0L } - ?: dlc.installSize - StoreDlcItem(id = id, name = dlc.title, downloadSize = size, isInstalled = dlc.isInstalled) - } - } - val selectedDlcDownloadSize = - remember(dlcItems, selectedDlcIds.toList()) { - dlcItems - .filter { !it.isInstalled && it.id in selectedDlcIds } - .sumOf { it.downloadSize.coerceAtLeast(0L) } - } - val selectedDlcInstallSize = - remember(dlcSizes, dlcItems, selectedDlcIds.toList()) { - dlcItems - .filter { !it.isInstalled && it.id in selectedDlcIds } - .sumOf { dlcSizes[it.id]?.installSize?.takeIf { size -> size > 0L } ?: it.downloadSize.coerceAtLeast(0L) } - } - val totalDownloadSize = - if (installed) { - selectedDlcDownloadSize - } else { - selectedManifestSizes.downloadSize.takeIf { it > 0L } - ?: app.downloadSize + selectedDlcDownloadSize - } - val totalInstallSize = - if (installed) { - selectedDlcInstallSize - } else { - selectedManifestSizes.installSize.takeIf { it > 0L } - ?: app.installSize.takeIf { it > 0L } - ?: totalDownloadSize - } - val requiredBytes = - if (installed) { - selectedDlcInstallSize.takeIf { it > 0L } ?: selectedDlcDownloadSize - } else { - totalInstallSize.takeIf { it > 0L } ?: totalDownloadSize - } - val availableBytes = - try { - StorageUtils.getAvailableSpace(installRootPath) - } catch (_: Exception) { - 0L - } - val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes - val installActionEnabled = isInstallEnabled && !hasBlockingGogDownload - val customPathLabel = - when { - customPath != null -> stringResource(R.string.common_ui_custom) - defaultPathSet -> stringResource(R.string.common_ui_already_set) - else -> stringResource(R.string.common_ui_custom) - } - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - shape = RectangleShape, - color = Color.Black, - ) { - StoreGameDetailScreen( - title = app.title, - subtitle = - listOfNotNull( - app.developer.takeIf { it.isNotBlank() }, - app.publisher.takeIf { - it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) - }, - ).joinToString(" • "), - sourceLabel = "GOG", - heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.gogHeroRef(app)), - isLoading = isLoading, - isInstalled = installed, - installPathDisplay = installPathDisplay, - downloadSize = totalDownloadSize, - installSize = totalInstallSize, - availableBytes = availableBytes, - isInstallEnabled = isInstallEnabled, - isDownloadActionEnabled = installActionEnabled, - customPathLabel = customPathLabel, - showCustomPath = true, - showCloudSync = false, - showUninstall = false, - showUpdateCheck = installed, - isCheckingForUpdate = isCheckingForGogUpdate, - isUpdateAvailable = gogUpdateInfo?.hasUpdate == true, - updateDownloadSize = gogUpdateInfo?.downloadSize ?: 0L, - updateStatusText = gogUpdateStatusText, - isUpdateActionEnabled = gogUpdateActionEnabled, - showVerifyFiles = installed, - areSteamActionsEnabled = !hasBlockingGogDownload, - dlcs = dlcItems, - selectedDlcIds = selectedDlcIds.toSet(), - isDlcSelectionEnabled = installActionEnabled, - onBack = onDismissRequest, - onInstall = { - if (hasBlockingGogDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_download_already_active), - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - GOGService.downloadGame( - context, - app.id, - installPathDisplay, - PrefManager.containerLanguage, - selectedDlcIds.toList(), - ) - onDismissRequest() - } - }, - onCloudSync = { - scope.launch(Dispatchers.IO) { - GOGService.syncCloudSaves(context, "GOG_${app.id}", "auto") - } - onDismissRequest() - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - context.getString(R.string.google_cloud_sync_started), - android.widget.Toast.LENGTH_SHORT, - ) - }, - onVerifyFiles = { - if (hasBlockingGogDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeGogDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch { - val started = - withContext(Dispatchers.IO) { - GOGService.verifyGameFiles(context, app.id) - } - if (started != null) { - showTaskProgressPopup( - started, - app.title, - getString(R.string.store_game_verify_complete), - getString(R.string.store_game_verify_failed_notice), - completeAsToast = true, - ) - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeGogDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onCheckForUpdate = { - if (hasBlockingGogDownload) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeGogDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch { - isCheckingForGogUpdate = true - gogUpdateStatusText = null - val latest = - withContext(Dispatchers.IO) { - GOGService.checkForGameUpdate(context, app.id) - } - gogUpdateInfo = latest - gogUpdateStatusText = - when { - latest.hasUpdate -> gogUpdateAvailableText - latest.message != null -> gogUpdateFailedText - else -> null - } - isCheckingForGogUpdate = false - if (!latest.hasUpdate && latest.message == null) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - gogNoUpdateAvailableText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onDownloadUpdate = { - if (!gogUpdateActionEnabled || gogUpdateInfo?.hasUpdate != true) return@StoreGameDetailScreen - context.runIfOnlineOrToast { - scope.launch { - val started = - withContext(Dispatchers.IO) { - GOGService.updateGameFiles(context, app.id) - } - if (started != null) { - onDismissRequest() - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeGogDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } - }, - onUninstall = { - scope.launch(Dispatchers.IO) { - val result = - GOGService.deleteGame( - context, - LibraryItem( - "GOG_${app.id}", - app.title, - com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG, - ), - ) - withContext(Dispatchers.Main) { - if (!result.isSuccess) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString( - R.string.library_games_failed_to_uninstall_reason, - result.exceptionOrNull()?.message - ?: getString(R.string.common_ui_unknown_error), - ), - android.widget.Toast.LENGTH_LONG, - ) - } - onDismissRequest() - } - } - }, - onCustomPath = { - if (customPath == null && defaultPathSet) { - showCustomPathWarning = true - } else { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: GOGConstants.defaultGOGGamesPath, - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - } - }, - onToggleDlc = { id -> - if (dlcItems.any { it.id == id && it.isInstalled }) { - return@StoreGameDetailScreen - } - if (selectedDlcIds.contains(id)) { - selectedDlcIds.remove(id) - } else { - selectedDlcIds.add(id) - } - }, - onToggleSelectAllDlcs = { - val selectableDlcItems = dlcItems.filterNot { it.isInstalled } - val all = selectableDlcItems.isNotEmpty() && selectableDlcItems.all { it.id in selectedDlcIds } - if (all) { - selectedDlcIds.removeAll(selectableDlcItems.map { it.id }.toSet()) - } else { - selectableDlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } - } - }, - ) - } - } - } - - // Steam Store Tab - @Composable - fun SteamStoreTab( - isLoggedIn: Boolean, - steamApps: List, - searchQuery: String = "", - layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, - ) { - if (!isLoggedIn && !SteamService.hasStoredCredentials(this)) { - LoginRequiredScreen("Steam") { - startActivity(Intent(this@UnifiedActivity, SteamLoginActivity::class.java)) - } - return - } - - var selectedAppForDialog by remember { mutableStateOf(null) } - val gridState = rememberLazyGridState() - val activity = LocalContext.current as? UnifiedActivity - - val displayedApps = - remember(steamApps, searchQuery) { - if (searchQuery.isBlank()) { - steamApps - } else { - steamApps.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - } - val installStateById = rememberSteamInstallStateMap(displayedApps) - - // Sync store focus infrastructure - LaunchedEffect(displayedApps.size) { - activity?.storeItemCount = displayedApps.size - val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) - if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { - activity.storeFocusIndex.value = lastIndex - } - } - // Register A-button click callback and grid state for visible-area snapping - DisposableEffect(displayedApps) { - val clickCallback: (Int) -> Unit = { idx -> - displayedApps.getOrNull(idx)?.let { selectedAppForDialog = it } - } - activity?.storeItemClickCallback = clickCallback - activity?.storeGridState = gridState - onDispose { - if (activity?.storeItemClickCallback === clickCallback) { - activity?.storeItemClickCallback = null - activity?.storeGridState = null - } - } - } - - if (layoutMode == LibraryLayoutMode.LIST) { - val listViewState = rememberLazyListState() - JoystickListScroll(listViewState, activity?.rightStickScrollState, minSpeed = 2.5f, maxSpeed = 16f, quadratic = true) - ListView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(), - listState = listViewState, - contentPadding = TabListContentPadding, - keyOf = { it.id }, - ) { app, _, _ -> - SteamStoreCapsule( - app, - isInstalled = installStateById[app.id] == true, - listMode = true, - isControllerActive = ControllerHelper.isControllerConnected(), - onClick = { - selectedAppForDialog = - app - }, - ) - } - } else { - val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() - val focusRequesters = - remember(displayedApps.size) { - List(displayedApps.size) { FocusRequester() } - } - LaunchedEffect(focusIndex, focusRequesters.size) { - if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { - gridState.animateScrollToItem(focusIndex) - try { - focusRequesters[focusIndex].requestFocus() - } catch (_: Exception) { - } - } - } - // Right joystick: 2x faster at full push with quadratic speed curve - JoystickGridScroll(gridState, activity?.rightStickScrollState, minSpeed = 2.5f, maxSpeed = 16f, quadratic = true) - // Left joystick: 75% slower scrolling (vertical only, for browsing store) - JoystickGridScroll(gridState, activity?.leftStickScrollState, deadZone = 0.15f, minSpeed = 0.3125f, maxSpeed = 2f) - FourByTwoGridView( - items = displayedApps, - modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), - gridState = gridState, - keyOf = { it.id }, - ) { app, index, rowHeight -> - Box( - Modifier.height(rowHeight).then( - if (index in focusRequesters.indices) { - Modifier.focusRequester(focusRequesters[index]) - } else { - Modifier - }, - ), - ) { - SteamStoreCapsule( - app, - isInstalled = installStateById[app.id] == true, - isFocusedOverride = index == focusIndex, - isControllerActive = - ControllerHelper - .isControllerConnected(), - onClick = { - selectedAppForDialog = - app - }, - ) - } - } - } - - if (selectedAppForDialog != null) { - GameManagerDialog( - app = selectedAppForDialog!!, - onDismissRequest = { selectedAppForDialog = null }, - ) - } - } - - @Composable - fun SteamStoreCapsule( - app: SteamApp, - isInstalled: Boolean, - listMode: Boolean = false, - isFocusedOverride: Boolean = false, - isControllerActive: Boolean = false, - onClick: () -> Unit, - ) { - val context = LocalContext.current - var isFocused by remember { mutableStateOf(false) } - val clickInteraction = remember { MutableInteractionSource() } - val isPressed by clickInteraction.collectIsPressedAsState() - val glowAlpha by animateFloatAsState( - targetValue = if (isPressed) 0.7f else 0f, - animationSpec = if (isPressed) tween(100) else tween(400), - label = "steamCapsuleGlow", - ) - val effectiveFocus = isControllerActive && (isFocusedOverride || isFocused) - val borderColor = if (isControllerActive) CardBorder else Color.Transparent - - if (listMode) { - Box( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .border(1.dp, borderColor, RoundedCornerShape(14.dp)) - .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 14.dp) - .background(CardDark, RoundedCornerShape(14.dp)) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .then( - if (glowAlpha > 0f) { - Modifier.drawWithContent { - drawContent() - drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(14.dp.toPx())) - } - } else { - Modifier - }, - ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), - ) { - // Hero background - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(app.getHeroUrl()) - .crossfade(300) - .build(), - contentDescription = null, - modifier = - Modifier - .matchParentSize() - .graphicsLayer { alpha = 0.25f }, - contentScale = ContentScale.Crop, - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 11.dp), - horizontalArrangement = Arrangement.Center, - ) { - Box( - Modifier - .height(52.dp) - .aspectRatio(462f / 174f) - .clip(RoundedCornerShape(8.dp)), - ) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(app.getSmallCapsuleUrl()) - .crossfade(300) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), - compact = true, - ) - } - } - Spacer(Modifier.width(14.dp)) - Text( - text = app.name, - modifier = - Modifier - .weight(1f) - .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - color = TextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .fillMaxSize() - .border(1.dp, borderColor, RoundedCornerShape(16.dp)) - .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 16.dp) - .background(CardDark, RoundedCornerShape(16.dp)) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .then( - if (glowAlpha > 0f) { - Modifier.drawWithContent { - drawContent() - drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(16.dp.toPx())) - } - } else { - Modifier - }, - ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), - ) { - Box( - Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), - ) { - val imageUrl = app.getCapsuleUrl() - - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(imageUrl) - .crossfade(300) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - - if (isInstalled) { - StoreInstalledBadge( - modifier = Modifier.align(Alignment.BottomEnd), - attachedCorner = true, - ) - } - } - - Text( - text = app.name, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) - .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), - style = MaterialTheme.typography.bodySmall, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) - } - } - } - - private data class DownloadCancelRequest( - val ids: List, - val isCancelAll: Boolean, - ) - - // Downloads Tab - @Composable - fun DownloadsTab( - selectedId: String?, - animationsActive: Boolean = true, - onSelectDownload: (String?) -> Unit, - ) { - val downloads = remember { mutableStateListOf>() } - var tick by remember { mutableIntStateOf(0) } - val scope = rememberCoroutineScope() - var cancelWarningRequest by remember { mutableStateOf(null) } - - val downloadsActivity = LocalContext.current as? UnifiedActivity - val bridge = downloadsActivity?.downloadsNavBridge - val navRegistry = remember(bridge) { PaneNavRegistry(initialSignal = bridge?.navSignal ?: -1) } - navRegistry.controllerActive = bridge?.controllerActive ?: false - LaunchedEffect(navRegistry, bridge?.navSignal) { - navRegistry.processNav(bridge?.navSignal ?: 0, bridge?.navDir ?: 0) - } - - val syncDownloads = - remember(selectedId, onSelectDownload) { - { - val currentDownloads = DownloadService.getAllDownloads() - downloads.clear() - downloads.addAll(currentDownloads) - if (selectedId != null && currentDownloads.none { it.first == selectedId }) { - onSelectDownload(null) - } - } - } - val latestSyncDownloads by rememberUpdatedState(syncDownloads) - - val downloadStatusListener = - remember { - object : EventDispatcher.JavaEventListener { - override fun onEvent(event: Any) { - if (event is AndroidEvent.DownloadStatusChanged) { - scope.launch { - latestSyncDownloads() - } - } - } - } - } - - DisposableEffect(downloadStatusListener, syncDownloads) { - syncDownloads() - PluviaApp.events.onJava(AndroidEvent.DownloadStatusChanged::class, downloadStatusListener) - onDispose { - PluviaApp.events.offJava(AndroidEvent.DownloadStatusChanged::class, downloadStatusListener) - } - } - - // Re-sync the list whenever the cross-store DownloadCoordinator records change. This - // is what makes PAUSED records (loaded from DB after app restart) appear in the tab, - // and what removes COMPLETE/CANCELLED/FAILED rows after Clear. - LaunchedEffect(syncDownloads) { - DownloadCoordinator.changes.collect { - latestSyncDownloads() - } - } - - downloads.forEach { (_, info) -> - LaunchedEffect(info) { - info.getStatusFlow().collect { - tick++ - } - } - // Also recompose on status-message changes. Active downloads push - // a changing message every progress tick, so this is what keeps - // the byte count / speed / progress bar refreshing live (the - // phase flow dedups to a single DOWNLOADING emission). - LaunchedEffect(info) { - info.getStatusMessageFlow().collect { - tick++ - } - } - } - - CompositionLocalProvider(LocalPaneNav provides navRegistry) { - Column( - Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) - .tabScreenPadding(top = DownloadsHeaderTopPadding) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val ev = awaitPointerEvent(PointerEventPass.Initial) - if (ev.type == PointerEventType.Press) { - bridge?.controllerActive = false - } - } - } - }, - ) { - @Suppress("UNUSED_EXPRESSION") - tick - - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - val selectedInfo = downloads.find { it.first == selectedId }?.second - val selectedStatus = selectedInfo?.getStatusFlow()?.value - // FAILED is resumable: the dispatcher preserves every breadcrumb - // on failure, so one click continues from where it left off. - val isResumable = - selectedStatus == DownloadPhase.PAUSED || selectedStatus == DownloadPhase.FAILED - val isComplete = selectedStatus == DownloadPhase.COMPLETE - val isCancelled = selectedStatus == DownloadPhase.CANCELLED - val pausableDownloads = - downloads.filter { - val status = it.second.getStatusFlow().value - status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED - } - val allPausableDownloadsPaused = - pausableDownloads.isNotEmpty() && - pausableDownloads.all { - val s = it.second.getStatusFlow().value - s == DownloadPhase.PAUSED || s == DownloadPhase.FAILED - } - - val pauseResumeLabel = - if (selectedId == null) { - if (allPausableDownloadsPaused) { - stringResource( - R.string.downloads_queue_resume_all, - ) - } else { - stringResource(R.string.downloads_queue_pause_all) - } - } else { - when { - selectedStatus == DownloadPhase.FAILED -> stringResource(R.string.session_drawer_retry) - isResumable -> stringResource(R.string.session_drawer_resume) - else -> stringResource(R.string.session_drawer_pause) - } - } - - val cancelLabel = - if (selectedId == null) { - stringResource(R.string.downloads_queue_cancel_all) - } else { - stringResource(R.string.common_ui_cancel) - } - - // Disable pause/resume for completed or cancelled downloads - val pauseResumeEnabled = - if (selectedId != null) { - !isComplete && !isCancelled - } else { - pausableDownloads.isNotEmpty() - } - - val cancelEnabled = - if (selectedId != null) { - !isComplete && !isCancelled - } else { - pausableDownloads.isNotEmpty() - } - - DownloadsQueueButton( - label = pauseResumeLabel, - accentColor = Accent, - onClick = { - val isResumeAction = - if (selectedId == null) allPausableDownloadsPaused else isResumable - val run = { - when { - selectedId == null && allPausableDownloadsPaused -> DownloadService.resumeAll() - selectedId == null -> DownloadService.pauseAll() - isResumable -> DownloadService.resumeDownload(selectedId) - else -> DownloadService.pauseDownload(selectedId) - } - } - if (isResumeAction) this@UnifiedActivity.runIfOnlineOrToast(run) else run() - }, - enabled = pauseResumeEnabled, - ) - - Box { - DownloadsQueueButton( - label = cancelLabel, - accentColor = DangerRed, - onClick = { - if (selectedId == null) { - cancelWarningRequest = - DownloadCancelRequest( - ids = pausableDownloads.map { it.first }, - isCancelAll = true, - ) - } else { - cancelWarningRequest = - DownloadCancelRequest( - ids = listOf(selectedId), - isCancelAll = false, - ) - } - }, - enabled = cancelEnabled, - ) - - cancelWarningRequest?.let { request -> - DownloadCancelWarningMenu( - expanded = true, - onDismissRequest = { cancelWarningRequest = null }, - onConfirm = { - val activeRequest = cancelWarningRequest - cancelWarningRequest = null - val ids = activeRequest?.ids.orEmpty() - if (activeRequest?.isCancelAll == true) { - DownloadService.cancelAll() - } else { - ids.forEach(DownloadService::cancelDownload) - } - onSelectDownload(null) - }, - isCancelAll = request.isCancelAll, - ) - } - } - - // Clear button - clears completed, cancelled, and failed downloads - val hasCompletedOrCancelled = - downloads.any { - val s = it.second.getStatusFlow().value - s == DownloadPhase.COMPLETE || s == DownloadPhase.CANCELLED || s == DownloadPhase.FAILED - } - - DownloadsQueueButton( - label = stringResource(R.string.downloads_queue_clear), - accentColor = Accent, - onClick = { - DownloadService.clearCompletedDownloads() - }, - enabled = hasCompletedOrCancelled, - ) - } - - val listState = rememberLazyListState() - val activity = LocalContext.current as? UnifiedActivity - val density = LocalContext.current.resources.displayMetrics.density - - LaunchedEffect(listState) { - activity?.rightStickScrollState?.collect { rz -> - if (kotlin.math.abs(rz) > 0.1f) { - // Max scroll speed is 20 rows per second (approx 20 * 100dp / 60fps ~ 32dp per frame) - // Min scroll speed is 0.75 rows per second (approx 0.75 * 100dp / 60fps ~ 1.25dp per frame) - // Use a square curve for more gradual acceleration - val speedFactor = kotlin.math.abs(rz) - val curveFactor = speedFactor * speedFactor - val baseSpeed = 1.25f + (curveFactor * (32f - 1.25f)) - val direction = if (rz > 0) 1f else -1f - - // Using a loop while the stick is held - while (kotlin.math.abs(activity.rightStickScrollState.value) > 0.1f) { - val currentRz = activity.rightStickScrollState.value - val currentSpeedFactor = kotlin.math.abs(currentRz) - val currentCurveFactor = currentSpeedFactor * currentSpeedFactor - val currentBaseSpeed = 1.25f + (currentCurveFactor * (32f - 1.25f)) - val currentDirection = if (currentRz > 0) 1f else -1f - - val pixelsToScroll = currentBaseSpeed * currentDirection * density - listState.dispatchRawDelta(pixelsToScroll) - kotlinx.coroutines.delay(16) // roughly 60fps - } - } - } - } - - // Sort so the user always sees what's actually running first, then everything - // they can resume, then finished items, with cancelled at the very bottom. - // The list re-sorts on phase transitions because `tick` (incremented by the - // status flow collectors above) is read here, forcing recomposition. - @Suppress("UNUSED_EXPRESSION") - tick - val sortedDownloads = - downloads.sortedBy { (_, info) -> - when (info.getStatusFlow().value) { - // In-progress states grouped together at the top. - DownloadPhase.DOWNLOADING, - DownloadPhase.PREPARING, - DownloadPhase.VERIFYING, - DownloadPhase.PATCHING, - DownloadPhase.APPLYING_DATA, - DownloadPhase.FINALIZING, - DownloadPhase.UNPACKING, - DownloadPhase.UNKNOWN, - -> 0 - // FAILED sorts with PAUSED — both are user-resumable; - // don't bury them under finished downloads. - DownloadPhase.PAUSED -> 1 - DownloadPhase.FAILED -> 1 - DownloadPhase.QUEUED -> 2 - DownloadPhase.COMPLETE -> 3 - DownloadPhase.CANCELLED -> 5 - } - } - - if (sortedDownloads.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - EmptyStateMessage(stringResource(R.string.downloads_queue_empty)) - } - } else { - LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(sortedDownloads, key = { it.first }) { (id, info) -> - DownloadItemDeck( - id, - info, - isSelected = selectedId == id, - animationsActive = animationsActive, - onClick = { - if (selectedId == id) onSelectDownload(null) else onSelectDownload(id) - }, - ) - } - } - } - } - } - } - - @Composable - private fun DownloadsQueueButton( - label: String, - accentColor: Color, - enabled: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, - ) { - val contentColor = if (enabled) accentColor else TextSecondary.copy(alpha = 0.48f) - - Button( - onClick = onClick, - enabled = enabled, - modifier = - modifier - .height(40.dp) - .widthIn(min = 96.dp) - .paneNavItem(cornerRadius = 8.dp, onActivate = { if (enabled) onClick() }), - colors = - ButtonDefaults.buttonColors( - containerColor = DownloadButtonBlack, - contentColor = contentColor, - disabledContainerColor = DownloadButtonBlack.copy(alpha = 0.18f), - disabledContentColor = TextSecondary.copy(alpha = 0.48f), - ), - border = BorderStroke(1.dp, contentColor.copy(alpha = if (enabled) 0.55f else 0.24f)), - contentPadding = PaddingValues(horizontal = 8.dp), - shape = RoundedCornerShape(8.dp), - ) { - Text( - label, - color = contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - @Composable - private fun AnimatedDownloadProgressFill( - modifier: Modifier, - widthPx: Float, - ) { - val infiniteTransition = rememberInfiniteTransition(label = "downloadProgressGradient") - val gradientOffset by infiniteTransition.animateFloat( - initialValue = -widthPx, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 5000, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "downloadProgressGradientOffset", - ) - - Box( - modifier.background( - Brush.horizontalGradient( - colorStops = DownloadChaseGradientStops, - startX = gradientOffset, - endX = gradientOffset + (widthPx * 2f), - tileMode = TileMode.Repeated, - ), - ), - ) - } - - @Composable - private fun DownloadChasingProgressBar( - progress: Float, - status: DownloadPhase, - animationsActive: Boolean, - modifier: Modifier = Modifier, - ) { - val clampedProgress = progress.coerceIn(0f, 1f) - val shouldUseActiveGradient = - when (status) { - DownloadPhase.DOWNLOADING, - DownloadPhase.QUEUED, - DownloadPhase.PREPARING, - DownloadPhase.VERIFYING, - DownloadPhase.PATCHING, - DownloadPhase.APPLYING_DATA, - DownloadPhase.FINALIZING, - DownloadPhase.UNPACKING, - -> true - else -> false - } - val shouldAnimate = shouldUseActiveGradient && animationsActive - val fillColor = - when (status) { - DownloadPhase.FAILED, - DownloadPhase.CANCELLED, - -> DangerRed - DownloadPhase.COMPLETE -> StatusOnline - DownloadPhase.PAUSED -> TextSecondary - else -> Accent - } - - BoxWithConstraints( - modifier = - modifier - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.34f)), - ) { - val density = LocalDensity.current - val widthPx = with(density) { maxWidth.toPx().coerceAtLeast(1f) } - - if (clampedProgress > 0f) { - val fillModifier = - Modifier - .fillMaxHeight() - .fillMaxWidth(clampedProgress) - .clip(RectangleShape) - - if (shouldUseActiveGradient) { - if (shouldAnimate) { - AnimatedDownloadProgressFill(fillModifier, widthPx) - } else { - Box( - fillModifier.background( - Brush.horizontalGradient( - colorStops = DownloadChaseGradientStops, - endX = widthPx * 2f, - tileMode = TileMode.Repeated, - ), - ), - ) - } - } else { - Box(fillModifier.background(fillColor)) - } - } - } - } - - /** - * The live progress body (phase label, bar, percentage, byte counts) for a - * game's in-flight download / verify. Observes [info] directly so it - * refreshes live. Rendered inside [SteamTaskProgressDialog]. - */ - @Composable - private fun SteamTaskProgressBody(info: DownloadInfo) { - var progress by remember(info) { mutableFloatStateOf(info.getProgress()) } - DisposableEffect(info) { - val listener: (Float) -> Unit = { progress = it } - info.addProgressListener(listener) - onDispose { info.removeProgressListener(listener) } - } - val status by info.getStatusFlow().collectAsState() - // The status message carries a unique suffix every progress tick; - // keying the byte sample on it (and on `progress`) keeps the card - // refreshing live — the Downloads-tab row relies on the same. - val statusMessage by info.getStatusMessageFlow().collectAsState() - val fraction = progress.coerceIn(0f, 1f) - val animatedFraction by animateFloatAsState( - targetValue = fraction, - animationSpec = tween(durationMillis = 400), - label = "steamTaskProgress", - ) - val (doneBytes, totalBytes) = - remember(progress, statusMessage) { info.getDisplayBytesProgress() } - - val phaseText = - when (status) { - DownloadPhase.VERIFYING -> stringResource(R.string.downloads_queue_phase_verifying) - DownloadPhase.DOWNLOADING -> stringResource(R.string.downloads_queue_phase_downloading) - DownloadPhase.PAUSED -> stringResource(R.string.downloads_queue_phase_paused) - DownloadPhase.QUEUED -> stringResource(R.string.downloads_queue_phase_queued) - DownloadPhase.PREPARING -> stringResource(R.string.downloads_queue_phase_preparing) - DownloadPhase.PATCHING -> stringResource(R.string.downloads_queue_phase_patching) - DownloadPhase.APPLYING_DATA -> stringResource(R.string.downloads_queue_phase_applying_data) - DownloadPhase.UNPACKING -> stringResource(R.string.downloads_queue_phase_unpacking) - DownloadPhase.FINALIZING -> stringResource(R.string.downloads_queue_phase_finalizing) - DownloadPhase.COMPLETE -> stringResource(R.string.downloads_queue_phase_complete) - DownloadPhase.FAILED -> stringResource(R.string.common_ui_failed) - DownloadPhase.CANCELLED -> stringResource(R.string.downloads_queue_phase_cancelled) - else -> stringResource(R.string.common_ui_working) - } - val phaseColor = - when (status) { - DownloadPhase.COMPLETE -> StatusOnline - DownloadPhase.FAILED, DownloadPhase.CANCELLED -> DangerRed - DownloadPhase.PAUSED, DownloadPhase.QUEUED -> StatusAway - else -> Accent - } - - Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(phaseText, color = phaseColor, fontSize = 14.sp, fontWeight = FontWeight.Bold) - Text( - "${(fraction * 100).toInt()}%", - color = TextPrimary, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - ) - } - DownloadChasingProgressBar( - progress = animatedFraction, - status = status, - animationsActive = true, - modifier = Modifier.fillMaxWidth().height(10.dp), - ) - Text( - if (totalBytes > 0L) { - "${StorageUtils.formatDecimalSize(doneBytes)} / " + - StorageUtils.formatDecimalSize(totalBytes) - } else { - " " - }, - color = TextSecondary, - fontSize = 11.sp, - ) - } - } - - /** - * Activity-root host for the Verify Files progress pop-up + completion - * notice. Rendered once near the NavHost so it outlives the game-detail - * dialogs that start the task — the verify-completion library refresh - * tears those down, and a dialog-scoped watcher would miss COMPLETE. - * - * Reads the `taskProgress*` activity fields; [showTaskProgressPopup] - * populates them. The watcher is keyed on [taskProgressInfo] so it - * re-attaches if recomposed and (because the status is a StateFlow) - * still observes a terminal phase that landed in between. - */ - @Composable - private fun TaskProgressHost() { - val info = taskProgressInfo - LaunchedEffect(info) { - if (info == null) return@LaunchedEffect - info.getStatusFlow().collect { st -> - when (st) { - DownloadPhase.COMPLETE -> { - // Snapshot first: `taskProgressShown = false` can trigger - // a follow-up task that overwrites these fields before we read. - val msg = taskProgressCompleteMsg - val asToast = taskProgressCompleteAsToast - taskProgressShown = false - taskProgressInfo = null - if (asToast) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this@UnifiedActivity, - msg, - android.widget.Toast.LENGTH_SHORT, - ) - } else { - taskDoneFailed = false - taskDoneMessage = msg - } - } - DownloadPhase.FAILED -> { - taskProgressShown = false - taskDoneFailed = true - taskDoneMessage = taskProgressFailedMsg - taskProgressInfo = null - } - DownloadPhase.CANCELLED -> { - taskProgressShown = false - taskProgressInfo = null - } - else -> Unit - } - } - } - if (taskCheckingShown) { - TaskCheckingDialog( - gameName = taskCheckingGameName, - onDismissRequest = { taskCheckingShown = false }, - ) - } - if (info != null && taskProgressShown) { - SteamTaskProgressDialog( - info = info, - gameName = taskProgressGameName, - onDismissRequest = { taskProgressShown = false }, - ) - } - taskDoneMessage?.let { msg -> - TaskCompleteDialog( - message = msg, - failed = taskDoneFailed, - onClose = { taskDoneMessage = null }, - ) - } - } - - /** - * Indeterminate "Checking for updates" pop-up — same frame as - * [SteamTaskProgressDialog] but without a known task to track. Dismissable; - * the underlying check keeps running and the host shows the result. - */ - @Composable - private fun TaskCheckingDialog( - gameName: String, - onDismissRequest: () -> Unit, - ) { - Dialog(onDismissRequest = onDismissRequest) { - PopupDialog( - title = gameName, - message = stringResource(R.string.store_game_checking_updates), - icon = Icons.Outlined.Sync, - accentColor = Accent, - modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), - content = { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = Accent, - ) - }, - footer = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - PopupTextAction( - label = stringResource(R.string.common_ui_close), - textColor = Accent, - onClick = onDismissRequest, - ) - } - }, - ) - } - } - - /** - * Dismissable pop-up showing live progress for a Steam task (verify / - * update). Tapping outside closes it — the task keeps running and stays - * visible in the Downloads tab. The host watches the task to completion - * separately and shows [TaskCompleteDialog] when it finishes. - */ - @Composable - private fun SteamTaskProgressDialog( - info: DownloadInfo, - gameName: String, - onDismissRequest: () -> Unit, - ) { - Dialog(onDismissRequest = onDismissRequest) { - PopupDialog( - title = gameName, - icon = Icons.Outlined.Download, - accentColor = Accent, - modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), - content = { - SteamTaskProgressBody(info) - Text( - stringResource(R.string.store_game_progress_background_hint), - color = TextSecondary, - fontSize = 11.sp, - ) - }, - footer = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - PopupTextAction( - label = stringResource(R.string.common_ui_close), - textColor = Accent, - onClick = onDismissRequest, - ) - } - }, - ) - } - } - - /** - * Small completion notice ("Verify Files Complete" / " Failed") with a - * single Close button. Shown by the host once a watched task finishes. - */ - @Composable - private fun TaskCompleteDialog(message: String, failed: Boolean, onClose: () -> Unit) { - Dialog(onDismissRequest = onClose) { - PopupDialog( - title = message, - icon = if (failed) Icons.Outlined.Warning else Icons.Outlined.CheckCircle, - accentColor = if (failed) DangerRed else StatusOnline, - confirmButtonColor = Accent, - confirmLabel = stringResource(R.string.common_ui_close), - onConfirm = onClose, - modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), - ) - } - } - - @Composable - private fun DownloadCancelWarningMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - onConfirm: () -> Unit, - isCancelAll: Boolean = false, - ) { - val titleRes = if (isCancelAll) R.string.downloads_queue_cancel_all_title else R.string.downloads_queue_cancel_download_title - val messageRes = if (isCancelAll) R.string.downloads_queue_cancel_all_warning else R.string.downloads_queue_cancel_download_warning - val confirmRes = if (isCancelAll) R.string.downloads_queue_cancel_all else R.string.downloads_queue_cancel_download - LaunchDangerConfirmDialog( - visible = expanded, - title = stringResource(titleRes), - message = stringResource(messageRes), - confirmLabel = stringResource(confirmRes), - onDismissRequest = onDismissRequest, - onConfirm = onConfirm, - icon = Icons.Outlined.Warning, - titleTextAlign = TextAlign.Center, - messageTextAlign = TextAlign.Center, - ) - } - - @Composable - fun DownloadItemDeck( - id: String, - info: DownloadInfo, - isSelected: Boolean, - animationsActive: Boolean, - onClick: () -> Unit, - ) { - var progress by remember { mutableFloatStateOf(info.getProgress()) } - var showDeleteDialog by remember { mutableStateOf(false) } - - DisposableEffect(info) { - val listener: (Float) -> Unit = { progress = it } - info.addProgressListener(listener) - onDispose { info.removeProgressListener(listener) } - } - val status by info.getStatusFlow().collectAsState() - val statusMessage by info.getStatusMessageFlow().collectAsState() - var previousStatus by remember { mutableStateOf(status) } - var showCompletedProgressBar by remember { mutableStateOf(status != DownloadPhase.COMPLETE) } - val isSteam = id.startsWith("STEAM_") - val isEpic = id.startsWith("EPIC_") - val isGog = id.startsWith("GOG_") - val appId = - if (isSteam) { - id.removePrefix("STEAM_").toIntOrNull() ?: 0 - } else if (isEpic) { - id.removePrefix("EPIC_").toIntOrNull() ?: 0 - } else { - 0 - } - val gogId = if (isGog) id.removePrefix("GOG_") else "" - - var steamApp by remember(appId) { mutableStateOf(null) } - var epicGame by remember(appId) { mutableStateOf(null) } - var gogGame by remember(gogId) { mutableStateOf(null) } - val context = LocalContext.current - val clickInteractionSource = remember { MutableInteractionSource() } - val animatedProgress by animateFloatAsState( - targetValue = if (status == DownloadPhase.COMPLETE) 1f else progress.coerceIn(0f, 1f), - animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing), - label = "downloadItemProgress", - ) - - LaunchedEffect(status) { - if (status == DownloadPhase.COMPLETE) { - if (previousStatus != DownloadPhase.COMPLETE) { - showCompletedProgressBar = true - delay(900) - } - showCompletedProgressBar = false - } else { - showCompletedProgressBar = true - } - previousStatus = status - } - - LaunchedEffect(appId, gogId, isSteam, isEpic, isGog) { - withContext(Dispatchers.IO) { - if (isSteam) { - steamApp = db.steamAppDao().findApp(appId) - } else if (isEpic) { - epicGame = EpicService.getEpicGameOf(appId) - } else if (isGog) { - gogGame = GOGService.getGOGGameOf(gogId) - } - } - } - - val unknownGameLabel = stringResource(R.string.library_games_unknown_game) - val displayName = - if (isSteam) { - steamApp?.name - } else if (isEpic) { - epicGame?.title - } else if (isGog) { - gogGame?.title - } else { - unknownGameLabel - } - val displayImage = - if (isSteam) { - steamApp?.getHeaderImageUrl() - } else if (isEpic) { - epicGame?.primaryImageUrl ?: epicGame?.iconUrl - } else if (isGog) { - gogGame?.imageUrl ?: gogGame?.iconUrl - } else { - null - } - - Surface( - color = if (isSelected) DownloadCardSelectedBlack else DownloadCardBlack, - shape = RoundedCornerShape(12.dp), - modifier = - Modifier - .fillMaxWidth() - .chasingBorder( - isFocused = isSelected, - paused = chasingBordersPaused.value || !animationsActive, - cornerRadius = 12.dp, - borderWidth = 2.dp, - animationDurationMs = 8000, - ) - .paneNavItem( - cornerRadius = 12.dp, - onActivate = onClick, - onSecondary = { - if (status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED) { - showDeleteDialog = true - } - }, - ) - .clickable( - interactionSource = clickInteractionSource, - indication = null, - onClick = onClick, - ), - ) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(displayImage) - .crossfade(300) - .build(), - contentDescription = null, - modifier = Modifier.size(120.dp, 68.dp).clip(RoundedCornerShape(4.dp)), - contentScale = ContentScale.Crop, - ) - - Spacer(Modifier.width(16.dp)) - - Column(Modifier.weight(1f)) { - val currentFile by info.getCurrentFileNameFlow().collectAsState() - val (downloadedBytes, totalBytes) = info.getDisplayBytesProgress() - val speed = info.getCurrentDownloadSpeed() ?: 0L - val percentage = (animatedProgress * 100).roundToInt() - val showDownloadSpeed = - status == DownloadPhase.DOWNLOADING && - progress < 1f && - speed > 0 - - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - displayName ?: unknownGameLabel, - fontWeight = FontWeight.Bold, - color = TextPrimary, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - // Centered Size Info - Text( - text = "${StorageUtils.formatDecimalSize(downloadedBytes)} / ${StorageUtils.formatDecimalSize(totalBytes)}", - style = MaterialTheme.typography.labelMedium, - color = TextSecondary, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - ) - - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { - if (showDownloadSpeed) { - Text( - text = StorageUtils.formatBitsPerSecond(speed), - style = MaterialTheme.typography.labelMedium, - color = Accent, - fontWeight = FontWeight.Bold, - ) - } - } - } - - val statusText = - when (status) { - DownloadPhase.DOWNLOADING -> { - currentFile?.let { - stringResource(R.string.downloads_queue_phase_downloading_file, it.take(10)) - } ?: stringResource(R.string.downloads_queue_phase_downloading) - } - - DownloadPhase.PAUSED -> { - stringResource(R.string.downloads_queue_phase_paused) - } - - DownloadPhase.QUEUED -> { - stringResource(R.string.downloads_queue_phase_queued) - } - - DownloadPhase.PREPARING -> { - stringResource(R.string.downloads_queue_phase_preparing) - } - - DownloadPhase.VERIFYING -> { - currentFile?.let { - stringResource(R.string.downloads_queue_phase_verifying_file, it.take(10)) - } ?: stringResource(R.string.downloads_queue_phase_verifying) - } - - DownloadPhase.PATCHING -> { - stringResource(R.string.downloads_queue_phase_patching) - } - - DownloadPhase.APPLYING_DATA -> { - stringResource(R.string.downloads_queue_phase_applying_data) - } - - DownloadPhase.FINALIZING -> { - stringResource(R.string.downloads_queue_phase_finalizing) - } - - DownloadPhase.UNPACKING -> { - stringResource(R.string.downloads_queue_phase_unpacking) - } - - DownloadPhase.COMPLETE -> { - stringResource(R.string.downloads_queue_phase_complete) - } - - DownloadPhase.CANCELLED -> { - stringResource(R.string.downloads_queue_phase_cancelled) - } - - DownloadPhase.FAILED -> { - stringResource( - R.string.downloads_queue_phase_failed, - if (statusMessage != null && - statusMessage != "null" - ) { - statusMessage!! - } else { - stringResource(R.string.common_ui_unknown_error) - }, - ) - } - - else -> { - stringResource(R.string.downloads_queue_phase_unknown) - } - } - val statusColor = - when (status) { - DownloadPhase.COMPLETE -> StatusOnline - DownloadPhase.FAILED, - DownloadPhase.CANCELLED, - -> DangerRed - DownloadPhase.PAUSED, - DownloadPhase.QUEUED, - -> StatusAway - DownloadPhase.DOWNLOADING, - DownloadPhase.PREPARING, - DownloadPhase.VERIFYING, - DownloadPhase.PATCHING, - DownloadPhase.APPLYING_DATA, - DownloadPhase.FINALIZING, - DownloadPhase.UNPACKING, - -> Accent - else -> TextSecondary - } - - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.downloads_queue_status_label), - style = MaterialTheme.typography.bodySmall, - color = TextSecondary, - maxLines = 1, - ) - Spacer(Modifier.width(4.dp)) - Text( - statusText, - style = MaterialTheme.typography.bodySmall, - color = statusColor, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - AnimatedVisibility( - visible = status != DownloadPhase.COMPLETE || showCompletedProgressBar, - exit = fadeOut(tween(180)) + shrinkVertically(tween(180)), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - DownloadChasingProgressBar( - progress = if (status == DownloadPhase.COMPLETE) 1f else animatedProgress, - status = status, - animationsActive = animationsActive, - modifier = Modifier.weight(1f).height(9.dp).padding(end = 10.dp), - ) - Text( - text = "$percentage%", - style = MaterialTheme.typography.labelMedium, - color = if (status == DownloadPhase.COMPLETE) StatusOnline else TextPrimary, - modifier = Modifier.width(40.dp), - ) - } - } - } - - Box(contentAlignment = Alignment.Center) { - IconButton( - onClick = { showDeleteDialog = true }, - enabled = status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED, - ) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.downloads_queue_cancel_download), - tint = - if (status != DownloadPhase.COMPLETE && - status != DownloadPhase.CANCELLED - ) { - Color(0xFFFF6B6B) - } else { - TextSecondary - }, - ) - } - if (showDeleteDialog) { - DownloadCancelWarningMenu( - expanded = true, - onDismissRequest = { showDeleteDialog = false }, - onConfirm = { - showDeleteDialog = false - DownloadService.cancelDownload(id) - }, - ) - } - } - if (ControllerHelper.isControllerConnected()) { - Spacer(Modifier.width(8.dp)) - ControllerBadge(if (ControllerHelper.isPlayStationController()) "\u2715" else "A") - } - } - } - } - - // Game Manager Dialog - @Composable - fun GameManagerDialog( - app: SteamApp, - onDismissRequest: () -> Unit, - ) { - val context = LocalContext.current - var isLoading by remember { mutableStateOf(true) } - var selectedManifestSizes by remember { mutableStateOf(SteamService.ManifestSizes()) } - var baseInstallSize by remember(app.id) { mutableStateOf(0L) } - var dlcApps by remember { mutableStateOf>(emptyList()) } - var dlcSizes by remember { mutableStateOf>(emptyMap()) } - var installedDlcIds by remember(app.id) { mutableStateOf>(emptySet()) } - var installed by remember(app.id) { mutableStateOf(null) } - val selectedDlcIds = remember { mutableStateListOf() } - var customPath by remember { mutableStateOf(null) } - var showCustomPathWarning by remember { mutableStateOf(false) } - var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } - var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } - var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } - var updateInfo by remember(app.id) { mutableStateOf(null) } - var updateStatusText by remember(app.id) { mutableStateOf(null) } - val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( - initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), - ) - val scope = rememberCoroutineScope() - - if (showCustomPathWarning) { - CustomPathWarningDialog( - onDismiss = { showCustomPathWarning = false }, - onProceed = { - showCustomPathWarning = false - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: SteamService.defaultAppInstallPath, - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - }, - ) - } - - data class SteamInstallLoadData( - val dlcApps: List, - val dlcSizes: Map, - val installedDlcIds: Set, - val baseManifestSizes: SteamService.ManifestSizes, - val installed: Boolean, - ) - - LaunchedEffect(app.id, downloadRecords) { - val loadData = - withContext(Dispatchers.IO) { - val selectableDlcApps = SteamService.getSelectableDlcAppsOf(app.id) - val perDlcSizes = - selectableDlcApps.associate { dlc -> - dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id) - } - val installedDlcIds = - SteamService.getInstalledDlcDepotsOf(app.id) - .orEmpty() - .toSet() - SteamInstallLoadData( - dlcApps = selectableDlcApps, - dlcSizes = perDlcSizes, - installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id), - installed = SteamService.isAppInstalled(app.id), - ) - } - dlcApps = loadData.dlcApps - dlcSizes = loadData.dlcSizes - installedDlcIds = loadData.installedDlcIds - selectedDlcIds.removeAll(loadData.installedDlcIds) - selectedManifestSizes = loadData.baseManifestSizes - baseInstallSize = loadData.baseManifestSizes.installSize - installed = loadData.installed - isLoading = false - } - - LaunchedEffect(app.id, selectedDlcIds.toList()) { - selectedManifestSizes = - withContext(Dispatchers.IO) { - SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) - } - } - - val totalDownloadSize = selectedManifestSizes.downloadSize - val totalInstallSize = selectedManifestSizes.installSize - val defaultPathSet = - if (PrefManager.useSingleDownloadFolder) { - PrefManager.defaultDownloadFolder.isNotEmpty() - } else { - PrefManager.steamDownloadFolder - .isNotEmpty() - } - val effectivePath = customPath ?: SteamService.defaultAppInstallPath - val availableBytes = - try { - StorageUtils.getAvailableSpace(effectivePath) - } catch (e: Exception) { - 0L - } - // For an already-installed game the base content is on disk, so only require free - // space for the newly-selected DLC (already-installed DLC is excluded from selection). - val requiredBytes = - if (installed == true) (totalInstallSize - baseInstallSize).coerceAtLeast(0L) else totalInstallSize - val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes - val installPathDisplay = customPath ?: SteamService.defaultAppInstallPath - - val dlcItems = - remember(dlcApps, dlcSizes, installedDlcIds) { - dlcApps.map { dlc -> - val sizes = dlcSizes[dlc.id] - val size = - sizes - ?.downloadSize - ?.takeIf { it > 0L } - ?: sizes?.installSize - ?: 0L - StoreDlcItem( - id = dlc.id, - name = dlc.name, - downloadSize = size, - isInstalled = dlc.id in installedDlcIds, - ) - } - } - val customPathLabel = - when { - customPath != null -> stringResource(R.string.common_ui_custom) - defaultPathSet -> stringResource(R.string.common_ui_already_set) - else -> stringResource(R.string.common_ui_custom) - } - val isReallyInstalled = installed == true - val steamDownloadRecord = - downloadRecords.firstOrNull { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && - it.storeGameId == app.id.toString() && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - ) - } - val hasBlockingSteamDownload = - downloadRecords.any { - it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && - it.storeGameId == app.id.toString() && - it.status in setOf( - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, - com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, - ) - } - val updateActionEnabled = steamDownloadRecord == null - val installActionEnabled = isInstallEnabled && steamDownloadRecord == null - val activeSteamDownloadText = stringResource(R.string.store_game_download_already_active) - val noUpdateAvailableText = stringResource(R.string.store_game_no_update_available) - val updateAvailableText = stringResource(R.string.store_game_update_available) - val updateFailedText = stringResource(R.string.store_game_update_check_failed) - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxSize(), - shape = RectangleShape, - color = Color.Black, - ) { - StoreGameDetailScreen( - title = app.name, - subtitle = - listOfNotNull( - app.developer.takeIf { it.isNotBlank() }, - app.publisher.takeIf { - it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) - }, - ).joinToString(" • "), - sourceLabel = "Steam", - heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.steamRef(app, "hero", app.getHeroUrl())), - isLoading = isLoading, - isInstalled = isReallyInstalled, - installPathDisplay = installPathDisplay, - downloadSize = totalDownloadSize, - installSize = totalInstallSize, - availableBytes = availableBytes, - isInstallEnabled = isInstallEnabled, - isDownloadActionEnabled = installActionEnabled, - customPathLabel = customPathLabel, - showCustomPath = true, - showCloudSync = false, - showUninstall = false, - showUpdateCheck = true, - isCheckingForUpdate = isCheckingForUpdate, - isUpdateAvailable = updateInfo?.hasUpdate == true, - updateDownloadSize = updateInfo?.downloadSize ?: 0L, - updateStatusText = updateStatusText, - isUpdateActionEnabled = updateActionEnabled, - isUpdateCheckCoolingDown = isUpdateCheckCoolingDown, - // Shown for any installed game; titles without UGC simply - // open to an empty Workshop window (handled gracefully). - showWorkshop = isReallyInstalled, - showVerifyFiles = isReallyInstalled, - areSteamActionsEnabled = !hasBlockingSteamDownload, - dlcs = dlcItems, - selectedDlcIds = selectedDlcIds.toSet(), - isDlcSelectionEnabled = steamDownloadRecord == null, - onBack = onDismissRequest, - onInstall = { - if (steamDownloadRecord != null) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeSteamDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch(Dispatchers.IO) { - val installableDlcIds = dlcItems - .filter { !it.isInstalled && it.id in selectedDlcIds } - .map { it.id } - SteamService.downloadApp(app.id, installableDlcIds, false, customPath) - withContext(Dispatchers.Main) { onDismissRequest() } - } - } - }, - onCheckForUpdate = { startUpdateCheck(app.id, app.name) }, - onWorkshop = { showWorkshopDialog = true }, - onVerifyFiles = { - if (steamDownloadRecord != null) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - activeSteamDownloadText, - android.widget.Toast.LENGTH_SHORT, - ) - return@StoreGameDetailScreen - } - context.runIfOnlineOrToast { - scope.launch { - val started = - withContext(Dispatchers.IO) { - SteamService.downloadAppForVerify(app.id) - } - if (started != null) { - // Hand off to the activity-root host so the - // pop-up + completion notice outlive this dialog. - showTaskProgressPopup( - started, - app.name, - getString(R.string.store_game_verify_complete), - getString(R.string.store_game_verify_failed_notice), - completeAsToast = true, - ) - } - } - } - }, - onDownloadUpdate = { - if (!updateActionEnabled || updateInfo?.hasUpdate != true) return@StoreGameDetailScreen - context.runIfOnlineOrToast { - scope.launch(Dispatchers.IO) { - try { - val latest = SteamService.checkForAppUpdate(app.id) - withContext(Dispatchers.Main) { - updateInfo = latest - updateStatusText = - when { - latest.hasUpdate -> updateAvailableText - latest.message != null -> updateFailedText - else -> null - } - } - if (!latest.hasUpdate) { - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - noUpdateAvailableText, - android.widget.Toast.LENGTH_SHORT, - ) - } - return@launch - } - - SteamService.downloadAppForUpdate(app.id, latest.depotIds) - withContext(Dispatchers.Main) { onDismissRequest() } - } catch (e: Exception) { - Log.w("UnifiedActivity", "Steam update download failed to start for appId=${app.id}", e) - withContext(Dispatchers.Main) { - updateStatusText = updateFailedText - } - } - } - } - }, - onCustomPath = { - if (customPath == null && defaultPathSet) { - showCustomPathWarning = true - } else { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = customPath ?: SteamService.defaultAppInstallPath, - title = getString(R.string.settings_content_install_directory), - extraRoots = driveRoots(includeInternal = true), - ) { path -> customPath = path } - } - }, - onToggleDlc = { id -> - if (steamDownloadRecord != null) { - return@StoreGameDetailScreen - } - if (dlcItems.any { it.id == id && it.isInstalled }) { - return@StoreGameDetailScreen - } - if (selectedDlcIds.contains(id)) { - selectedDlcIds.remove(id) - } else { - selectedDlcIds.add(id) - } - }, - onToggleSelectAllDlcs = { - if (steamDownloadRecord != null) { - return@StoreGameDetailScreen - } - val selectableDlcItems = dlcItems.filterNot { it.isInstalled } - val all = selectableDlcItems.isNotEmpty() && selectableDlcItems.all { it.id in selectedDlcIds } - if (all) { - selectedDlcIds.removeAll(selectableDlcItems.map { it.id }.toSet()) - } else { - selectableDlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } - } - }, - ) - } - } - - if (showWorkshopDialog) { - WorkshopDialog( - appId = app.id, - gameTitle = app.name, - onDismissRequest = { showWorkshopDialog = false }, - ) - } - } - - @Composable - private fun WorkshopDialog( - appId: Int, - gameTitle: String, - onDismissRequest: () -> Unit, - ) { - var loadState by remember(appId) { mutableStateOf(WorkshopLoadState.LOADING) } - var errorMessage by remember(appId) { mutableStateOf(null) } - var allItems by remember(appId) { mutableStateOf>(emptyList()) } - var query by remember(appId) { mutableStateOf("") } - // Published-file-ids with an install OR uninstall in flight. - val busyIds = remember(appId) { mutableStateListOf() } - var reloadKey by remember(appId) { mutableStateOf(0) } - val scope = rememberCoroutineScope() - - LaunchedEffect(appId, reloadKey) { - loadState = WorkshopLoadState.LOADING - errorMessage = null - // Drop any in-flight spinners — a reload re-fetches the list. - busyIds.clear() - try { - val items = - withContext(Dispatchers.IO) { - val json = SteamService.getSubscribedWorkshopItems(appId) - if (json == null) { - null - } else { - val installed = - com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator - .installedItemIds(applicationContext, appId) - parseWorkshopItemsJson(json, installed) - } - } - if (items == null) { - errorMessage = - "Couldn't load your Workshop subscriptions. " + - "Make sure you're signed in to Steam and online." - loadState = WorkshopLoadState.ERROR - } else { - allItems = items - loadState = WorkshopLoadState.READY - } - } catch (e: Exception) { - Log.w("UnifiedActivity", "Workshop load failed for appId=$appId", e) - errorMessage = e.message - loadState = WorkshopLoadState.ERROR - } - } - - val filtered = - remember(allItems, query) { - val q = query.trim() - if (q.isBlank()) { - allItems - } else { - allItems.filter { - it.title.contains(q, ignoreCase = true) || - it.author.contains(q, ignoreCase = true) - } - } - } - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - StoreWorkshopScreen( - gameTitle = gameTitle, - loadState = loadState, - errorMessage = errorMessage, - items = filtered, - query = query, - // Snapshotted here inside the Dialog content lambda: a mutation of - // the SnapshotStateList invalidates this scope, so .toSet() re-runs. - busyIds = busyIds.toSet(), - onQueryChange = { query = it }, - onInstall = { id -> - val item = allItems.firstOrNull { it.publishedFileId == id } - if (item != null && id !in busyIds) { - if (item.manifestId == 0L) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this@UnifiedActivity, - "This Workshop item has no downloadable content", - android.widget.Toast.LENGTH_SHORT, - ) - } else { - busyIds.add(id) - scope.launch { - val ok = - SteamService.installWorkshopItem( - appId = appId, - publishedFileId = item.publishedFileId, - manifestId = item.manifestId, - title = item.title, - fileSizeBytes = item.fileSizeBytes, - timeUpdated = item.timeUpdated, - previewUrl = item.previewImageUrl ?: "", - ) - if (ok) { - allItems = - allItems.map { - if (it.publishedFileId == id) it.copy(isInstalled = true) else it - } - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this@UnifiedActivity, - "Workshop download failed — check your Steam connection", - android.widget.Toast.LENGTH_LONG, - ) - } - busyIds.remove(id) - } - } - } - }, - onUninstall = { id -> - if (id !in busyIds) { - busyIds.add(id) - scope.launch { - val ok = - withContext(Dispatchers.IO) { - com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator - .uninstall(applicationContext, appId, id) - } - if (ok) { - allItems = - allItems.map { - if (it.publishedFileId == id) it.copy(isInstalled = false) else it - } - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - this@UnifiedActivity, - "Couldn't uninstall this Workshop item", - android.widget.Toast.LENGTH_SHORT, - ) - } - busyIds.remove(id) - } - } - }, - onRetry = { reloadKey++ }, - onClose = onDismissRequest, - ) - } - } - - private fun findLibraryShortcutForGame( - containerManager: ContainerManager, - app: SteamApp, - isCustom: Boolean, - isEpic: Boolean, - epicId: Int, - ): Shortcut? = findShortcutForGame(containerManager.loadShortcuts(), app, isCustom, isEpic, epicId) - - private fun findShortcutForGame( - shortcuts: List, - app: SteamApp, - isCustom: Boolean, - isEpic: Boolean, - epicId: Int, - ): Shortcut? = - when { - isEpic -> { - shortcuts.find { - it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == epicId.toString() - } - } - - else -> { - shortcuts.find { - it.getExtra("app_id") == app.id.toString() || it.getExtra("custom_name") == app.name || it.name == app.name - } - } - } - - private fun findLibraryArtworkShortcut( - shortcuts: List, - app: SteamApp, - gogGame: GOGGame?, - epicGame: EpicGame?, - ): Shortcut? = - when { - gogGame != null -> { - shortcuts.find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id - } - } - - epicGame != null -> { - shortcuts.find { - it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == epicGame.id.toString() - } - } - - else -> { - findShortcutForGame( - shortcuts = shortcuts, - app = app, - isCustom = app.id < 0, - isEpic = app.id >= 2000000000, - epicId = if (app.id >= 2000000000) app.id - 2000000000 else 0, - ) - } - } - - private fun Shortcut.hasExistingArtwork(extraKey: String): Boolean = - (getExtra(extraKey) - .takeIf { it.isNotBlank() } - ?.let { java.io.File(it).isFile } == true) - - private fun artworkCacheId( - app: SteamApp, - gogGame: GOGGame?, - epicGame: EpicGame?, - ): ArtworkCacheId? = - when { - gogGame != null -> ArtworkCacheId("gog", gogGame.id) - epicGame != null -> ArtworkCacheId("epic", epicGame.id.toString()) - app.id >= 0 -> ArtworkCacheId("steam", app.id.toString()) - else -> null - } - - private fun customArtworkOverrideSlots( - app: SteamApp, - gogGame: GOGGame?, - epicGame: EpicGame?, - hasDefaultCustomArt: Boolean, - hasGridCustomArt: Boolean, - hasCarouselCustomArt: Boolean, - hasListCustomArt: Boolean, - hasHeroCustomArt: Boolean, - ): Set { - val overridesPrimary = hasDefaultCustomArt || hasGridCustomArt || hasCarouselCustomArt || hasListCustomArt - if (!overridesPrimary && !hasHeroCustomArt) return emptySet() - - return when { - gogGame != null -> { - buildSet { - if (overridesPrimary) { - add("cover") - add("icon") - } - if (hasHeroCustomArt) add("hero") - } - } - - epicGame != null -> { - buildSet { - if (overridesPrimary) { - add("cover") - add("square") - add("logo") - } - if (hasHeroCustomArt) add("hero") - } - } - - app.id >= 0 -> { - buildSet { - if (hasDefaultCustomArt || hasGridCustomArt) add("capsule") - if (hasDefaultCustomArt || hasCarouselCustomArt) add("library_capsule") - if (hasDefaultCustomArt || hasListCustomArt) add("small_capsule") - if (hasHeroCustomArt) add("hero") - } - } - - else -> emptySet() - } - } - - private fun isShortcutCloudSyncEnabled(shortcut: Shortcut?): Boolean = - shortcut == null || shortcut.getExtra("cloud_sync_disabled", "0") != "1" - - private fun setShortcutCloudSyncEnabled( - shortcut: Shortcut?, - enabled: Boolean, - ) { - if (shortcut == null) return - shortcut.putExtra("cloud_sync_disabled", if (enabled) null else "1") - if (enabled) { - shortcut.putExtra("cloud_force_download", null) - } - shortcut.saveData() - } - - private fun isShortcutOfflineMode(shortcut: Shortcut?): Boolean = - shortcut != null && shortcut.getExtra("offline_mode", "0") == "1" - - private fun setShortcutOfflineMode( - shortcut: Shortcut?, - enabled: Boolean, - ) { - if (shortcut == null) return - shortcut.putExtra("offline_mode", if (enabled) "1" else null) - shortcut.saveData() - } - - private fun repairShortcutDisplayNameIfNeeded( - shortcut: Shortcut, - displayName: String, - vararg technicalNames: String, - ) { - if (displayName.isBlank() || !shortcut.file.isFile) return - - runCatching { - val technicalNameSet = (technicalNames.toList() + shortcut.file.nameWithoutExtension) - .filter { it.isNotBlank() } - .toSet() - val lines = com.winlator.cmod.shared.io.FileUtils.readLines(shortcut.file) - val sb = StringBuilder() - var changed = false - var sawName = false - - for (line in lines) { - if (line.startsWith("Name=")) { - sawName = true - val currentName = line.removePrefix("Name=").trim() - if (currentName.isBlank() || currentName in technicalNameSet) { - sb.append("Name=").append(displayName).append('\n') - changed = true - } else { - sb.append(line).append('\n') - } - } else { - sb.append(line).append('\n') - } - } - - if (!sawName) { - val desktopHeader = "[Desktop Entry]\n" - val insertIndex = - if (sb.startsWith(desktopHeader)) { - desktopHeader.length - } else { - 0 - } - sb.insert(insertIndex, "Name=$displayName\n") - changed = true - } - - if (changed) { - com.winlator.cmod.shared.io.FileUtils.writeString(shortcut.file, sb.toString()) - } - }.onFailure { - Log.w("SHORTCUTS", "Failed to repair shortcut display name for ${shortcut.file.name}", it) - } - } - - private fun resolveLibraryShortcutArtworkModel( - context: android.content.Context, - app: SteamApp, - isCustom: Boolean, - isEpic: Boolean, - epicArtworkUrl: String?, - ): Any? = - when { - isCustom -> { - val safeName = app.name.replace("/", "_").replace("\\", "_") - val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") - if (iconFile.exists()) iconFile else null - } - - isEpic -> { - epicArtworkUrl?.takeIf { it.isNotBlank() } - } - - else -> { - app.getCapsuleUrl() - } - } - - private suspend fun loadArtworkBitmap( - context: android.content.Context, - artworkModel: Any?, - ): Bitmap? { - if (artworkModel == null) return null - return try { - val request = - ImageRequest - .Builder(context) - .data(artworkModel) - .allowHardware(false) - .size(192, 192) - .build() - val result = context.imageLoader.execute(request) - val drawable = result.drawable ?: return null - if (drawable is BitmapDrawable) { - drawable.bitmap - } else { - val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 192 - val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 192 - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, width, height) - drawable.draw(canvas) - bitmap - } - } catch (_: Exception) { - null - } - } - - private suspend fun requestPinnedHomeShortcut( - context: android.content.Context, - shortcut: Shortcut, - artworkModel: Any? = null, - ): Boolean { - if (shortcut.getExtra("uuid").isEmpty()) { - shortcut.genUUID() - } - val shortcutId = shortcut.getExtra("uuid") - if (shortcutId.isEmpty()) return false - val canonicalShortcutPath = shortcut.file.absolutePath - val shortcutPathHash = canonicalShortcutPath.hashCode() - val containerIdForLaunch = shortcut.getExtra("container_id").toIntOrNull() ?: shortcut.container.id - val pinShortcutId = "shortcut_${shortcut.container.id}_${shortcutId}_${shortcutPathHash.toUInt().toString(16)}" - - val shortcutManager = context.getSystemService(android.content.pm.ShortcutManager::class.java) ?: return false - if (!shortcutManager.isRequestPinShortcutSupported) return false - - val launchIntent = - Intent(context, XServerDisplayActivity::class.java).apply { - val launchData = - Uri - .Builder() - .scheme("winnative") - .authority(BuildConfig.APPLICATION_ID) - .appendPath("shortcut") - .appendQueryParameter("uuid", shortcutId) - .appendQueryParameter("container", containerIdForLaunch.toString()) - .appendQueryParameter("hash", shortcutPathHash.toString()) - .build() - action = Intent.ACTION_VIEW - data = launchData - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra("container_id", containerIdForLaunch) - putExtra("shortcut_path", canonicalShortcutPath) - putExtra("shortcut_name", shortcut.name) - putExtra("shortcut_uuid", shortcutId) - putExtra("shortcut_path_hash", shortcutPathHash) - putExtra(XServerDisplayActivity.EXTRA_LAUNCHED_FROM_PINNED_SHORTCUT, true) - } - - val customIconPath = - shortcut - .getExtra("customLibraryIconPath") - .ifBlank { shortcut.getExtra("customCoverArtPath") } - val customArtworkModel = - customIconPath - .takeIf { it.isNotBlank() } - ?.let { java.io.File(it) } - ?.takeIf { it.exists() } - - val artworkBitmap = loadArtworkBitmap(context, customArtworkModel) ?: loadArtworkBitmap(context, artworkModel) - val shortcutIcon = - artworkBitmap?.let { - android.graphics.drawable.Icon - .createWithBitmap(it) - } - ?: shortcut.icon?.let { - android.graphics.drawable.Icon - .createWithBitmap(it) - } - ?: android.graphics.drawable.Icon - .createWithResource(context, R.drawable.icon_shortcut) - - val pinShortcutInfo = - android.content.pm.ShortcutInfo - .Builder(context, pinShortcutId) - .setShortLabel(shortcut.name) - .setLongLabel(shortcut.name) - .setIcon(shortcutIcon) - .setIntent(launchIntent) - .build() - - val callbackIntent = - Intent(context, ShortcutBroadcastReceiver::class.java).apply { - action = ShortcutBroadcastReceiver.ACTION_PIN_SHORTCUT_RESULT - putExtra("shortcut_path", canonicalShortcutPath) - putExtra("shortcut_name", shortcut.name) - } - val callbackFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - val callback = - PendingIntent.getBroadcast( - context, - pinShortcutId.hashCode(), - callbackIntent, - callbackFlags, - ) - - val result = - ShortcutsFragment.pinOrUpdateShortcut( - shortcutManager, - pinShortcutInfo, - ShortcutsFragment.buildPinnedShortcutIds(containerIdForLaunch, shortcutId, canonicalShortcutPath), - callback.intentSender, - ) - if (result == ShortcutsFragment.PinShortcutResult.REUSED_EXISTING) { - val toastIcon = artworkBitmap ?: shortcut.icon - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.shortcuts_list_readded_existing, - toastIcon, - ) - } - return result != ShortcutsFragment.PinShortcutResult.FAILED - } - - private suspend fun addLibraryShortcutToHomeScreen( - context: android.content.Context, - app: SteamApp, - isCustom: Boolean, - isEpic: Boolean, - epicId: Int, - epicArtworkUrl: String? = null, - ): Boolean { - val containerManager = ContainerManager(context) - val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) ?: return false - val artworkModel = resolveLibraryShortcutArtworkModel(context, app, isCustom, isEpic, epicArtworkUrl) - return requestPinnedHomeShortcut(context, shortcut, artworkModel) - } - - private suspend fun addGogShortcutToHomeScreen( - context: android.content.Context, - app: GOGGame, - artworkUrl: String?, - ): Boolean { - val shortcut = - ContainerManager(context).loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id - } ?: return false - val artworkModel = artworkUrl?.takeIf { it.isNotBlank() } - return requestPinnedHomeShortcut(context, shortcut, artworkModel) - } - - // Game launch with drive-aware mapping - private fun launchSteamGame( - context: android.content.Context, - containerManager: ContainerManager, - app: SteamApp, - joinConnect: String? = null, - ) { - lifecycleScope.launch(Dispatchers.IO) { - val gameInstallPath = SteamService.getAppDirPath(app.id) - val gameDir = java.io.File(gameInstallPath) - if (!gameDir.exists()) { - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Game not installed: ${app.name}", - android.widget.Toast.LENGTH_SHORT, - ) - } - return@launch - } - - val shortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == app.id.toString() - } - val detectedLaunchExecutable = SteamService.getInstalledExe(app.id) - - if (shortcut != null) { - if (!SetupWizardActivity.isContainerUsable(context, shortcut.container)) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer( - context, - shortcut.container.wineVersion, - ) - } - return@launch - } - normalizeContainerDrives(shortcut.container) - shortcut.putExtra("game_source", "STEAM") - shortcut.putExtra("game_install_path", gameInstallPath) - val existingLaunchExecutable = shortcut.getExtra("launch_exe_path") - if (existingLaunchExecutable.isNullOrBlank() && detectedLaunchExecutable.isNotBlank()) { - shortcut.putExtra("launch_exe_path", detectedLaunchExecutable) - } - val loaderExec = "wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" - val lines = - com.winlator.cmod.shared.io.FileUtils - .readLines(shortcut.file) - val rewritten = StringBuilder() - var execUpdated = false - for (line in lines) { - if (line.startsWith("Exec=")) { - rewritten.append("Exec=").append(loaderExec).append("\n") - execUpdated = true - } else { - rewritten.append(line).append("\n") - } - } - if (!execUpdated) { - rewritten.append("Exec=").append(loaderExec).append("\n") - } - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcut.file, rewritten.toString()) - shortcut.saveData() - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", shortcut.container.id) - intent.putExtra("shortcut_path", shortcut.file.path) - intent.putExtra("shortcut_name", shortcut.name) - if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } else { - val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) - - if (container == null) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer(context) - } - return@launch - } - - normalizeContainerDrives(container) - - val execPath = "wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" - - // Generate a shortcut dynamically - val desktopDir = container.getDesktopDir() - if (!desktopDir.exists()) desktopDir.mkdirs() - val shortcutFile = java.io.File(desktopDir, "${app.name.replace("/", "_")}.desktop") - val content = java.lang.StringBuilder() - content.append("[Desktop Entry]\n") - content.append("Type=Application\n") - content.append("Name=${app.name}\n") - content.append("Exec=$execPath\n") - content.append("Icon=steam_icon_${app.id}\n") - content.append("\n[Extra Data]\n") - content.append("game_source=STEAM\n") - content.append("app_id=${app.id}\n") - content.append("container_id=${container.id}\n") - content.append("game_install_path=${gameInstallPath}\n") - content.append("launch_exe_path=${detectedLaunchExecutable}\n") - content.append("use_container_defaults=1\n") - - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcutFile, content.toString()) - - container.saveData() - - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", container.id) - intent.putExtra("shortcut_path", shortcutFile.path) - intent.putExtra("shortcut_name", app.name) - if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } - } - } - - private fun launchEpicGame( - context: android.content.Context, - containerManager: ContainerManager, - app: EpicGame, - ) { - lifecycleScope.launch(Dispatchers.IO) { - val gameInstallPath = app.installPath.takeIf { it.isNotEmpty() } ?: EpicConstants.getGameInstallPath(context, app.appName) - val gameDir = java.io.File(gameInstallPath) - if (!gameDir.exists()) { - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Game not installed: ${app.title}", - android.widget.Toast.LENGTH_SHORT, - ) - } - return@launch - } - - // Try to find an existing shortcut first (preserves per-game settings) - val existingShortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == app.id.toString() - } - - if (existingShortcut != null) { - val launchContainer = - resolveShortcutLaunchContainer(containerManager, existingShortcut) - ?: existingShortcut.container - if (!SetupWizardActivity.isContainerUsable(context, launchContainer)) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer( - context, - launchContainer.wineVersion, - ) - } - return@launch - } - // Existing shortcut found: preserve per-game settings and update the mapped install path - val shortcut = existingShortcut - val epicDisplayName = - app.title.takeIf { it.isNotBlank() } - ?: shortcut.name.takeIf { it.isNotBlank() } - ?: app.appName - // Ensure game_install_path is always up-to-date - shortcut.putExtra("game_install_path", gameInstallPath) - shortcut.putExtra("container_id", launchContainer.id.toString()) - repairShortcutDisplayNameIfNeeded(shortcut, epicDisplayName, app.appName, app.id.toString()) - normalizeContainerDrives(launchContainer) - - // Repair broken Exec line if the executable is missing or still points at a legacy placeholder mapping. - val currentPath = shortcut.path - if (currentPath == null || currentPath == "D:\\" || currentPath == "D:\\\\" || - currentPath == "A:\\" || currentPath == "A:\\\\" || - currentPath.startsWith("A:\\") - ) { - val newExecCmd = - buildStoreWineExecCommandForSelectedExe( - launchContainer, - "EPIC", - gameInstallPath, - shortcut.getExtra("launch_exe_path"), - ) ?: run { - val exePath = EpicService.getInstalledExe(app.id) - if (exePath.isNotEmpty()) { - shortcut.putExtra("launch_exe_path", exePath) - buildStoreWineExecCommand( - launchContainer, - "EPIC", - gameInstallPath, - java.io.File(gameInstallPath, exePath.replace("\\", "/")), - ) - } else { - val exeFile = findGameExe(gameDir) - if (exeFile != null) { - shortcut.putExtra("launch_exe_path", exeFile.absolutePath) - buildStoreWineExecCommand(launchContainer, "EPIC", gameInstallPath, exeFile) - } else { - null - } - } - } - if (newExecCmd != null) { - // Rewrite the Exec line in the .desktop file while preserving all other content - val lines = - com.winlator.cmod.shared.io.FileUtils - .readLines(shortcut.file) - val sb = StringBuilder() - for (line in lines) { - if (line.startsWith("Exec=")) { - sb.append("Exec=$newExecCmd\n") - } else { - sb.append(line).append("\n") - } - } - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcut.file, sb.toString()) - } - } - - shortcut.saveData() - val launchShortcutFile = ensureShortcutFileInContainer(shortcut, launchContainer) - - // Provision the EOS overlay into this container. Best-effort — failures are - // non-fatal (games without the EOS SDK ignore it; games with the SDK still run - // without the in-game HUD). Tokens must be staged inside the prefix because - // the dosdevices map doesn't expose the app cache dir on any drive letter. - runCatching { - EpicService.installOverlay(context, launchContainer) - }.onFailure { - Log.w("EPIC", "EOS overlay install failed for ${app.appName}; launching anyway", it) - } - - val launchArgsResult = - EpicGameLauncher.buildLaunchParameters( - context = context, - game = app, - container = launchContainer, - ) - launchArgsResult.exceptionOrNull()?.let { err -> - // The launch can still proceed (offline-tolerant titles, single-player non-DRM - // games), so we don't abort — but surface the failure prominently so users - // know why a DRM/online title may bounce to its login screen. - Log.e("EPIC", "Failed to build Epic launch parameters for ${app.appName}: ${err.message}", err) - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Could not refresh Epic launch token: ${err.message ?: "unknown error"}", - android.widget.Toast.LENGTH_LONG, - ) - } - } - val args = launchArgsResult.getOrNull()?.joinToString(" ") ?: "" - - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", launchContainer.id) - intent.putExtra("shortcut_path", launchShortcutFile.path) - intent.putExtra("shortcut_name", epicDisplayName) - intent.putExtra("extra_exec_args", args) // Pass fresh tokens - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } else { - // No existing shortcut — create a new one - val exePath = EpicService.getInstalledExe(app.id) - val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) - - if (container == null) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer(context) - } - return@launch - } - - normalizeContainerDrives(container) - val execCmd = - if (exePath.isNotEmpty()) { - buildStoreWineExecCommand( - container, - "EPIC", - gameInstallPath, - java.io.File(gameInstallPath, exePath.replace("\\", "/")), - ) - } else { - val exeFile = findGameExe(gameDir) - if (exeFile != null) { - buildStoreWineExecCommand(container, "EPIC", gameInstallPath, exeFile) - } else { - "wine \"explorer.exe\"" - } - } - - val desktopDir = container.getDesktopDir() - if (!desktopDir.exists()) desktopDir.mkdirs() - val shortcutFile = java.io.File(desktopDir, "${app.appName}.desktop") - val content = java.lang.StringBuilder() - content.append("[Desktop Entry]\n") - content.append("Type=Application\n") - content.append("Name=${app.title}\n") - content.append("Exec=$execCmd\n") - content.append("Icon=epic_icon_${app.id}\n") - content.append("\n[Extra Data]\n") - content.append("game_source=EPIC\n") - content.append("app_id=${app.id}\n") - if (app.catalogId.isNotEmpty()) { - // Persist catalog_id so EpicGameFixHelper / GameFixes can dispatch the - // per-catalog registry/env/folder fixes without a DB round-trip on launch. - content.append("catalog_id=${app.catalogId}\n") - } - content.append("container_id=${container.id}\n") - content.append("game_install_path=${gameInstallPath}\n") - if (exePath.isNotEmpty()) { - content.append("launch_exe_path=${exePath}\n") - } - content.append("use_container_defaults=1\n") - - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcutFile, content.toString()) - - container.saveData() - - // Best-effort EOS overlay provisioning — see existing-shortcut branch above. - runCatching { - EpicService.installOverlay(context, container) - }.onFailure { - Log.w("EPIC", "EOS overlay install failed for ${app.appName}; launching anyway", it) - } - - val launchArgsResult = - EpicGameLauncher.buildLaunchParameters( - context = context, - game = app, - container = container, - ) - launchArgsResult.exceptionOrNull()?.let { err -> - Log.e("EPIC", "Failed to build Epic launch parameters for ${app.appName}: ${err.message}", err) - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Could not refresh Epic launch token: ${err.message ?: "unknown error"}", - android.widget.Toast.LENGTH_LONG, - ) - } - } - val args = launchArgsResult.getOrNull()?.joinToString(" ") ?: "" - - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", container.id) - intent.putExtra("shortcut_path", shortcutFile.path) - intent.putExtra("shortcut_name", app.title) - intent.putExtra("extra_exec_args", args) // Pass fresh tokens - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } - } - } - - private fun launchGogGame( - context: android.content.Context, - containerManager: ContainerManager, - app: GOGGame, - ) { - lifecycleScope.launch(Dispatchers.IO) { - val gameInstallPath = app.installPath.takeIf { it.isNotEmpty() } ?: GOGConstants.getGameInstallPath(app.title) - val gameDir = java.io.File(gameInstallPath) - if (!gameDir.exists()) { - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Game not installed: ${app.title}", - android.widget.Toast.LENGTH_SHORT, - ) - } - return@launch - } - - val existingShortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id - } - - val gogAppId = "GOG_${app.id}" - GOGService.syncCloudSaves(context, gogAppId) - - if (existingShortcut != null) { - val shortcut = existingShortcut - if (!SetupWizardActivity.isContainerUsable(context, shortcut.container)) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer( - context, - shortcut.container.wineVersion, - ) - } - return@launch - } - shortcut.putExtra("game_install_path", gameInstallPath) - normalizeContainerDrives(shortcut.container) - - // Repair broken Exec line if the executable is missing or still points at a legacy placeholder mapping. - val currentPath = shortcut.path - if (currentPath == null || currentPath == "D:\\" || currentPath == "D:\\\\" || - currentPath == "A:\\" || currentPath == "A:\\\\" || - currentPath.startsWith("A:\\") - ) { - val newExecCmd = - buildStoreWineExecCommandForSelectedExe( - shortcut.container, - "GOG", - gameInstallPath, - shortcut.getExtra("launch_exe_path"), - ) ?: run { - val libraryItem = - LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG) - val exePath = GOGService.getInstalledExe(libraryItem) - if (exePath.isNotEmpty()) { - shortcut.putExtra("launch_exe_path", exePath) - buildStoreWineExecCommand( - shortcut.container, - "GOG", - gameInstallPath, - java.io.File(gameInstallPath, exePath.replace("\\", "/")), - ) - } else { - val exeFile = findGameExe(gameDir) - if (exeFile != null) { - shortcut.putExtra("launch_exe_path", exeFile.absolutePath) - buildStoreWineExecCommand(shortcut.container, "GOG", gameInstallPath, exeFile) - } else { - null - } - } - } - if (newExecCmd != null) { - val lines = - com.winlator.cmod.shared.io.FileUtils - .readLines(shortcut.file) - val sb = StringBuilder() - for (line in lines) { - if (line.startsWith("Exec=")) { - sb.append("Exec=$newExecCmd\n") - } else { - sb.append(line).append("\n") - } - } - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcut.file, sb.toString()) - } - } - - shortcut.saveData() - - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", shortcut.container.id) - intent.putExtra("shortcut_path", shortcut.file.path) - intent.putExtra("shortcut_name", shortcut.name) - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - return@launch - } - - val libraryItem = LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG) - val exePath = GOGService.getInstalledExe(libraryItem) - - val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) - - if (container == null) { - withContext(Dispatchers.Main) { - SetupWizardActivity.promptToInstallWineOrCreateContainer(context) - } - return@launch - } - - normalizeContainerDrives(container) - val execCmd = - if (exePath.isNotEmpty()) { - buildStoreWineExecCommand( - container, - "GOG", - gameInstallPath, - java.io.File(gameInstallPath, exePath.replace("\\", "/")), - ) - } else { - val exeFile = findGameExe(gameDir) - if (exeFile != null) { - buildStoreWineExecCommand(container, "GOG", gameInstallPath, exeFile) - } else { - "wine \"explorer.exe\"" - } - } - - val desktopDir = container.getDesktopDir() - if (!desktopDir.exists()) desktopDir.mkdirs() - val shortcutFile = java.io.File(desktopDir, "${app.title.replace("/", "_")}.desktop") - val content = java.lang.StringBuilder() - content.append("[Desktop Entry]\n") - content.append("Type=Application\n") - content.append("Name=${app.title}\n") - content.append("Exec=$execCmd\n") - content.append("Icon=gog_icon_${app.id}\n") - content.append("\n[Extra Data]\n") - content.append("game_source=GOG\n") - content.append("gog_id=${app.id}\n") - content.append("app_id=${gogPseudoId(app.id)}\n") - content.append("container_id=${container.id}\n") - content.append("game_install_path=${gameInstallPath}\n") - if (exePath.isNotEmpty()) { - content.append("launch_exe_path=${exePath}\n") - } - content.append("use_container_defaults=1\n") - - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcutFile, content.toString()) - container.saveData() - - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", container.id) - intent.putExtra("shortcut_path", shortcutFile.path) - intent.putExtra("shortcut_name", app.title) - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } - } - - private fun normalizeContainerDrives(container: com.winlator.cmod.runtime.container.Container) { - container.drives = - com.winlator.cmod.runtime.wine.WineUtils.normalizePersistentDrives( - this, - container.drives ?: com.winlator.cmod.runtime.container.Container.DEFAULT_DRIVES, - false, - ) - } - - private fun resolveShortcutLaunchContainer( - containerManager: ContainerManager, - shortcut: Shortcut, - ): com.winlator.cmod.runtime.container.Container? { - val overrideContainerId = shortcut.getExtra("container_id").toIntOrNull()?.takeIf { it > 0 } - return overrideContainerId - ?.let { containerManager.getContainerById(it) } - ?: shortcut.container - } - - private fun ensureShortcutFileInContainer( - shortcut: Shortcut, - targetContainer: com.winlator.cmod.runtime.container.Container, - ): java.io.File { - val targetDesktopDir = targetContainer.getDesktopDir() - val alreadyInTarget = - runCatching { - shortcut.file.parentFile?.canonicalFile == targetDesktopDir.canonicalFile - }.getOrDefault(false) - - if (alreadyInTarget) return shortcut.file - - if (!targetDesktopDir.exists()) targetDesktopDir.mkdirs() - shortcut.putExtra("container_id", targetContainer.id.toString()) - shortcut.saveData() - - val targetFile = java.io.File(targetDesktopDir, shortcut.file.name) - runCatching { - com.winlator.cmod.shared.io.FileUtils.copy(shortcut.file, targetFile) - val lnkFileName = shortcut.file.name.substringBeforeLast(".desktop") + ".lnk" - val oldLnkFile = java.io.File(shortcut.file.parentFile, lnkFileName) - if (oldLnkFile.exists()) { - com.winlator.cmod.shared.io.FileUtils.copy(oldLnkFile, java.io.File(targetDesktopDir, lnkFileName)) - oldLnkFile.delete() - } - shortcut.file.delete() - }.onFailure { - Log.w("EPIC", "Failed to move Epic shortcut ${shortcut.file.name} to container ${targetContainer.id}; launching original file", it) - return shortcut.file - } - - return targetFile - } - - private fun buildWineExecCommand( - container: com.winlator.cmod.runtime.container.Container?, - gameInstallPath: String, - relativeExePath: String, - ): String { - val exeFile = java.io.File(gameInstallPath, relativeExePath.replace("\\", "/")) - return buildWineExecCommand(container, gameInstallPath, exeFile) - } - - private fun buildWineExecCommand( - container: com.winlator.cmod.runtime.container.Container?, - gameInstallPath: String, - exeFile: java.io.File, - ): String { - val windowsPath = - container?.let { - com.winlator.cmod.runtime.wine.WineUtils - .getDriveCGameWindowsPath( - it, - "CUSTOM", - gameInstallPath, - exeFile.absolutePath, - ) ?: com.winlator.cmod.runtime.wine.WineUtils - .getWindowsPath(it, exeFile.absolutePath) - } ?: run { - com.winlator.cmod.runtime.wine.WineUtils.getDosPath(exeFile.absolutePath) - } - return "wine \"$windowsPath\"" - } - - private fun buildStoreWineExecCommand( - container: com.winlator.cmod.runtime.container.Container?, - source: String, - gameInstallPath: String, - exeFile: java.io.File, - ): String { - val windowsPath = - container?.let { - com.winlator.cmod.runtime.wine.WineUtils.getDriveCGameWindowsPath( - it, - source, - gameInstallPath, - exeFile.absolutePath, - ) - } ?: run { - val relativePath = - try { - exeFile.relativeTo(java.io.File(gameInstallPath)).path.replace("/", "\\") - } catch (_: Exception) { - exeFile.name - } - val linkName = - com.winlator.cmod.runtime.wine.WineUtils.getDriveCGameLinkName(gameInstallPath) - "C:\\WinNative\\Games\\$source\\$linkName\\$relativePath" - } - return "wine \"$windowsPath\"" - } - - private fun buildStoreWineExecCommandForSelectedExe( - container: com.winlator.cmod.runtime.container.Container?, - source: String, - gameInstallPath: String, - selectedExePath: String?, - ): String? { - if (selectedExePath.isNullOrBlank()) return null - - val selectedExe = java.io.File(selectedExePath) - if (!selectedExe.isFile) return null - - val normalizedBaseDir = - java.io - .File(gameInstallPath) - .absolutePath - .removeSuffix("/") - val normalizedExePath = selectedExe.absolutePath - return if (normalizedExePath == normalizedBaseDir || normalizedExePath.startsWith("$normalizedBaseDir/")) { - buildStoreWineExecCommand(container, source, gameInstallPath, selectedExe) - } else { - val hostPath = normalizedExePath.replace("/", "\\\\").let { if (it.startsWith("\\")) it else "\\$it" } - "wine \"Z:${hostPath}\"" - } - } - - // Launch custom game by shortcut name - private fun launchCustomGame( - context: android.content.Context, - containerManager: ContainerManager, - gameName: String, - ) { - lifecycleScope.launch(Dispatchers.IO) { - val allShortcuts = containerManager.loadShortcuts() - - // Try matching by app_id (for non-official Steam/Epic), custom_name, or filename - var shortcut = - allShortcuts.find { it.getExtra("app_id") == gameName } - ?: allShortcuts.find { it.getExtra("custom_name") == gameName } - ?: allShortcuts.find { it.name == gameName } - ?: allShortcuts.find { it.name == gameName.replace("/", "_").replace("\\", "_") } - - // If still not found, try matching by looking at the safe filename directly - if (shortcut == null) { - val safeName = gameName.replace("/", "_").replace("\\", "_") - for (container in containerManager.containers) { - val desktopFile = java.io.File(container.getDesktopDir(), "$safeName.desktop") - if (desktopFile.exists()) { - shortcut = - com.winlator.cmod.runtime.container - .Shortcut(container, desktopFile) - break - } - } - } - - if (shortcut == null) { - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Custom game shortcut not found: $gameName", - android.widget.Toast.LENGTH_SHORT, - ) - } - return@launch - } - - // Backfill custom_name if missing (legacy shortcuts) - if (shortcut.getExtra("custom_name").isEmpty()) { - shortcut.putExtra("custom_name", gameName) - shortcut.saveData() - } - - // Refresh storage-root mappings; custom game paths launch through the drive_c game symlink. - val gameFolder = shortcut.getExtra("custom_game_folder", "") - if (gameFolder.isNotEmpty()) { - normalizeContainerDrives(shortcut.container) - shortcut.container.saveData() - } - val intent = Intent(context, XServerDisplayActivity::class.java) - intent.putExtra("container_id", shortcut.container.id) - intent.putExtra("shortcut_path", shortcut.file.path) - intent.putExtra("shortcut_name", gameName) - withContext(Dispatchers.Main) { - launchGame(context, intent) - } - } - } - - private fun launchGame( - context: android.content.Context, - intent: Intent, - ) { - DownloadService.clearCompletedDownloads() - context.startActivity(intent) - // Suppress the default activity transition so the preloader stays seamless - if (context is android.app.Activity) { - com.winlator.cmod.shared.android.AppUtils - .applyOpenActivityTransition(context, 0, 0) - } - } - - private fun findGameExe(dir: java.io.File): java.io.File? { - // BFS: check each directory level fully before going deeper - val exclusions = - listOf( - "unins", - "redist", - "setup", - "dotnet", - "vcredist", - "dxsetup", - "helper", - "crash", - "ue4prereq", - "dxwebsetup", - "launcher", - ) - - var currentDirs = listOf(dir) - var depth = 0 - var fallbackExe: java.io.File? = null - - while (currentDirs.isNotEmpty() && depth <= 4) { - val nextDirs = mutableListOf() - val candidates = mutableListOf() - - for (d in currentDirs) { - val children = d.listFiles() ?: continue - for (f in children) { - if (f.isDirectory) { - nextDirs.add(f) - } else if (f.extension.equals("exe", ignoreCase = true)) { - val name = f.name.lowercase() - if (exclusions.none { name.contains(it) }) { - candidates.add(f) - } - } - } - } - - // Prefer 64-bit executable candidates at the current depth - val exe64 = - candidates.find { - it.name.lowercase().contains("64") || - it.parentFile - ?.name - ?.lowercase() - ?.contains("64") == true - } - if (exe64 != null) return exe64 - - // Collect the first valid candidate as a fallback - if (fallbackExe == null && candidates.isNotEmpty()) { - fallbackExe = candidates.first() - } - - currentDirs = nextDirs - depth++ - } - return fallbackExe - } - - @Composable - fun EmptyStateMessage(message: String) { - Text(message, color = TextSecondary, modifier = Modifier.padding(16.dp)) - } - - @Composable - fun LoginRequiredScreen( - storeName: String, - onLoginClick: () -> Unit, - ) { - val message = - if (storeName == - "Library" - ) { - stringResource(R.string.library_games_sign_in_prompt) - } else { - stringResource(R.string.stores_accounts_sign_in_store_prompt, storeName) - } - val buttonText = - if (storeName == - "Library" - ) { - stringResource(R.string.stores_accounts_manage) - } else { - stringResource(R.string.stores_accounts_sign_into_store, storeName) - } - - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 48.dp), - ) { - Icon( - Icons.Outlined.Person, - contentDescription = null, - tint = Accent, - modifier = Modifier.size(48.dp), - ) - Spacer(Modifier.height(16.dp)) - Text( - message, - color = TextSecondary, - style = MaterialTheme.typography.bodyMedium, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - lineHeight = 20.sp, - ) - Spacer(Modifier.height(20.dp)) - val interactionSource = - remember { - androidx.compose.foundation.interaction - .MutableInteractionSource() - } - val isPressed by interactionSource.collectIsPressedAsState() - val btnScale by animateFloatAsState( - targetValue = if (isPressed) 0.95f else 1f, - animationSpec = tween(100), - label = "btnScale", - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .graphicsLayer { - scaleX = btnScale - scaleY = btnScale - }.clickable( - interactionSource = interactionSource, - indication = null, - onClick = onLoginClick, - ).border(1.dp, Accent.copy(alpha = 0.5f), RoundedCornerShape(20.dp)) - .padding(horizontal = 20.dp, vertical = 10.dp), - ) { - Text(buttonText, color = Accent, fontSize = 13.sp, fontWeight = FontWeight.Medium) - } - } - } - } - - // Drawer content: avatar card + filters - @Composable - private fun DrawerContent( - persona: com.winlator.cmod.feature.stores.steam.data.SteamFriend?, - isOpen: Boolean, - context: android.content.Context, - scope: kotlinx.coroutines.CoroutineScope, - storeVisible: SnapshotStateMap, - contentFilters: SnapshotStateMap, - libraryLayoutMode: LibraryLayoutMode, - immersiveMode: Boolean, - immersiveBlur: Boolean, - onLibraryLayoutSelected: (LibraryLayoutMode) -> Unit, - onStoreVisibleChanged: (String, Boolean) -> Unit, - onContentFiltersChanged: (String, Boolean) -> Unit, - onImmersiveModeChanged: (Boolean) -> Unit, - onImmersiveBlurChanged: (Boolean) -> Unit, - onExportAll: () -> Unit, - onExitApp: () -> Unit, - ) { - val drawerBridge = (context as? UnifiedActivity)?.drawerNavBridge - val navRegistry = remember(drawerBridge) { PaneNavRegistry(initialSignal = drawerBridge?.navSignal ?: -1) } - navRegistry.controllerActive = drawerBridge?.controllerActive ?: false - LaunchedEffect(navRegistry, drawerBridge?.navSignal) { - navRegistry.processNav(drawerBridge?.navSignal ?: 0, drawerBridge?.navDir ?: 0) - } - LaunchedEffect(isOpen) { if (isOpen) navRegistry.reset() } - - ModalDrawerSheet( - drawerShape = RectangleShape, - drawerContainerColor = Color(0xFF12121B), - drawerContentColor = TextPrimary, - windowInsets = WindowInsets(0, 0, 0, 0), - modifier = Modifier.width(324.dp), - ) { - CompositionLocalProvider(LocalPaneNav provides navRegistry) { - Column( - Modifier - .fillMaxHeight() - .navigationBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - ) { - - // ── Layouts ── - Text( - stringResource(R.string.library_games_layouts_header), - color = TextSecondary, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - modifier = Modifier.padding(bottom = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerFilterButton( - label = "4-Grid", - checked = libraryLayoutMode == LibraryLayoutMode.GRID_4, - modifier = Modifier.weight(1f), - ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.GRID_4) } - DrawerFilterButton( - label = stringResource(R.string.library_games_layout_carousel), - checked = libraryLayoutMode == LibraryLayoutMode.CAROUSEL, - modifier = Modifier.weight(1f), - fontSize = 11.sp, - ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.CAROUSEL) } - DrawerFilterButton( - label = stringResource(R.string.library_games_layout_list), - checked = libraryLayoutMode == LibraryLayoutMode.LIST, - modifier = Modifier.weight(1f), - ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.LIST) } - } - - Spacer(Modifier.height(16.dp)) - - // ── View Options ── - Text( - stringResource(R.string.library_games_view_options_header), - color = TextSecondary, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - modifier = Modifier.padding(bottom = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - - DrawerSwitchCard( - label = stringResource(R.string.library_games_immersive_mode), - description = stringResource(R.string.library_games_immersive_mode_description), - checked = immersiveMode, - onCheckedChange = onImmersiveModeChanged, - ) - - AnimatedVisibility(visible = immersiveMode) { - Column { - Spacer(Modifier.height(8.dp)) - DrawerSwitchCard( - label = stringResource(R.string.library_games_immersive_blur), - description = stringResource(R.string.library_games_immersive_blur_description), - checked = immersiveBlur, - onCheckedChange = onImmersiveBlurChanged, - ) - } - } - - Spacer(Modifier.height(12.dp)) - DrawerActionCard( - icon = Icons.Outlined.IosShare, - label = stringResource(R.string.shortcuts_export_to_frontend), - onClick = onExportAll, - ) - - Spacer(Modifier.height(16.dp)) - - // ── Stores ── - Text( - stringResource(R.string.stores_accounts_stores_header), - color = TextSecondary, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - modifier = Modifier.padding(bottom = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerFilterButton("Steam", storeVisible["steam"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("steam", it) } - DrawerFilterButton("Epic", storeVisible["epic"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("epic", it) } - } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerFilterButton("GOG", storeVisible["gog"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("gog", it) } - Spacer(Modifier.weight(1f)) - } - - Spacer(Modifier.height(16.dp)) - - // ── Content Types ── - Text( - stringResource(R.string.settings_content_types_header), - color = TextSecondary, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.4.sp, - modifier = Modifier.padding(bottom = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerFilterButton("Games", contentFilters["games"] == true, Modifier.weight(1f)) { onContentFiltersChanged("games", it) } - DrawerFilterButton("DLC", contentFilters["dlc"] == true, Modifier.weight(1f)) { onContentFiltersChanged("dlc", it) } - } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DrawerFilterButton("Applications", contentFilters["applications"] == true, Modifier.weight(1f)) { onContentFiltersChanged("applications", it) } - DrawerFilterButton("Tools", contentFilters["tools"] == true, Modifier.weight(1f)) { onContentFiltersChanged("tools", it) } - } - - Spacer(Modifier.height(20.dp)) - HorizontalDivider(color = TextSecondary.copy(alpha = 0.15f)) - Spacer(Modifier.height(16.dp)) - - DrawerExitAppCard(onClick = onExitApp) - } - } - } - } - - @Composable - private fun DrawerExitAppCard(onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.97f else 1f, - animationSpec = tween(100), - label = "exitAppCardScale", - ) - - Row( - modifier = - Modifier - .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .clip(RoundedCornerShape(12.dp)) - .background(DangerRed.copy(alpha = 0.16f)) - .border(1.dp, DangerRed.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) - .paneNavItem(cornerRadius = 12.dp, onActivate = onClick) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 14.dp, vertical = 13.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(34.dp) - .clip(RoundedCornerShape(8.dp)) - .background(DangerRed.copy(alpha = 0.22f)), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.AutoMirrored.Outlined.ExitToApp, - contentDescription = null, - tint = Color(0xFFFFB4B4), - modifier = Modifier.size(20.dp), - ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(R.string.common_ui_exit_app), - color = Color(0xFFFFD6D6), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - @Composable - private fun DrawerActionCard( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - onClick: () -> Unit, - ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.97f else 1f, - animationSpec = tween(100), - label = "drawerActionCardScale", - ) - - Row( - modifier = - Modifier - .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .clip(RoundedCornerShape(12.dp)) - .background(Accent.copy(alpha = 0.14f)) - .border(1.dp, Accent.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) - .paneNavItem(cornerRadius = 12.dp, onActivate = onClick) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 14.dp, vertical = 13.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(34.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Accent.copy(alpha = 0.22f)), - contentAlignment = Alignment.Center, - ) { - Icon( - icon, - contentDescription = null, - tint = Accent, - modifier = Modifier.size(20.dp), - ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = label, - color = TextPrimary, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - @Composable - private fun DrawerFilterButton( - label: String, - checked: Boolean, - modifier: Modifier = Modifier, - fontSize: TextUnit = TextUnit.Unspecified, - onToggle: (Boolean) -> Unit, - ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - val bgColor by animateColorAsState( - targetValue = if (checked) Accent.copy(alpha = 0.2f) else CardDark, - animationSpec = tween(200), - label = "filterBg", - ) - val borderColor by animateColorAsState( - targetValue = if (checked) Accent else CardBorder, - animationSpec = tween(200), - label = "filterBorder", - ) - val textColor by animateColorAsState( - targetValue = if (checked) Accent else TextSecondary, - animationSpec = tween(200), - label = "filterText", - ) - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.92f else 1f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessHigh), - label = "filterScale", - ) - - Box( - modifier = - modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - }.clip(RoundedCornerShape(8.dp)) - .background(bgColor) - .border(1.dp, borderColor, RoundedCornerShape(8.dp)) - .paneNavItem(cornerRadius = 8.dp, onActivate = { onToggle(!checked) }) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { onToggle(!checked) } - .padding(vertical = 10.dp, horizontal = 10.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - fontSize = fontSize, - color = textColor, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - } - } - - @Composable - private fun DrawerSwitchCard( - label: String, - description: String?, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - ) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - - val bgColor by animateColorAsState( - targetValue = if (checked) Accent.copy(alpha = 0.18f) else CardDark, - animationSpec = tween(200), - label = "switchCardBg", - ) - val borderColor by animateColorAsState( - targetValue = if (checked) Accent else CardBorder, - animationSpec = tween(200), - label = "switchCardBorder", - ) - val labelColor by animateColorAsState( - targetValue = if (checked) Accent else TextPrimary, - animationSpec = tween(200), - label = "switchCardLabel", - ) - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.97f else 1f, - animationSpec = tween(120), - label = "switchCardScale", - ) - - Row( - modifier = - modifier - .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - }.clip(RoundedCornerShape(10.dp)) - .background(bgColor) - .border(1.dp, borderColor, RoundedCornerShape(10.dp)) - .paneNavItem(cornerRadius = 10.dp, onActivate = { onCheckedChange(!checked) }) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { onCheckedChange(!checked) } - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = labelColor, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) - if (!description.isNullOrBlank()) { - Spacer(Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.labelSmall, - color = TextSecondary, - maxLines = 2, - ) - } - } - Spacer(Modifier.width(10.dp)) - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - modifier = Modifier.focusProperties { canFocus = false }, - colors = - SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = Accent, - checkedBorderColor = Accent, - uncheckedThumbColor = TextSecondary, - uncheckedTrackColor = CardDark, - uncheckedBorderColor = CardBorder, - ), - ) - } - } - - @Composable - private fun AddCustomGameDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var selectedExePath by remember { mutableStateOf(null) } - var gameName by remember { mutableStateOf("") } - var gameFolder by remember { mutableStateOf(null) } - var isAdding by remember { mutableStateOf(false) } - val registry = remember { PaneNavRegistry() } - val addEnabled = selectedExePath != null && gameName.isNotBlank() && gameFolder != null && !isAdding - val doAdd: () -> Unit = { - isAdding = true - scope.launch(Dispatchers.IO) { - addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) - withContext(Dispatchers.Main) { - isAdding = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "$gameName added!", - android.widget.Toast.LENGTH_SHORT, - ) - onDismiss() - } - } - } - - fun selectExecutable(path: String) { - val launchable = - path.endsWith(".exe", ignoreCase = true) || - path.endsWith(".bat", ignoreCase = true) || - path.endsWith(".cmd", ignoreCase = true) - if (!launchable || !java.io.File(path).isFile) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - R.string.common_ui_select_valid_exe_file, - android.widget.Toast.LENGTH_SHORT, - ) - return - } - - selectedExePath = path - gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path) - // Auto-generate a game name from the EXE name (without extension) - if (gameName.isBlank()) { - gameName = - java.io - .File(path) - .nameWithoutExtension - .replace("_", " ") - .replace("-", " ") - } - } - - val defaultDensity = LocalDensity.current - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - CompositionLocalProvider( - LocalDensity provides Density(defaultDensity.density, fontScale = 1f), - androidx.compose.material3.LocalMinimumInteractiveComponentSize provides androidx.compose.ui.unit.Dp.Unspecified, - LocalPaneNav provides registry, - ) { - DialogPaneNav(registry, onDismiss = onDismiss, onStart = { if (addEnabled) doAdd() }) - Surface( - modifier = - Modifier - .widthIn(max = 360.dp) - .fillMaxWidth(0.9f), - shape = RoundedCornerShape(20.dp), - color = Color(0xFF141B24), - ) { - Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { - // Title - Text( - stringResource(R.string.library_games_add_custom_game), - color = TextPrimary, - fontWeight = FontWeight.SemiBold, - fontSize = 15.sp, - ) - - Spacer(Modifier.height(10.dp)) - - // Scrollable content area - Column( - modifier = - Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - ) { - // Pick EXE button - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.05f)) - .paneNavItem( - cornerRadius = 12.dp, - tapToSelect = true, - isEntry = true, - onActivate = { - DirectoryPickerDialog.showFile( - activity = this@UnifiedActivity, - initialPath = - selectedExePath ?: gameFolder - ?: android.os.Environment - .getExternalStoragePublicDirectory( - android.os.Environment.DIRECTORY_DOWNLOADS, - ).absolutePath, - title = getString(R.string.common_ui_select_exe), - allowedExtensions = setOf("exe", "bat", "cmd"), - dimAmount = 0.5f, - preserveBackdropBlur = true, - extraRoots = driveRoots(includeInternal = true), - onSelected = ::selectExecutable, - ) - }, - ).padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(8.dp)) - Text( - selectedExePath ?: "Select Executable (.exe)", - color = if (selectedExePath == null) TextSecondary else TextPrimary, - maxLines = if (selectedExePath == null) 1 else Int.MAX_VALUE, - overflow = if (selectedExePath == null) TextOverflow.Ellipsis else TextOverflow.Visible, - fontSize = if (selectedExePath == null) 12.sp else 10.sp, - modifier = Modifier.weight(1f), - ) - } - - if (selectedExePath != null) { - - Spacer(Modifier.height(8.dp)) - - // Game name text field — compact - OutlinedTextField( - value = gameName, - onValueChange = { gameName = it }, - label = { Text(stringResource(R.string.library_games_game_name), fontSize = 11.sp) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().paneNavItem(cornerRadius = 10.dp).controllerTextFieldEscape(), - textStyle = MaterialTheme.typography.bodySmall.copy(color = TextPrimary), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = Accent, - unfocusedBorderColor = TextSecondary.copy(alpha = 0.3f), - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - cursorColor = Accent, - focusedLabelColor = Accent, - unfocusedLabelColor = TextSecondary, - ), - shape = RoundedCornerShape(10.dp), - ) - - Spacer(Modifier.height(8.dp)) - - // Game folder — single compact row - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.White.copy(alpha = 0.05f)) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.Folder, - contentDescription = null, - tint = StatusOnline.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(6.dp)) - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.library_games_game_folder_mapped_drive), - color = TextSecondary, - fontSize = 9.sp, - ) - Text( - gameFolder ?: stringResource(R.string.common_ui_auto_detected), - color = if (gameFolder != null) TextPrimary else TextSecondary, - fontSize = 10.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - val openFolderPicker = { - if (ensureAllFilesAccessForImports(context)) { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = gameFolder, - title = getString(R.string.common_ui_select_folder), - dimAmount = 0.5f, - preserveBackdropBlur = true, - extraRoots = driveRoots(includeInternal = true), - ) { path -> gameFolder = path } - } - } - IconButton( - onClick = openFolderPicker, - modifier = - Modifier.size(28.dp).paneNavItem( - cornerRadius = 8.dp, - onActivate = openFolderPicker, - ), - ) { - Icon( - Icons.Outlined.Edit, - contentDescription = stringResource(R.string.common_ui_change), - tint = Accent, - modifier = Modifier.size(14.dp), - ) - } - } - } - } - - Spacer(Modifier.height(12.dp)) - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = onDismiss, - shape = RoundedCornerShape(10.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, TextSecondary.copy(alpha = 0.3f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = TextSecondary), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), - modifier = - Modifier.height(34.dp).widthIn(min = 72.dp) - .paneNavItem(cornerRadius = 10.dp, onActivate = onDismiss), - ) { - Text(stringResource(R.string.common_ui_cancel), fontSize = 12.sp) - } - Spacer(Modifier.width(8.dp)) - OutlinedButton( - onClick = doAdd, - enabled = addEnabled, - shape = RoundedCornerShape(10.dp), - border = - androidx.compose.foundation.BorderStroke( - 1.dp, - if (addEnabled) Accent.copy(alpha = 0.5f) else TextSecondary.copy(alpha = 0.2f), - ), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Accent), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), - modifier = - Modifier.height(34.dp).widthIn(min = 72.dp) - .paneNavItem(cornerRadius = 10.dp, onActivate = { if (addEnabled) doAdd() }), - ) { - if (isAdding) { - CircularProgressIndicator(color = Accent, modifier = Modifier.size(12.dp), strokeWidth = 2.dp) - } else { - Text(stringResource(R.string.common_ui_add), fontWeight = FontWeight.Medium, fontSize = 12.sp) - } - } - } - } - } - } - } - } - - private fun ensureAllFilesAccessForImports(context: android.content.Context): Boolean { - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R || android.os.Environment.isExternalStorageManager()) { - return true - } - - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "Grant All files access to browse Downloads directly.", - android.widget.Toast.LENGTH_LONG, - ) - - val intent = - android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { - data = android.net.Uri.parse("package:$packageName") - } - startActivity(intent) - return false - } - - private fun driveRoots(includeInternal: Boolean): List { - val imagefsRoot = - com.winlator.cmod.runtime.display.environment.ImageFs.find(this).getRootDir() - val roots = - mutableListOf( - DirectoryPickerDialog.ManagedRoot("C:", java.io.File(imagefsRoot, "home").absolutePath), - DirectoryPickerDialog.ManagedRoot("Z:", imagefsRoot.absolutePath), - DirectoryPickerDialog.ManagedRoot( - "D:", - android.os.Environment - .getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS) - .absolutePath, - ), - ) - if (includeInternal) { - roots += - DirectoryPickerDialog.ManagedRoot( - "Internal", - android.os.Environment.getExternalStorageDirectory().absolutePath, - ) - } - return roots - } - - private fun addCustomGame( - context: android.content.Context, - name: String, - exePath: String, - gameFolderPath: String, - ) { - val containerManager = ContainerManager(context) - var container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) - if (container == null) { - SetupWizardActivity.promptToInstallWineOrCreateContainer(context) - return - } - - val exeFile = java.io.File(exePath) - normalizeContainerDrives(container) - val execCmd = buildWineExecCommand(container, gameFolderPath, exeFile) - - val desktopDir = container.getDesktopDir() - if (!desktopDir.exists()) desktopDir.mkdirs() - val safeName = name.replace("/", "_").replace("\\", "_") - val shortcutFile = java.io.File(desktopDir, "$safeName.desktop") - val shortcutUuid = java.util.UUID.randomUUID().toString() - val iconOutFile = LibraryShortcutArtwork.buildManagedCustomGameArtworkFile(context, shortcutUuid) - val extractedArtworkPath = - try { - if (PeIconExtractor.extractAndSave(java.io.File(exePath), iconOutFile)) { - iconOutFile.absolutePath - } else { - null - } - } catch (_: Exception) { - null - } - val content = StringBuilder() - content.append("[Desktop Entry]\n") - content.append("Type=Application\n") - content.append("Name=$name\n") - content.append("Exec=$execCmd\n") - content.append("Icon=custom_game\n") - content.append("\n[Extra Data]\n") - content.append("game_source=CUSTOM\n") - content.append("custom_name=$name\n") - content.append("custom_exe=$exePath\n") - content.append("custom_game_folder=$gameFolderPath\n") - content.append("uuid=$shortcutUuid\n") - extractedArtworkPath?.let { content.append("customCoverArtPath=$it\n") } - content.append("container_id=${container.id}\n") - content.append("use_container_defaults=1\n") - com.winlator.cmod.shared.io.FileUtils - .writeString(shortcutFile, content.toString()) - container.saveData() - } - - @Composable - fun CustomPathWarningDialog( - onDismiss: () -> Unit, - onProceed: () -> Unit, - ) { - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(16.dp), - color = CardDark, - modifier = Modifier.padding(16.dp), - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = stringResource(R.string.stores_accounts_custom_download_path), - style = MaterialTheme.typography.titleLarge, - color = TextPrimary, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(16.dp)) - Text( - text = stringResource(R.string.stores_accounts_custom_download_path_description), - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary, - ) - Spacer(Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.common_ui_close), color = TextSecondary) - } - Spacer(Modifier.width(8.dp)) - Button( - onClick = onProceed, - colors = ButtonDefaults.buttonColors(containerColor = Accent), - shape = RoundedCornerShape(8.dp), - ) { - Text(stringResource(R.string.common_ui_proceed)) - } - } - } - } - } - } - - @Composable - private fun rememberControllerConnectionState(): ControllerConnectionState { - val context = LocalContext.current - val inputManager = remember(context) { context.getSystemService(InputManager::class.java) } - var controllerState by remember { mutableStateOf(ControllerConnectionState()) } - - DisposableEffect(inputManager) { - fun refreshState() { - controllerState = - ControllerConnectionState( - isConnected = ControllerHelper.isControllerConnected(), - isPlayStation = ControllerHelper.isPlayStationController(), - ) - } - - val listener = - object : InputManager.InputDeviceListener { - override fun onInputDeviceAdded(deviceId: Int) = refreshState() - - override fun onInputDeviceRemoved(deviceId: Int) = refreshState() - - override fun onInputDeviceChanged(deviceId: Int) = refreshState() - } - - refreshState() - inputManager?.registerInputDeviceListener(listener, null) - onDispose { - inputManager?.unregisterInputDeviceListener(listener) - } - } - - return controllerState - } } @Composable @@ -12625,7 +1284,7 @@ fun ControllerBadge( } } -private inline fun android.content.Context.runIfOnlineOrToast(action: () -> Unit) { +internal inline fun android.content.Context.runIfOnlineOrToast(action: () -> Unit) { if (com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { action() } else { diff --git a/app/src/main/app/shell/UnifiedActivityDownloads.kt b/app/src/main/app/shell/UnifiedActivityDownloads.kt new file mode 100644 index 000000000..60e18c020 --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityDownloads.kt @@ -0,0 +1,1839 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.DownloadCancelRequest + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Downloads tab + queue/progress UI + game/workshop managers, split out of UnifiedActivity.kt (behavior-identical). + +// Downloads Tab +@Composable +internal fun UnifiedActivity.DownloadsTab( + selectedId: String?, + animationsActive: Boolean = true, + onSelectDownload: (String?) -> Unit, +) { + val downloads = remember { mutableStateListOf>() } + var tick by remember { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + var cancelWarningRequest by remember { mutableStateOf(null) } + + val downloadsActivity = LocalContext.current as? UnifiedActivity + val bridge = downloadsActivity?.downloadsNavBridge + val navRegistry = remember(bridge) { PaneNavRegistry(initialSignal = bridge?.navSignal ?: -1) } + navRegistry.controllerActive = bridge?.controllerActive ?: false + LaunchedEffect(navRegistry, bridge?.navSignal) { + navRegistry.processNav(bridge?.navSignal ?: 0, bridge?.navDir ?: 0) + } + + val syncDownloads = + remember(selectedId, onSelectDownload) { + { + val currentDownloads = DownloadService.getAllDownloads() + downloads.clear() + downloads.addAll(currentDownloads) + if (selectedId != null && currentDownloads.none { it.first == selectedId }) { + onSelectDownload(null) + } + } + } + val latestSyncDownloads by rememberUpdatedState(syncDownloads) + + val downloadStatusListener = + remember { + object : EventDispatcher.JavaEventListener { + override fun onEvent(event: Any) { + if (event is AndroidEvent.DownloadStatusChanged) { + scope.launch { + latestSyncDownloads() + } + } + } + } + } + + DisposableEffect(downloadStatusListener, syncDownloads) { + syncDownloads() + PluviaApp.events.onJava(AndroidEvent.DownloadStatusChanged::class, downloadStatusListener) + onDispose { + PluviaApp.events.offJava(AndroidEvent.DownloadStatusChanged::class, downloadStatusListener) + } + } + + // Re-sync the list whenever the cross-store DownloadCoordinator records change. This + // is what makes PAUSED records (loaded from DB after app restart) appear in the tab, + // and what removes COMPLETE/CANCELLED/FAILED rows after Clear. + LaunchedEffect(syncDownloads) { + DownloadCoordinator.changes.collect { + latestSyncDownloads() + } + } + + downloads.forEach { (_, info) -> + LaunchedEffect(info) { + info.getStatusFlow().collect { + tick++ + } + } + // Also recompose on status-message changes. Active downloads push + // a changing message every progress tick, so this is what keeps + // the byte count / speed / progress bar refreshing live (the + // phase flow dedups to a single DOWNLOADING emission). + LaunchedEffect(info) { + info.getStatusMessageFlow().collect { + tick++ + } + } + } + + CompositionLocalProvider(LocalPaneNav provides navRegistry) { + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)) + .tabScreenPadding(top = DownloadsHeaderTopPadding) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val ev = awaitPointerEvent(PointerEventPass.Initial) + if (ev.type == PointerEventType.Press) { + bridge?.controllerActive = false + } + } + } + }, + ) { + @Suppress("UNUSED_EXPRESSION") + tick + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + val selectedInfo = downloads.find { it.first == selectedId }?.second + val selectedStatus = selectedInfo?.getStatusFlow()?.value + // FAILED is resumable: the dispatcher preserves every breadcrumb + // on failure, so one click continues from where it left off. + val isResumable = + selectedStatus == DownloadPhase.PAUSED || selectedStatus == DownloadPhase.FAILED + val isComplete = selectedStatus == DownloadPhase.COMPLETE + val isCancelled = selectedStatus == DownloadPhase.CANCELLED + val pausableDownloads = + downloads.filter { + val status = it.second.getStatusFlow().value + status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED + } + val allPausableDownloadsPaused = + pausableDownloads.isNotEmpty() && + pausableDownloads.all { + val s = it.second.getStatusFlow().value + s == DownloadPhase.PAUSED || s == DownloadPhase.FAILED + } + + val pauseResumeLabel = + if (selectedId == null) { + if (allPausableDownloadsPaused) { + stringResource( + R.string.downloads_queue_resume_all, + ) + } else { + stringResource(R.string.downloads_queue_pause_all) + } + } else { + when { + selectedStatus == DownloadPhase.FAILED -> stringResource(R.string.session_drawer_retry) + isResumable -> stringResource(R.string.session_drawer_resume) + else -> stringResource(R.string.session_drawer_pause) + } + } + + val cancelLabel = + if (selectedId == null) { + stringResource(R.string.downloads_queue_cancel_all) + } else { + stringResource(R.string.common_ui_cancel) + } + + // Disable pause/resume for completed or cancelled downloads + val pauseResumeEnabled = + if (selectedId != null) { + !isComplete && !isCancelled + } else { + pausableDownloads.isNotEmpty() + } + + val cancelEnabled = + if (selectedId != null) { + !isComplete && !isCancelled + } else { + pausableDownloads.isNotEmpty() + } + + DownloadsQueueButton( + label = pauseResumeLabel, + accentColor = Accent, + onClick = { + val isResumeAction = + if (selectedId == null) allPausableDownloadsPaused else isResumable + val run = { + when { + selectedId == null && allPausableDownloadsPaused -> DownloadService.resumeAll() + selectedId == null -> DownloadService.pauseAll() + isResumable -> DownloadService.resumeDownload(selectedId) + else -> DownloadService.pauseDownload(selectedId) + } + } + if (isResumeAction) this@DownloadsTab.runIfOnlineOrToast(run) else run() + }, + enabled = pauseResumeEnabled, + ) + + Box { + DownloadsQueueButton( + label = cancelLabel, + accentColor = DangerRed, + onClick = { + if (selectedId == null) { + cancelWarningRequest = + DownloadCancelRequest( + ids = pausableDownloads.map { it.first }, + isCancelAll = true, + ) + } else { + cancelWarningRequest = + DownloadCancelRequest( + ids = listOf(selectedId), + isCancelAll = false, + ) + } + }, + enabled = cancelEnabled, + ) + + cancelWarningRequest?.let { request -> + DownloadCancelWarningMenu( + expanded = true, + onDismissRequest = { cancelWarningRequest = null }, + onConfirm = { + val activeRequest = cancelWarningRequest + cancelWarningRequest = null + val ids = activeRequest?.ids.orEmpty() + if (activeRequest?.isCancelAll == true) { + DownloadService.cancelAll() + } else { + ids.forEach(DownloadService::cancelDownload) + } + onSelectDownload(null) + }, + isCancelAll = request.isCancelAll, + ) + } + } + + // Clear button - clears completed, cancelled, and failed downloads + val hasCompletedOrCancelled = + downloads.any { + val s = it.second.getStatusFlow().value + s == DownloadPhase.COMPLETE || s == DownloadPhase.CANCELLED || s == DownloadPhase.FAILED + } + + DownloadsQueueButton( + label = stringResource(R.string.downloads_queue_clear), + accentColor = Accent, + onClick = { + DownloadService.clearCompletedDownloads() + }, + enabled = hasCompletedOrCancelled, + ) + } + + val listState = rememberLazyListState() + val activity = LocalContext.current as? UnifiedActivity + val density = LocalContext.current.resources.displayMetrics.density + + LaunchedEffect(listState) { + activity?.rightStickScrollState?.collect { rz -> + if (kotlin.math.abs(rz) > 0.1f) { + // Max scroll speed is 20 rows per second (approx 20 * 100dp / 60fps ~ 32dp per frame) + // Min scroll speed is 0.75 rows per second (approx 0.75 * 100dp / 60fps ~ 1.25dp per frame) + // Use a square curve for more gradual acceleration + val speedFactor = kotlin.math.abs(rz) + val curveFactor = speedFactor * speedFactor + val baseSpeed = 1.25f + (curveFactor * (32f - 1.25f)) + val direction = if (rz > 0) 1f else -1f + + // Using a loop while the stick is held + while (kotlin.math.abs(activity.rightStickScrollState.value) > 0.1f) { + val currentRz = activity.rightStickScrollState.value + val currentSpeedFactor = kotlin.math.abs(currentRz) + val currentCurveFactor = currentSpeedFactor * currentSpeedFactor + val currentBaseSpeed = 1.25f + (currentCurveFactor * (32f - 1.25f)) + val currentDirection = if (currentRz > 0) 1f else -1f + + val pixelsToScroll = currentBaseSpeed * currentDirection * density + listState.dispatchRawDelta(pixelsToScroll) + kotlinx.coroutines.delay(16) // roughly 60fps + } + } + } + } + + // Sort so the user always sees what's actually running first, then everything + // they can resume, then finished items, with cancelled at the very bottom. + // The list re-sorts on phase transitions because `tick` (incremented by the + // status flow collectors above) is read here, forcing recomposition. + @Suppress("UNUSED_EXPRESSION") + tick + val sortedDownloads = + downloads.sortedBy { (_, info) -> + when (info.getStatusFlow().value) { + // In-progress states grouped together at the top. + DownloadPhase.DOWNLOADING, + DownloadPhase.PREPARING, + DownloadPhase.VERIFYING, + DownloadPhase.PATCHING, + DownloadPhase.APPLYING_DATA, + DownloadPhase.FINALIZING, + DownloadPhase.UNPACKING, + DownloadPhase.UNKNOWN, + -> 0 + // FAILED sorts with PAUSED — both are user-resumable; + // don't bury them under finished downloads. + DownloadPhase.PAUSED -> 1 + DownloadPhase.FAILED -> 1 + DownloadPhase.QUEUED -> 2 + DownloadPhase.COMPLETE -> 3 + DownloadPhase.CANCELLED -> 5 + } + } + + if (sortedDownloads.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + EmptyStateMessage(stringResource(R.string.downloads_queue_empty)) + } + } else { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(sortedDownloads, key = { it.first }) { (id, info) -> + DownloadItemDeck( + id, + info, + isSelected = selectedId == id, + animationsActive = animationsActive, + onClick = { + if (selectedId == id) onSelectDownload(null) else onSelectDownload(id) + }, + ) + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.DownloadsQueueButton( + label: String, + accentColor: Color, + enabled: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val contentColor = if (enabled) accentColor else TextSecondary.copy(alpha = 0.48f) + + Button( + onClick = onClick, + enabled = enabled, + modifier = + modifier + .height(40.dp) + .widthIn(min = 96.dp) + .paneNavItem(cornerRadius = 8.dp, onActivate = { if (enabled) onClick() }), + colors = + ButtonDefaults.buttonColors( + containerColor = DownloadButtonBlack, + contentColor = contentColor, + disabledContainerColor = DownloadButtonBlack.copy(alpha = 0.18f), + disabledContentColor = TextSecondary.copy(alpha = 0.48f), + ), + border = BorderStroke(1.dp, contentColor.copy(alpha = if (enabled) 0.55f else 0.24f)), + contentPadding = PaddingValues(horizontal = 8.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text( + label, + color = contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun UnifiedActivity.AnimatedDownloadProgressFill( + modifier: Modifier, + widthPx: Float, +) { + val infiniteTransition = rememberInfiniteTransition(label = "downloadProgressGradient") + val gradientOffset by infiniteTransition.animateFloat( + initialValue = -widthPx, + targetValue = 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 5000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "downloadProgressGradientOffset", + ) + + Box( + modifier.background( + Brush.horizontalGradient( + colorStops = DownloadChaseGradientStops, + startX = gradientOffset, + endX = gradientOffset + (widthPx * 2f), + tileMode = TileMode.Repeated, + ), + ), + ) +} + +@Composable +internal fun UnifiedActivity.DownloadChasingProgressBar( + progress: Float, + status: DownloadPhase, + animationsActive: Boolean, + modifier: Modifier = Modifier, +) { + val clampedProgress = progress.coerceIn(0f, 1f) + val shouldUseActiveGradient = + when (status) { + DownloadPhase.DOWNLOADING, + DownloadPhase.QUEUED, + DownloadPhase.PREPARING, + DownloadPhase.VERIFYING, + DownloadPhase.PATCHING, + DownloadPhase.APPLYING_DATA, + DownloadPhase.FINALIZING, + DownloadPhase.UNPACKING, + -> true + else -> false + } + val shouldAnimate = shouldUseActiveGradient && animationsActive + val fillColor = + when (status) { + DownloadPhase.FAILED, + DownloadPhase.CANCELLED, + -> DangerRed + DownloadPhase.COMPLETE -> StatusOnline + DownloadPhase.PAUSED -> TextSecondary + else -> Accent + } + + BoxWithConstraints( + modifier = + modifier + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.34f)), + ) { + val density = LocalDensity.current + val widthPx = with(density) { maxWidth.toPx().coerceAtLeast(1f) } + + if (clampedProgress > 0f) { + val fillModifier = + Modifier + .fillMaxHeight() + .fillMaxWidth(clampedProgress) + .clip(RectangleShape) + + if (shouldUseActiveGradient) { + if (shouldAnimate) { + AnimatedDownloadProgressFill(fillModifier, widthPx) + } else { + Box( + fillModifier.background( + Brush.horizontalGradient( + colorStops = DownloadChaseGradientStops, + endX = widthPx * 2f, + tileMode = TileMode.Repeated, + ), + ), + ) + } + } else { + Box(fillModifier.background(fillColor)) + } + } + } +} + +/** + * The live progress body (phase label, bar, percentage, byte counts) for a + * game's in-flight download / verify. Observes [info] directly so it + * refreshes live. Rendered inside [SteamTaskProgressDialog]. + */ +@Composable +internal fun UnifiedActivity.SteamTaskProgressBody(info: DownloadInfo) { + var progress by remember(info) { mutableFloatStateOf(info.getProgress()) } + DisposableEffect(info) { + val listener: (Float) -> Unit = { progress = it } + info.addProgressListener(listener) + onDispose { info.removeProgressListener(listener) } + } + val status by info.getStatusFlow().collectAsState() + // The status message carries a unique suffix every progress tick; + // keying the byte sample on it (and on `progress`) keeps the card + // refreshing live — the Downloads-tab row relies on the same. + val statusMessage by info.getStatusMessageFlow().collectAsState() + val fraction = progress.coerceIn(0f, 1f) + val animatedFraction by animateFloatAsState( + targetValue = fraction, + animationSpec = tween(durationMillis = 400), + label = "steamTaskProgress", + ) + val (doneBytes, totalBytes) = + remember(progress, statusMessage) { info.getDisplayBytesProgress() } + + val phaseText = + when (status) { + DownloadPhase.VERIFYING -> stringResource(R.string.downloads_queue_phase_verifying) + DownloadPhase.DOWNLOADING -> stringResource(R.string.downloads_queue_phase_downloading) + DownloadPhase.PAUSED -> stringResource(R.string.downloads_queue_phase_paused) + DownloadPhase.QUEUED -> stringResource(R.string.downloads_queue_phase_queued) + DownloadPhase.PREPARING -> stringResource(R.string.downloads_queue_phase_preparing) + DownloadPhase.PATCHING -> stringResource(R.string.downloads_queue_phase_patching) + DownloadPhase.APPLYING_DATA -> stringResource(R.string.downloads_queue_phase_applying_data) + DownloadPhase.UNPACKING -> stringResource(R.string.downloads_queue_phase_unpacking) + DownloadPhase.FINALIZING -> stringResource(R.string.downloads_queue_phase_finalizing) + DownloadPhase.COMPLETE -> stringResource(R.string.downloads_queue_phase_complete) + DownloadPhase.FAILED -> stringResource(R.string.common_ui_failed) + DownloadPhase.CANCELLED -> stringResource(R.string.downloads_queue_phase_cancelled) + else -> stringResource(R.string.common_ui_working) + } + val phaseColor = + when (status) { + DownloadPhase.COMPLETE -> StatusOnline + DownloadPhase.FAILED, DownloadPhase.CANCELLED -> DangerRed + DownloadPhase.PAUSED, DownloadPhase.QUEUED -> StatusAway + else -> Accent + } + + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(phaseText, color = phaseColor, fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text( + "${(fraction * 100).toInt()}%", + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + DownloadChasingProgressBar( + progress = animatedFraction, + status = status, + animationsActive = true, + modifier = Modifier.fillMaxWidth().height(10.dp), + ) + Text( + if (totalBytes > 0L) { + "${StorageUtils.formatDecimalSize(doneBytes)} / " + + StorageUtils.formatDecimalSize(totalBytes) + } else { + " " + }, + color = TextSecondary, + fontSize = 11.sp, + ) + } +} + +/** + * Activity-root host for the Verify Files progress pop-up + completion + * notice. Rendered once near the NavHost so it outlives the game-detail + * dialogs that start the task — the verify-completion library refresh + * tears those down, and a dialog-scoped watcher would miss COMPLETE. + * + * Reads the `taskProgress*` activity fields; [showTaskProgressPopup] + * populates them. The watcher is keyed on [taskProgressInfo] so it + * re-attaches if recomposed and (because the status is a StateFlow) + * still observes a terminal phase that landed in between. + */ +@Composable +internal fun UnifiedActivity.TaskProgressHost() { + val info = taskProgressInfo + LaunchedEffect(info) { + if (info == null) return@LaunchedEffect + info.getStatusFlow().collect { st -> + when (st) { + DownloadPhase.COMPLETE -> { + // Snapshot first: `taskProgressShown = false` can trigger + // a follow-up task that overwrites these fields before we read. + val msg = taskProgressCompleteMsg + val asToast = taskProgressCompleteAsToast + taskProgressShown = false + taskProgressInfo = null + if (asToast) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@TaskProgressHost, + msg, + android.widget.Toast.LENGTH_SHORT, + ) + } else { + taskDoneFailed = false + taskDoneMessage = msg + } + } + DownloadPhase.FAILED -> { + taskProgressShown = false + taskDoneFailed = true + taskDoneMessage = taskProgressFailedMsg + taskProgressInfo = null + } + DownloadPhase.CANCELLED -> { + taskProgressShown = false + taskProgressInfo = null + } + else -> Unit + } + } + } + if (taskCheckingShown) { + TaskCheckingDialog( + gameName = taskCheckingGameName, + onDismissRequest = { taskCheckingShown = false }, + ) + } + if (info != null && taskProgressShown) { + SteamTaskProgressDialog( + info = info, + gameName = taskProgressGameName, + onDismissRequest = { taskProgressShown = false }, + ) + } + taskDoneMessage?.let { msg -> + TaskCompleteDialog( + message = msg, + failed = taskDoneFailed, + onClose = { taskDoneMessage = null }, + ) + } +} + +/** + * Indeterminate "Checking for updates" pop-up — same frame as + * [SteamTaskProgressDialog] but without a known task to track. Dismissable; + * the underlying check keeps running and the host shows the result. + */ +@Composable +internal fun UnifiedActivity.TaskCheckingDialog( + gameName: String, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + PopupDialog( + title = gameName, + message = stringResource(R.string.store_game_checking_updates), + icon = Icons.Outlined.Sync, + accentColor = Accent, + modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), + content = { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = Accent, + ) + }, + footer = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + PopupTextAction( + label = stringResource(R.string.common_ui_close), + textColor = Accent, + onClick = onDismissRequest, + ) + } + }, + ) + } +} + +/** + * Dismissable pop-up showing live progress for a Steam task (verify / + * update). Tapping outside closes it — the task keeps running and stays + * visible in the Downloads tab. The host watches the task to completion + * separately and shows [TaskCompleteDialog] when it finishes. + */ +@Composable +internal fun UnifiedActivity.SteamTaskProgressDialog( + info: DownloadInfo, + gameName: String, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + PopupDialog( + title = gameName, + icon = Icons.Outlined.Download, + accentColor = Accent, + modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), + content = { + SteamTaskProgressBody(info) + Text( + stringResource(R.string.store_game_progress_background_hint), + color = TextSecondary, + fontSize = 11.sp, + ) + }, + footer = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + PopupTextAction( + label = stringResource(R.string.common_ui_close), + textColor = Accent, + onClick = onDismissRequest, + ) + } + }, + ) + } +} + +/** + * Small completion notice ("Verify Files Complete" / " Failed") with a + * single Close button. Shown by the host once a watched task finishes. + */ +@Composable +internal fun UnifiedActivity.TaskCompleteDialog(message: String, failed: Boolean, onClose: () -> Unit) { + Dialog(onDismissRequest = onClose) { + PopupDialog( + title = message, + icon = if (failed) Icons.Outlined.Warning else Icons.Outlined.CheckCircle, + accentColor = if (failed) DangerRed else StatusOnline, + confirmButtonColor = Accent, + confirmLabel = stringResource(R.string.common_ui_close), + onConfirm = onClose, + modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), + ) + } +} + +@Composable +internal fun UnifiedActivity.DownloadCancelWarningMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + isCancelAll: Boolean = false, +) { + val titleRes = if (isCancelAll) R.string.downloads_queue_cancel_all_title else R.string.downloads_queue_cancel_download_title + val messageRes = if (isCancelAll) R.string.downloads_queue_cancel_all_warning else R.string.downloads_queue_cancel_download_warning + val confirmRes = if (isCancelAll) R.string.downloads_queue_cancel_all else R.string.downloads_queue_cancel_download + LaunchDangerConfirmDialog( + visible = expanded, + title = stringResource(titleRes), + message = stringResource(messageRes), + confirmLabel = stringResource(confirmRes), + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + icon = Icons.Outlined.Warning, + titleTextAlign = TextAlign.Center, + messageTextAlign = TextAlign.Center, + ) +} + +@Composable +internal fun UnifiedActivity.DownloadItemDeck( + id: String, + info: DownloadInfo, + isSelected: Boolean, + animationsActive: Boolean, + onClick: () -> Unit, +) { + var progress by remember { mutableFloatStateOf(info.getProgress()) } + var showDeleteDialog by remember { mutableStateOf(false) } + + DisposableEffect(info) { + val listener: (Float) -> Unit = { progress = it } + info.addProgressListener(listener) + onDispose { info.removeProgressListener(listener) } + } + val status by info.getStatusFlow().collectAsState() + val statusMessage by info.getStatusMessageFlow().collectAsState() + var previousStatus by remember { mutableStateOf(status) } + var showCompletedProgressBar by remember { mutableStateOf(status != DownloadPhase.COMPLETE) } + val isSteam = id.startsWith("STEAM_") + val isEpic = id.startsWith("EPIC_") + val isGog = id.startsWith("GOG_") + val appId = + if (isSteam) { + id.removePrefix("STEAM_").toIntOrNull() ?: 0 + } else if (isEpic) { + id.removePrefix("EPIC_").toIntOrNull() ?: 0 + } else { + 0 + } + val gogId = if (isGog) id.removePrefix("GOG_") else "" + + var steamApp by remember(appId) { mutableStateOf(null) } + var epicGame by remember(appId) { mutableStateOf(null) } + var gogGame by remember(gogId) { mutableStateOf(null) } + val context = LocalContext.current + val clickInteractionSource = remember { MutableInteractionSource() } + val animatedProgress by animateFloatAsState( + targetValue = if (status == DownloadPhase.COMPLETE) 1f else progress.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing), + label = "downloadItemProgress", + ) + + LaunchedEffect(status) { + if (status == DownloadPhase.COMPLETE) { + if (previousStatus != DownloadPhase.COMPLETE) { + showCompletedProgressBar = true + delay(900) + } + showCompletedProgressBar = false + } else { + showCompletedProgressBar = true + } + previousStatus = status + } + + LaunchedEffect(appId, gogId, isSteam, isEpic, isGog) { + withContext(Dispatchers.IO) { + if (isSteam) { + steamApp = db.steamAppDao().findApp(appId) + } else if (isEpic) { + epicGame = EpicService.getEpicGameOf(appId) + } else if (isGog) { + gogGame = GOGService.getGOGGameOf(gogId) + } + } + } + + val unknownGameLabel = stringResource(R.string.library_games_unknown_game) + val displayName = + if (isSteam) { + steamApp?.name + } else if (isEpic) { + epicGame?.title + } else if (isGog) { + gogGame?.title + } else { + unknownGameLabel + } + val displayImage = + if (isSteam) { + steamApp?.getHeaderImageUrl() + } else if (isEpic) { + epicGame?.primaryImageUrl ?: epicGame?.iconUrl + } else if (isGog) { + gogGame?.imageUrl ?: gogGame?.iconUrl + } else { + null + } + + Surface( + color = if (isSelected) DownloadCardSelectedBlack else DownloadCardBlack, + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .chasingBorder( + isFocused = isSelected, + paused = chasingBordersPaused.value || !animationsActive, + cornerRadius = 12.dp, + borderWidth = 2.dp, + animationDurationMs = 8000, + ) + .paneNavItem( + cornerRadius = 12.dp, + onActivate = onClick, + onSecondary = { + if (status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED) { + showDeleteDialog = true + } + }, + ) + .clickable( + interactionSource = clickInteractionSource, + indication = null, + onClick = onClick, + ), + ) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(displayImage) + .crossfade(300) + .build(), + contentDescription = null, + modifier = Modifier.size(120.dp, 68.dp).clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + + Spacer(Modifier.width(16.dp)) + + Column(Modifier.weight(1f)) { + val currentFile by info.getCurrentFileNameFlow().collectAsState() + val (downloadedBytes, totalBytes) = info.getDisplayBytesProgress() + val speed = info.getCurrentDownloadSpeed() ?: 0L + val percentage = (animatedProgress * 100).roundToInt() + val showDownloadSpeed = + status == DownloadPhase.DOWNLOADING && + progress < 1f && + speed > 0 + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + displayName ?: unknownGameLabel, + fontWeight = FontWeight.Bold, + color = TextPrimary, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Centered Size Info + Text( + text = "${StorageUtils.formatDecimalSize(downloadedBytes)} / ${StorageUtils.formatDecimalSize(totalBytes)}", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) + + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + if (showDownloadSpeed) { + Text( + text = StorageUtils.formatBitsPerSecond(speed), + style = MaterialTheme.typography.labelMedium, + color = Accent, + fontWeight = FontWeight.Bold, + ) + } + } + } + + val statusText = + when (status) { + DownloadPhase.DOWNLOADING -> { + currentFile?.let { + stringResource(R.string.downloads_queue_phase_downloading_file, it.take(10)) + } ?: stringResource(R.string.downloads_queue_phase_downloading) + } + + DownloadPhase.PAUSED -> { + stringResource(R.string.downloads_queue_phase_paused) + } + + DownloadPhase.QUEUED -> { + stringResource(R.string.downloads_queue_phase_queued) + } + + DownloadPhase.PREPARING -> { + stringResource(R.string.downloads_queue_phase_preparing) + } + + DownloadPhase.VERIFYING -> { + currentFile?.let { + stringResource(R.string.downloads_queue_phase_verifying_file, it.take(10)) + } ?: stringResource(R.string.downloads_queue_phase_verifying) + } + + DownloadPhase.PATCHING -> { + stringResource(R.string.downloads_queue_phase_patching) + } + + DownloadPhase.APPLYING_DATA -> { + stringResource(R.string.downloads_queue_phase_applying_data) + } + + DownloadPhase.FINALIZING -> { + stringResource(R.string.downloads_queue_phase_finalizing) + } + + DownloadPhase.UNPACKING -> { + stringResource(R.string.downloads_queue_phase_unpacking) + } + + DownloadPhase.COMPLETE -> { + stringResource(R.string.downloads_queue_phase_complete) + } + + DownloadPhase.CANCELLED -> { + stringResource(R.string.downloads_queue_phase_cancelled) + } + + DownloadPhase.FAILED -> { + stringResource( + R.string.downloads_queue_phase_failed, + if (statusMessage != null && + statusMessage != "null" + ) { + statusMessage!! + } else { + stringResource(R.string.common_ui_unknown_error) + }, + ) + } + + else -> { + stringResource(R.string.downloads_queue_phase_unknown) + } + } + val statusColor = + when (status) { + DownloadPhase.COMPLETE -> StatusOnline + DownloadPhase.FAILED, + DownloadPhase.CANCELLED, + -> DangerRed + DownloadPhase.PAUSED, + DownloadPhase.QUEUED, + -> StatusAway + DownloadPhase.DOWNLOADING, + DownloadPhase.PREPARING, + DownloadPhase.VERIFYING, + DownloadPhase.PATCHING, + DownloadPhase.APPLYING_DATA, + DownloadPhase.FINALIZING, + DownloadPhase.UNPACKING, + -> Accent + else -> TextSecondary + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.downloads_queue_status_label), + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + maxLines = 1, + ) + Spacer(Modifier.width(4.dp)) + Text( + statusText, + style = MaterialTheme.typography.bodySmall, + color = statusColor, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + AnimatedVisibility( + visible = status != DownloadPhase.COMPLETE || showCompletedProgressBar, + exit = fadeOut(tween(180)) + shrinkVertically(tween(180)), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + DownloadChasingProgressBar( + progress = if (status == DownloadPhase.COMPLETE) 1f else animatedProgress, + status = status, + animationsActive = animationsActive, + modifier = Modifier.weight(1f).height(9.dp).padding(end = 10.dp), + ) + Text( + text = "$percentage%", + style = MaterialTheme.typography.labelMedium, + color = if (status == DownloadPhase.COMPLETE) StatusOnline else TextPrimary, + modifier = Modifier.width(40.dp), + ) + } + } + } + + Box(contentAlignment = Alignment.Center) { + IconButton( + onClick = { showDeleteDialog = true }, + enabled = status != DownloadPhase.COMPLETE && status != DownloadPhase.CANCELLED, + ) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.downloads_queue_cancel_download), + tint = + if (status != DownloadPhase.COMPLETE && + status != DownloadPhase.CANCELLED + ) { + Color(0xFFFF6B6B) + } else { + TextSecondary + }, + ) + } + if (showDeleteDialog) { + DownloadCancelWarningMenu( + expanded = true, + onDismissRequest = { showDeleteDialog = false }, + onConfirm = { + showDeleteDialog = false + DownloadService.cancelDownload(id) + }, + ) + } + } + if (ControllerHelper.isControllerConnected()) { + Spacer(Modifier.width(8.dp)) + ControllerBadge(if (ControllerHelper.isPlayStationController()) "\u2715" else "A") + } + } + } +} + +// Game Manager Dialog +@Composable +internal fun UnifiedActivity.GameManagerDialog( + app: SteamApp, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var selectedManifestSizes by remember { mutableStateOf(SteamService.ManifestSizes()) } + var baseInstallSize by remember(app.id) { mutableStateOf(0L) } + var dlcApps by remember { mutableStateOf>(emptyList()) } + var dlcSizes by remember { mutableStateOf>(emptyMap()) } + var installedDlcIds by remember(app.id) { mutableStateOf>(emptySet()) } + var installed by remember(app.id) { mutableStateOf(null) } + val selectedDlcIds = remember { mutableStateListOf() } + var customPath by remember { mutableStateOf(null) } + var showCustomPathWarning by remember { mutableStateOf(false) } + var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } + var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } + var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var updateInfo by remember(app.id) { mutableStateOf(null) } + var updateStatusText by remember(app.id) { mutableStateOf(null) } + val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( + initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), + ) + val scope = rememberCoroutineScope() + + if (showCustomPathWarning) { + CustomPathWarningDialog( + onDismiss = { showCustomPathWarning = false }, + onProceed = { + showCustomPathWarning = false + DirectoryPickerDialog.show( + activity = this@GameManagerDialog, + initialPath = customPath ?: SteamService.defaultAppInstallPath, + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + }, + ) + } + + data class SteamInstallLoadData( + val dlcApps: List, + val dlcSizes: Map, + val installedDlcIds: Set, + val baseManifestSizes: SteamService.ManifestSizes, + val installed: Boolean, + ) + + LaunchedEffect(app.id, downloadRecords) { + val loadData = + withContext(Dispatchers.IO) { + val selectableDlcApps = SteamService.getSelectableDlcAppsOf(app.id) + val perDlcSizes = + selectableDlcApps.associate { dlc -> + dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id) + } + val installedDlcIds = + SteamService.getInstalledDlcDepotsOf(app.id) + .orEmpty() + .toSet() + SteamInstallLoadData( + dlcApps = selectableDlcApps, + dlcSizes = perDlcSizes, + installedDlcIds = installedDlcIds, + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id), + installed = SteamService.isAppInstalled(app.id), + ) + } + dlcApps = loadData.dlcApps + dlcSizes = loadData.dlcSizes + installedDlcIds = loadData.installedDlcIds + selectedDlcIds.removeAll(loadData.installedDlcIds) + selectedManifestSizes = loadData.baseManifestSizes + baseInstallSize = loadData.baseManifestSizes.installSize + installed = loadData.installed + isLoading = false + } + + LaunchedEffect(app.id, selectedDlcIds.toList()) { + selectedManifestSizes = + withContext(Dispatchers.IO) { + SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) + } + } + + val totalDownloadSize = selectedManifestSizes.downloadSize + val totalInstallSize = selectedManifestSizes.installSize + val defaultPathSet = + if (PrefManager.useSingleDownloadFolder) { + PrefManager.defaultDownloadFolder.isNotEmpty() + } else { + PrefManager.steamDownloadFolder + .isNotEmpty() + } + val effectivePath = customPath ?: SteamService.defaultAppInstallPath + val availableBytes = + try { + StorageUtils.getAvailableSpace(effectivePath) + } catch (e: Exception) { + 0L + } + // For an already-installed game the base content is on disk, so only require free + // space for the newly-selected DLC (already-installed DLC is excluded from selection). + val requiredBytes = + if (installed == true) (totalInstallSize - baseInstallSize).coerceAtLeast(0L) else totalInstallSize + val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes + val installPathDisplay = customPath ?: SteamService.defaultAppInstallPath + + val dlcItems = + remember(dlcApps, dlcSizes, installedDlcIds) { + dlcApps.map { dlc -> + val sizes = dlcSizes[dlc.id] + val size = + sizes + ?.downloadSize + ?.takeIf { it > 0L } + ?: sizes?.installSize + ?: 0L + StoreDlcItem( + id = dlc.id, + name = dlc.name, + downloadSize = size, + isInstalled = dlc.id in installedDlcIds, + ) + } + } + val customPathLabel = + when { + customPath != null -> stringResource(R.string.common_ui_custom) + defaultPathSet -> stringResource(R.string.common_ui_already_set) + else -> stringResource(R.string.common_ui_custom) + } + val isReallyInstalled = installed == true + val steamDownloadRecord = + downloadRecords.firstOrNull { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && + it.storeGameId == app.id.toString() && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + ) + } + val hasBlockingSteamDownload = + downloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && + it.storeGameId == app.id.toString() && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + val updateActionEnabled = steamDownloadRecord == null + val installActionEnabled = isInstallEnabled && steamDownloadRecord == null + val activeSteamDownloadText = stringResource(R.string.store_game_download_already_active) + val noUpdateAvailableText = stringResource(R.string.store_game_no_update_available) + val updateAvailableText = stringResource(R.string.store_game_update_available) + val updateFailedText = stringResource(R.string.store_game_update_check_failed) + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape, + color = Color.Black, + ) { + StoreGameDetailScreen( + title = app.name, + subtitle = + listOfNotNull( + app.developer.takeIf { it.isNotBlank() }, + app.publisher.takeIf { + it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) + }, + ).joinToString(" • "), + sourceLabel = "Steam", + heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.steamRef(app, "hero", app.getHeroUrl())), + isLoading = isLoading, + isInstalled = isReallyInstalled, + installPathDisplay = installPathDisplay, + downloadSize = totalDownloadSize, + installSize = totalInstallSize, + availableBytes = availableBytes, + isInstallEnabled = isInstallEnabled, + isDownloadActionEnabled = installActionEnabled, + customPathLabel = customPathLabel, + showCustomPath = true, + showCloudSync = false, + showUninstall = false, + showUpdateCheck = true, + isCheckingForUpdate = isCheckingForUpdate, + isUpdateAvailable = updateInfo?.hasUpdate == true, + updateDownloadSize = updateInfo?.downloadSize ?: 0L, + updateStatusText = updateStatusText, + isUpdateActionEnabled = updateActionEnabled, + isUpdateCheckCoolingDown = isUpdateCheckCoolingDown, + // Shown for any installed game; titles without UGC simply + // open to an empty Workshop window (handled gracefully). + showWorkshop = isReallyInstalled, + showVerifyFiles = isReallyInstalled, + areSteamActionsEnabled = !hasBlockingSteamDownload, + dlcs = dlcItems, + selectedDlcIds = selectedDlcIds.toSet(), + isDlcSelectionEnabled = steamDownloadRecord == null, + onBack = onDismissRequest, + onInstall = { + if (steamDownloadRecord != null) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeSteamDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch(Dispatchers.IO) { + val installableDlcIds = dlcItems + .filter { !it.isInstalled && it.id in selectedDlcIds } + .map { it.id } + SteamService.downloadApp(app.id, installableDlcIds, false, customPath) + withContext(Dispatchers.Main) { onDismissRequest() } + } + } + }, + onCheckForUpdate = { startUpdateCheck(app.id, app.name) }, + onWorkshop = { showWorkshopDialog = true }, + onVerifyFiles = { + if (steamDownloadRecord != null) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeSteamDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch { + val started = + withContext(Dispatchers.IO) { + SteamService.downloadAppForVerify(app.id) + } + if (started != null) { + // Hand off to the activity-root host so the + // pop-up + completion notice outlive this dialog. + showTaskProgressPopup( + started, + app.name, + getString(R.string.store_game_verify_complete), + getString(R.string.store_game_verify_failed_notice), + completeAsToast = true, + ) + } + } + } + }, + onDownloadUpdate = { + if (!updateActionEnabled || updateInfo?.hasUpdate != true) return@StoreGameDetailScreen + context.runIfOnlineOrToast { + scope.launch(Dispatchers.IO) { + try { + val latest = SteamService.checkForAppUpdate(app.id) + withContext(Dispatchers.Main) { + updateInfo = latest + updateStatusText = + when { + latest.hasUpdate -> updateAvailableText + latest.message != null -> updateFailedText + else -> null + } + } + if (!latest.hasUpdate) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + noUpdateAvailableText, + android.widget.Toast.LENGTH_SHORT, + ) + } + return@launch + } + + SteamService.downloadAppForUpdate(app.id, latest.depotIds) + withContext(Dispatchers.Main) { onDismissRequest() } + } catch (e: Exception) { + Log.w("UnifiedActivity", "Steam update download failed to start for appId=${app.id}", e) + withContext(Dispatchers.Main) { + updateStatusText = updateFailedText + } + } + } + } + }, + onCustomPath = { + if (customPath == null && defaultPathSet) { + showCustomPathWarning = true + } else { + DirectoryPickerDialog.show( + activity = this@GameManagerDialog, + initialPath = customPath ?: SteamService.defaultAppInstallPath, + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + } + }, + onToggleDlc = { id -> + if (steamDownloadRecord != null) { + return@StoreGameDetailScreen + } + if (dlcItems.any { it.id == id && it.isInstalled }) { + return@StoreGameDetailScreen + } + if (selectedDlcIds.contains(id)) { + selectedDlcIds.remove(id) + } else { + selectedDlcIds.add(id) + } + }, + onToggleSelectAllDlcs = { + if (steamDownloadRecord != null) { + return@StoreGameDetailScreen + } + val selectableDlcItems = dlcItems.filterNot { it.isInstalled } + val all = selectableDlcItems.isNotEmpty() && selectableDlcItems.all { it.id in selectedDlcIds } + if (all) { + selectedDlcIds.removeAll(selectableDlcItems.map { it.id }.toSet()) + } else { + selectableDlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } + } + }, + ) + } + } + + if (showWorkshopDialog) { + WorkshopDialog( + appId = app.id, + gameTitle = app.name, + onDismissRequest = { showWorkshopDialog = false }, + ) + } +} + +@Composable +internal fun UnifiedActivity.WorkshopDialog( + appId: Int, + gameTitle: String, + onDismissRequest: () -> Unit, +) { + var loadState by remember(appId) { mutableStateOf(WorkshopLoadState.LOADING) } + var errorMessage by remember(appId) { mutableStateOf(null) } + var allItems by remember(appId) { mutableStateOf>(emptyList()) } + var query by remember(appId) { mutableStateOf("") } + // Published-file-ids with an install OR uninstall in flight. + val busyIds = remember(appId) { mutableStateListOf() } + var reloadKey by remember(appId) { mutableStateOf(0) } + val scope = rememberCoroutineScope() + + LaunchedEffect(appId, reloadKey) { + loadState = WorkshopLoadState.LOADING + errorMessage = null + // Drop any in-flight spinners — a reload re-fetches the list. + busyIds.clear() + try { + val items = + withContext(Dispatchers.IO) { + val json = SteamService.getSubscribedWorkshopItems(appId) + if (json == null) { + null + } else { + val installed = + com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator + .installedItemIds(applicationContext, appId) + parseWorkshopItemsJson(json, installed) + } + } + if (items == null) { + errorMessage = + "Couldn't load your Workshop subscriptions. " + + "Make sure you're signed in to Steam and online." + loadState = WorkshopLoadState.ERROR + } else { + allItems = items + loadState = WorkshopLoadState.READY + } + } catch (e: Exception) { + Log.w("UnifiedActivity", "Workshop load failed for appId=$appId", e) + errorMessage = e.message + loadState = WorkshopLoadState.ERROR + } + } + + val filtered = + remember(allItems, query) { + val q = query.trim() + if (q.isBlank()) { + allItems + } else { + allItems.filter { + it.title.contains(q, ignoreCase = true) || + it.author.contains(q, ignoreCase = true) + } + } + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreWorkshopScreen( + gameTitle = gameTitle, + loadState = loadState, + errorMessage = errorMessage, + items = filtered, + query = query, + // Snapshotted here inside the Dialog content lambda: a mutation of + // the SnapshotStateList invalidates this scope, so .toSet() re-runs. + busyIds = busyIds.toSet(), + onQueryChange = { query = it }, + onInstall = { id -> + val item = allItems.firstOrNull { it.publishedFileId == id } + if (item != null && id !in busyIds) { + if (item.manifestId == 0L) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@WorkshopDialog, + "This Workshop item has no downloadable content", + android.widget.Toast.LENGTH_SHORT, + ) + } else { + busyIds.add(id) + scope.launch { + val ok = + SteamService.installWorkshopItem( + appId = appId, + publishedFileId = item.publishedFileId, + manifestId = item.manifestId, + title = item.title, + fileSizeBytes = item.fileSizeBytes, + timeUpdated = item.timeUpdated, + previewUrl = item.previewImageUrl ?: "", + ) + if (ok) { + allItems = + allItems.map { + if (it.publishedFileId == id) it.copy(isInstalled = true) else it + } + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@WorkshopDialog, + "Workshop download failed — check your Steam connection", + android.widget.Toast.LENGTH_LONG, + ) + } + busyIds.remove(id) + } + } + } + }, + onUninstall = { id -> + if (id !in busyIds) { + busyIds.add(id) + scope.launch { + val ok = + withContext(Dispatchers.IO) { + com.winlator.cmod.feature.stores.steam.workshop.WorkshopModsGenerator + .uninstall(applicationContext, appId, id) + } + if (ok) { + allItems = + allItems.map { + if (it.publishedFileId == id) it.copy(isInstalled = false) else it + } + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@WorkshopDialog, + "Couldn't uninstall this Workshop item", + android.widget.Toast.LENGTH_SHORT, + ) + } + busyIds.remove(id) + } + } + }, + onRetry = { reloadKey++ }, + onClose = onDismissRequest, + ) + } +} diff --git a/app/src/main/app/shell/UnifiedActivityDrawer.kt b/app/src/main/app/shell/UnifiedActivityDrawer.kt new file mode 100644 index 000000000..2f9478838 --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityDrawer.kt @@ -0,0 +1,1208 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.ControllerConnectionState + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Navigation drawer + add-custom-game dialog + empty/login states, split out of UnifiedActivity.kt (behavior-identical). + +@Composable +internal fun UnifiedActivity.EmptyStateMessage(message: String) { + Text(message, color = TextSecondary, modifier = Modifier.padding(16.dp)) +} + +@Composable +internal fun UnifiedActivity.LoginRequiredScreen( + storeName: String, + onLoginClick: () -> Unit, +) { + val message = + if (storeName == + "Library" + ) { + stringResource(R.string.library_games_sign_in_prompt) + } else { + stringResource(R.string.stores_accounts_sign_in_store_prompt, storeName) + } + val buttonText = + if (storeName == + "Library" + ) { + stringResource(R.string.stores_accounts_manage) + } else { + stringResource(R.string.stores_accounts_sign_into_store, storeName) + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + Icon( + Icons.Outlined.Person, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + Text( + message, + color = TextSecondary, + style = MaterialTheme.typography.bodyMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + lineHeight = 20.sp, + ) + Spacer(Modifier.height(20.dp)) + val interactionSource = + remember { + androidx.compose.foundation.interaction + .MutableInteractionSource() + } + val isPressed by interactionSource.collectIsPressedAsState() + val btnScale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1f, + animationSpec = tween(100), + label = "btnScale", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .graphicsLayer { + scaleX = btnScale + scaleY = btnScale + }.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onLoginClick, + ).border(1.dp, Accent.copy(alpha = 0.5f), RoundedCornerShape(20.dp)) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text(buttonText, color = Accent, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +// Drawer content: avatar card + filters +@Composable +internal fun UnifiedActivity.DrawerContent( + persona: com.winlator.cmod.feature.stores.steam.data.SteamFriend?, + isOpen: Boolean, + context: android.content.Context, + scope: kotlinx.coroutines.CoroutineScope, + storeVisible: SnapshotStateMap, + contentFilters: SnapshotStateMap, + libraryLayoutMode: LibraryLayoutMode, + immersiveMode: Boolean, + immersiveBlur: Boolean, + onLibraryLayoutSelected: (LibraryLayoutMode) -> Unit, + onStoreVisibleChanged: (String, Boolean) -> Unit, + onContentFiltersChanged: (String, Boolean) -> Unit, + onImmersiveModeChanged: (Boolean) -> Unit, + onImmersiveBlurChanged: (Boolean) -> Unit, + onExportAll: () -> Unit, + onExitApp: () -> Unit, +) { + val drawerBridge = (context as? UnifiedActivity)?.drawerNavBridge + val navRegistry = remember(drawerBridge) { PaneNavRegistry(initialSignal = drawerBridge?.navSignal ?: -1) } + navRegistry.controllerActive = drawerBridge?.controllerActive ?: false + LaunchedEffect(navRegistry, drawerBridge?.navSignal) { + navRegistry.processNav(drawerBridge?.navSignal ?: 0, drawerBridge?.navDir ?: 0) + } + LaunchedEffect(isOpen) { if (isOpen) navRegistry.reset() } + + ModalDrawerSheet( + drawerShape = RectangleShape, + drawerContainerColor = Color(0xFF12121B), + drawerContentColor = TextPrimary, + windowInsets = WindowInsets(0, 0, 0, 0), + modifier = Modifier.width(324.dp), + ) { + CompositionLocalProvider(LocalPaneNav provides navRegistry) { + Column( + Modifier + .fillMaxHeight() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) { + + // ── Layouts ── + Text( + stringResource(R.string.library_games_layouts_header), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(bottom = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerFilterButton( + label = "4-Grid", + checked = libraryLayoutMode == LibraryLayoutMode.GRID_4, + modifier = Modifier.weight(1f), + ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.GRID_4) } + DrawerFilterButton( + label = stringResource(R.string.library_games_layout_carousel), + checked = libraryLayoutMode == LibraryLayoutMode.CAROUSEL, + modifier = Modifier.weight(1f), + fontSize = 11.sp, + ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.CAROUSEL) } + DrawerFilterButton( + label = stringResource(R.string.library_games_layout_list), + checked = libraryLayoutMode == LibraryLayoutMode.LIST, + modifier = Modifier.weight(1f), + ) { if (it) onLibraryLayoutSelected(LibraryLayoutMode.LIST) } + } + + Spacer(Modifier.height(16.dp)) + + // ── View Options ── + Text( + stringResource(R.string.library_games_view_options_header), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(bottom = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + DrawerSwitchCard( + label = stringResource(R.string.library_games_immersive_mode), + description = stringResource(R.string.library_games_immersive_mode_description), + checked = immersiveMode, + onCheckedChange = onImmersiveModeChanged, + ) + + AnimatedVisibility(visible = immersiveMode) { + Column { + Spacer(Modifier.height(8.dp)) + DrawerSwitchCard( + label = stringResource(R.string.library_games_immersive_blur), + description = stringResource(R.string.library_games_immersive_blur_description), + checked = immersiveBlur, + onCheckedChange = onImmersiveBlurChanged, + ) + } + } + + Spacer(Modifier.height(12.dp)) + DrawerActionCard( + icon = Icons.Outlined.IosShare, + label = stringResource(R.string.shortcuts_export_to_frontend), + onClick = onExportAll, + ) + + Spacer(Modifier.height(16.dp)) + + // ── Stores ── + Text( + stringResource(R.string.stores_accounts_stores_header), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(bottom = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerFilterButton("Steam", storeVisible["steam"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("steam", it) } + DrawerFilterButton("Epic", storeVisible["epic"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("epic", it) } + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerFilterButton("GOG", storeVisible["gog"] == true, Modifier.weight(1f)) { onStoreVisibleChanged("gog", it) } + Spacer(Modifier.weight(1f)) + } + + Spacer(Modifier.height(16.dp)) + + // ── Content Types ── + Text( + stringResource(R.string.settings_content_types_header), + color = TextSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.4.sp, + modifier = Modifier.padding(bottom = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerFilterButton("Games", contentFilters["games"] == true, Modifier.weight(1f)) { onContentFiltersChanged("games", it) } + DrawerFilterButton("DLC", contentFilters["dlc"] == true, Modifier.weight(1f)) { onContentFiltersChanged("dlc", it) } + } + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerFilterButton("Applications", contentFilters["applications"] == true, Modifier.weight(1f)) { onContentFiltersChanged("applications", it) } + DrawerFilterButton("Tools", contentFilters["tools"] == true, Modifier.weight(1f)) { onContentFiltersChanged("tools", it) } + } + + Spacer(Modifier.height(20.dp)) + HorizontalDivider(color = TextSecondary.copy(alpha = 0.15f)) + Spacer(Modifier.height(16.dp)) + + DrawerExitAppCard(onClick = onExitApp) + } + } + } +} + +@Composable +internal fun UnifiedActivity.DrawerExitAppCard(onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = tween(100), + label = "exitAppCardScale", + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(RoundedCornerShape(12.dp)) + .background(DangerRed.copy(alpha = 0.16f)) + .border(1.dp, DangerRed.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .paneNavItem(cornerRadius = 12.dp, onActivate = onClick) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(DangerRed.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Outlined.ExitToApp, + contentDescription = null, + tint = Color(0xFFFFB4B4), + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.common_ui_exit_app), + color = Color(0xFFFFD6D6), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun UnifiedActivity.DrawerActionCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = tween(100), + label = "drawerActionCardScale", + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(RoundedCornerShape(12.dp)) + .background(Accent.copy(alpha = 0.14f)) + .border(1.dp, Accent.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .paneNavItem(cornerRadius = 12.dp, onActivate = onClick) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Accent.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = label, + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun UnifiedActivity.DrawerFilterButton( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + fontSize: TextUnit = TextUnit.Unspecified, + onToggle: (Boolean) -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val bgColor by animateColorAsState( + targetValue = if (checked) Accent.copy(alpha = 0.2f) else CardDark, + animationSpec = tween(200), + label = "filterBg", + ) + val borderColor by animateColorAsState( + targetValue = if (checked) Accent else CardBorder, + animationSpec = tween(200), + label = "filterBorder", + ) + val textColor by animateColorAsState( + targetValue = if (checked) Accent else TextSecondary, + animationSpec = tween(200), + label = "filterText", + ) + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.92f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessHigh), + label = "filterScale", + ) + + Box( + modifier = + modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + }.clip(RoundedCornerShape(8.dp)) + .background(bgColor) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .paneNavItem(cornerRadius = 8.dp, onActivate = { onToggle(!checked) }) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { onToggle(!checked) } + .padding(vertical = 10.dp, horizontal = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontSize = fontSize, + color = textColor, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +@Composable +internal fun UnifiedActivity.DrawerSwitchCard( + label: String, + description: String?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val bgColor by animateColorAsState( + targetValue = if (checked) Accent.copy(alpha = 0.18f) else CardDark, + animationSpec = tween(200), + label = "switchCardBg", + ) + val borderColor by animateColorAsState( + targetValue = if (checked) Accent else CardBorder, + animationSpec = tween(200), + label = "switchCardBorder", + ) + val labelColor by animateColorAsState( + targetValue = if (checked) Accent else TextPrimary, + animationSpec = tween(200), + label = "switchCardLabel", + ) + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + animationSpec = tween(120), + label = "switchCardScale", + ) + + Row( + modifier = + modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + }.clip(RoundedCornerShape(10.dp)) + .background(bgColor) + .border(1.dp, borderColor, RoundedCornerShape(10.dp)) + .paneNavItem(cornerRadius = 10.dp, onActivate = { onCheckedChange(!checked) }) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { onCheckedChange(!checked) } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = labelColor, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + if (!description.isNullOrBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + maxLines = 2, + ) + } + } + Spacer(Modifier.width(10.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.focusProperties { canFocus = false }, + colors = + SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Accent, + checkedBorderColor = Accent, + uncheckedThumbColor = TextSecondary, + uncheckedTrackColor = CardDark, + uncheckedBorderColor = CardBorder, + ), + ) + } +} + +@Composable +internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var selectedExePath by remember { mutableStateOf(null) } + var gameName by remember { mutableStateOf("") } + var gameFolder by remember { mutableStateOf(null) } + var isAdding by remember { mutableStateOf(false) } + val registry = remember { PaneNavRegistry() } + val addEnabled = selectedExePath != null && gameName.isNotBlank() && gameFolder != null && !isAdding + val doAdd: () -> Unit = { + isAdding = true + scope.launch(Dispatchers.IO) { + addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) + withContext(Dispatchers.Main) { + isAdding = false + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "$gameName added!", + android.widget.Toast.LENGTH_SHORT, + ) + onDismiss() + } + } + } + + fun selectExecutable(path: String) { + val launchable = + path.endsWith(".exe", ignoreCase = true) || + path.endsWith(".bat", ignoreCase = true) || + path.endsWith(".cmd", ignoreCase = true) + if (!launchable || !java.io.File(path).isFile) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.common_ui_select_valid_exe_file, + android.widget.Toast.LENGTH_SHORT, + ) + return + } + + selectedExePath = path + gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path) + // Auto-generate a game name from the EXE name (without extension) + if (gameName.isBlank()) { + gameName = + java.io + .File(path) + .nameWithoutExtension + .replace("_", " ") + .replace("-", " ") + } + } + + val defaultDensity = LocalDensity.current + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + CompositionLocalProvider( + LocalDensity provides Density(defaultDensity.density, fontScale = 1f), + androidx.compose.material3.LocalMinimumInteractiveComponentSize provides androidx.compose.ui.unit.Dp.Unspecified, + LocalPaneNav provides registry, + ) { + DialogPaneNav(registry, onDismiss = onDismiss, onStart = { if (addEnabled) doAdd() }) + Surface( + modifier = + Modifier + .widthIn(max = 360.dp) + .fillMaxWidth(0.9f), + shape = RoundedCornerShape(20.dp), + color = Color(0xFF141B24), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + // Title + Text( + stringResource(R.string.library_games_add_custom_game), + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp, + ) + + Spacer(Modifier.height(10.dp)) + + // Scrollable content area + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + // Pick EXE button + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .paneNavItem( + cornerRadius = 12.dp, + tapToSelect = true, + isEntry = true, + onActivate = { + DirectoryPickerDialog.showFile( + activity = this@AddCustomGameDialog, + initialPath = + selectedExePath ?: gameFolder + ?: android.os.Environment + .getExternalStoragePublicDirectory( + android.os.Environment.DIRECTORY_DOWNLOADS, + ).absolutePath, + title = getString(R.string.common_ui_select_exe), + allowedExtensions = setOf("exe", "bat", "cmd"), + dimAmount = 0.5f, + preserveBackdropBlur = true, + extraRoots = driveRoots(includeInternal = true), + onSelected = ::selectExecutable, + ) + }, + ).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + selectedExePath ?: "Select Executable (.exe)", + color = if (selectedExePath == null) TextSecondary else TextPrimary, + maxLines = if (selectedExePath == null) 1 else Int.MAX_VALUE, + overflow = if (selectedExePath == null) TextOverflow.Ellipsis else TextOverflow.Visible, + fontSize = if (selectedExePath == null) 12.sp else 10.sp, + modifier = Modifier.weight(1f), + ) + } + + if (selectedExePath != null) { + + Spacer(Modifier.height(8.dp)) + + // Game name text field — compact + OutlinedTextField( + value = gameName, + onValueChange = { gameName = it }, + label = { Text(stringResource(R.string.library_games_game_name), fontSize = 11.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().paneNavItem(cornerRadius = 10.dp).controllerTextFieldEscape(), + textStyle = MaterialTheme.typography.bodySmall.copy(color = TextPrimary), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Accent, + unfocusedBorderColor = TextSecondary.copy(alpha = 0.3f), + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + cursorColor = Accent, + focusedLabelColor = Accent, + unfocusedLabelColor = TextSecondary, + ), + shape = RoundedCornerShape(10.dp), + ) + + Spacer(Modifier.height(8.dp)) + + // Game folder — single compact row + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Folder, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.library_games_game_folder_mapped_drive), + color = TextSecondary, + fontSize = 9.sp, + ) + Text( + gameFolder ?: stringResource(R.string.common_ui_auto_detected), + color = if (gameFolder != null) TextPrimary else TextSecondary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val openFolderPicker = { + if (ensureAllFilesAccessForImports(context)) { + DirectoryPickerDialog.show( + activity = this@AddCustomGameDialog, + initialPath = gameFolder, + title = getString(R.string.common_ui_select_folder), + dimAmount = 0.5f, + preserveBackdropBlur = true, + extraRoots = driveRoots(includeInternal = true), + ) { path -> gameFolder = path } + } + } + IconButton( + onClick = openFolderPicker, + modifier = + Modifier.size(28.dp).paneNavItem( + cornerRadius = 8.dp, + onActivate = openFolderPicker, + ), + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.common_ui_change), + tint = Accent, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(10.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, TextSecondary.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = TextSecondary), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = + Modifier.height(34.dp).widthIn(min = 72.dp) + .paneNavItem(cornerRadius = 10.dp, onActivate = onDismiss), + ) { + Text(stringResource(R.string.common_ui_cancel), fontSize = 12.sp) + } + Spacer(Modifier.width(8.dp)) + OutlinedButton( + onClick = doAdd, + enabled = addEnabled, + shape = RoundedCornerShape(10.dp), + border = + androidx.compose.foundation.BorderStroke( + 1.dp, + if (addEnabled) Accent.copy(alpha = 0.5f) else TextSecondary.copy(alpha = 0.2f), + ), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Accent), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = + Modifier.height(34.dp).widthIn(min = 72.dp) + .paneNavItem(cornerRadius = 10.dp, onActivate = { if (addEnabled) doAdd() }), + ) { + if (isAdding) { + CircularProgressIndicator(color = Accent, modifier = Modifier.size(12.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.common_ui_add), fontWeight = FontWeight.Medium, fontSize = 12.sp) + } + } + } + } + } + } + } +} + +internal fun UnifiedActivity.ensureAllFilesAccessForImports(context: android.content.Context): Boolean { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R || android.os.Environment.isExternalStorageManager()) { + return true + } + + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Grant All files access to browse Downloads directly.", + android.widget.Toast.LENGTH_LONG, + ) + + val intent = + android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + data = android.net.Uri.parse("package:$packageName") + } + startActivity(intent) + return false +} + +internal fun UnifiedActivity.driveRoots(includeInternal: Boolean): List { + val imagefsRoot = + com.winlator.cmod.runtime.display.environment.ImageFs.find(this).getRootDir() + val roots = + mutableListOf( + DirectoryPickerDialog.ManagedRoot("C:", java.io.File(imagefsRoot, "home").absolutePath), + DirectoryPickerDialog.ManagedRoot("Z:", imagefsRoot.absolutePath), + DirectoryPickerDialog.ManagedRoot( + "D:", + android.os.Environment + .getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS) + .absolutePath, + ), + ) + if (includeInternal) { + roots += + DirectoryPickerDialog.ManagedRoot( + "Internal", + android.os.Environment.getExternalStorageDirectory().absolutePath, + ) + } + return roots +} + +internal fun UnifiedActivity.addCustomGame( + context: android.content.Context, + name: String, + exePath: String, + gameFolderPath: String, +) { + val containerManager = ContainerManager(context) + var container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) + if (container == null) { + SetupWizardActivity.promptToInstallWineOrCreateContainer(context) + return + } + + val exeFile = java.io.File(exePath) + normalizeContainerDrives(container) + val execCmd = buildWineExecCommand(container, gameFolderPath, exeFile) + + val desktopDir = container.getDesktopDir() + if (!desktopDir.exists()) desktopDir.mkdirs() + val safeName = name.replace("/", "_").replace("\\", "_") + val shortcutFile = java.io.File(desktopDir, "$safeName.desktop") + val shortcutUuid = java.util.UUID.randomUUID().toString() + val iconOutFile = LibraryShortcutArtwork.buildManagedCustomGameArtworkFile(context, shortcutUuid) + val extractedArtworkPath = + try { + if (PeIconExtractor.extractAndSave(java.io.File(exePath), iconOutFile)) { + iconOutFile.absolutePath + } else { + null + } + } catch (_: Exception) { + null + } + val content = StringBuilder() + content.append("[Desktop Entry]\n") + content.append("Type=Application\n") + content.append("Name=$name\n") + content.append("Exec=$execCmd\n") + content.append("Icon=custom_game\n") + content.append("\n[Extra Data]\n") + content.append("game_source=CUSTOM\n") + content.append("custom_name=$name\n") + content.append("custom_exe=$exePath\n") + content.append("custom_game_folder=$gameFolderPath\n") + content.append("uuid=$shortcutUuid\n") + extractedArtworkPath?.let { content.append("customCoverArtPath=$it\n") } + content.append("container_id=${container.id}\n") + content.append("use_container_defaults=1\n") + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcutFile, content.toString()) + container.saveData() +} + +@Composable +internal fun UnifiedActivity.CustomPathWarningDialog( + onDismiss: () -> Unit, + onProceed: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(16.dp), + color = CardDark, + modifier = Modifier.padding(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.stores_accounts_custom_download_path), + style = MaterialTheme.typography.titleLarge, + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.stores_accounts_custom_download_path_description), + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_ui_close), color = TextSecondary) + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = onProceed, + colors = ButtonDefaults.buttonColors(containerColor = Accent), + shape = RoundedCornerShape(8.dp), + ) { + Text(stringResource(R.string.common_ui_proceed)) + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.rememberControllerConnectionState(): ControllerConnectionState { + val context = LocalContext.current + val inputManager = remember(context) { context.getSystemService(InputManager::class.java) } + var controllerState by remember { mutableStateOf(ControllerConnectionState()) } + + DisposableEffect(inputManager) { + fun refreshState() { + controllerState = + ControllerConnectionState( + isConnected = ControllerHelper.isControllerConnected(), + isPlayStation = ControllerHelper.isPlayStationController(), + ) + } + + val listener = + object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refreshState() + + override fun onInputDeviceRemoved(deviceId: Int) = refreshState() + + override fun onInputDeviceChanged(deviceId: Int) = refreshState() + } + + refreshState() + inputManager?.registerInputDeviceListener(listener, null) + onDispose { + inputManager?.unregisterInputDeviceListener(listener) + } + } + + return controllerState +} diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt new file mode 100644 index 000000000..46f7988f7 --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt @@ -0,0 +1,2912 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.GameSettingsActionItem +import com.winlator.cmod.app.shell.UnifiedActivity.GameSettingsScreen +import com.winlator.cmod.app.shell.UnifiedActivity.HeroBootChoice +import com.winlator.cmod.app.shell.UnifiedActivity.HeroLaunchPopup +import com.winlator.cmod.app.shell.UnifiedActivity.HomeShortcutUiState +import com.winlator.cmod.app.shell.UnifiedActivity.LibraryDetailPopup +import com.winlator.cmod.app.shell.UnifiedActivity.LibraryDetailScreen + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Game settings/detail dialogs, split out of UnifiedActivity.kt (behavior-identical). + +@Composable +internal fun UnifiedActivity.LibraryDetailPopupFrame( + title: String, + onDismissRequest: () -> Unit, + wide: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + val dismissInteractionSource = remember { MutableInteractionSource() } + val panelInteractionSource = remember { MutableInteractionSource() } + val registry = remember { PaneNavRegistry() } + + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onDismissRequest) + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.58f)) + .clickable( + interactionSource = dismissInteractionSource, + indication = null, + onClick = onDismissRequest, + ), + ) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + val panelMaxWidth = if (wide) 440.dp else 360.dp + val panelWidthFraction = if (wide) 0.72f else 0.58f + val panelMaxHeight = (maxHeight - 16.dp).coerceAtLeast(240.dp) + + Surface( + modifier = + Modifier + .fillMaxWidth(panelWidthFraction) + .widthIn(max = panelMaxWidth) + .heightIn(max = panelMaxHeight) + .clickable( + interactionSource = panelInteractionSource, + indication = null, + onClick = {}, + ), + shape = RoundedCornerShape(16.dp), + color = CardDark, + border = BorderStroke(1.dp, CardBorder), + tonalElevation = 8.dp, + shadowElevation = 12.dp, + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontSize = 13.sp, + color = TextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = onDismissRequest, + modifier = + Modifier + .size(34.dp) + .paneNavItem(cornerRadius = 8.dp, onActivate = onDismissRequest), + ) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.common_ui_close), + tint = TextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } + HorizontalDivider(color = CardBorder, thickness = 0.5.dp) + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + content() + } + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.GameSettingsDialogFrame( + title: String, + onDismissRequest: () -> Unit, + wide: Boolean = false, + contentKey: Any? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val registry = remember { PaneNavRegistry() } + LaunchedEffect(contentKey) { registry.reset() } + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onDismissRequest) + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val widthModifier = + if (wide) { + Modifier.widthIn(min = 320.dp, max = (maxWidth - 32.dp).coerceAtMost(560.dp)) + } else { + Modifier.widthIn(min = 200.dp, max = 280.dp) + } + val maxContentHeight = (maxHeight - 48.dp).coerceAtLeast(320.dp) + Surface( + modifier = widthModifier.heightIn(max = maxContentHeight), + shape = RoundedCornerShape(14.dp), + color = CardDark, + border = BorderStroke(1.dp, CardBorder), + tonalElevation = 8.dp, + ) { + Column( + modifier = + Modifier + .padding(vertical = 6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleSmall, + color = TextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = onDismissRequest, + modifier = Modifier + .padding(end = 4.dp) + .size(34.dp) + .paneNavItem(cornerRadius = 17.dp, onActivate = onDismissRequest, pinTop = true), + ) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.common_ui_close), + tint = TextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } + HorizontalDivider(color = CardBorder, thickness = 0.5.dp) + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + content() + } + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.GameSettingsActionGrid( + actions: List, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + actions.forEachIndexed { index, action -> + if (index > 0) { + HorizontalDivider( + color = CardBorder.copy(alpha = 0.5f), + thickness = 0.5.dp, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + GameSettingsActionCard(action = action, isEntry = index == 0) + } + } +} + +@Composable +internal fun UnifiedActivity.GameSettingsActionCard( + action: GameSettingsActionItem, + modifier: Modifier = Modifier, + isEntry: Boolean = false, +) { + val isDanger = action.accentColor == DangerRed + val iconColor = if (isDanger) DangerRed else TextSecondary + val textColor = if (isDanger) DangerRed else TextPrimary + + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.96f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "actionCardScale", + ) + Row( + modifier = + modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + }.paneNavItem(cornerRadius = 0.dp, onActivate = action.onClick, isEntry = isEntry) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = action.onClick, + ).padding(horizontal = 16.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = action.icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(18.dp), + ) + Text( + text = action.title, + style = MaterialTheme.typography.bodyMedium, + color = textColor, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } +} + +@Composable +internal fun UnifiedActivity.GameSettingsInfoCard( + message: String, + accentColor: Color = Accent, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = accentColor.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + lineHeight = 18.sp, + textAlign = TextAlign.Center, + ) + } +} + +/** + * Shared uninstall/remove confirmation UI used by GameSettingsDialog, + * GOGGameSettingsDialog, and LibraryGameDetailDialog. + */ +@Composable +internal fun UnifiedActivity.UninstallConfirmation( + message: String, + confirmLabel: String = stringResource(R.string.common_ui_uninstall), + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + var isUninstalling by remember { mutableStateOf(false) } + + GameSettingsInfoCard(message = message, accentColor = DangerRed) + + if (isUninstalling) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = DangerRed) + } + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + isUninstalling = true + onConfirm() + }, + modifier = Modifier.paneNavItem( + cornerRadius = 8.dp, + onActivate = { isUninstalling = true; onConfirm() }, + isEntry = true, + ), + border = BorderStroke(1.dp, DangerRed.copy(alpha = 0.5f)), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed), + ) { + Text( + confirmLabel, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = onCancel, + modifier = Modifier.paneNavItem(cornerRadius = 8.dp, onActivate = onCancel), + ) { + Text(stringResource(R.string.common_ui_cancel), color = TextSecondary, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +internal fun UnifiedActivity.ShortcutRemovalConfirmation( + message: String, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + var isRemoving by remember { mutableStateOf(false) } + + GameSettingsInfoCard(message = message, accentColor = DangerRed) + + if (isRemoving) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = DangerRed) + } + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + isRemoving = true + onConfirm() + }, + modifier = Modifier.paneNavItem( + cornerRadius = 8.dp, + onActivate = { isRemoving = true; onConfirm() }, + isEntry = true, + ), + border = BorderStroke(1.dp, DangerRed.copy(alpha = 0.5f)), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = DangerRed), + ) { + Text( + stringResource(R.string.common_ui_remove), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = onCancel, + modifier = Modifier.paneNavItem(cornerRadius = 8.dp, onActivate = onCancel), + ) { + Text(stringResource(R.string.common_ui_cancel), color = TextSecondary, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +internal fun UnifiedActivity.HeroLaunchConfirmFooter( + onCancel: () -> Unit, + onContinue: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PaneFooterAction( + label = stringResource(R.string.common_ui_cancel), + textColor = DangerRed, + onClick = onCancel, + ) + PaneFooterAction( + label = stringResource(R.string.common_ui_continue), + textColor = StatusOnline, + onClick = onContinue, + isEntry = true, + ) + } +} + +@Composable +internal fun UnifiedActivity.PaneFooterAction( + label: String, + textColor: Color, + onClick: () -> Unit, + isEntry: Boolean = false, +) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .paneNavItem( + cornerRadius = 8.dp, + onActivate = onClick, + tapToSelect = true, + isEntry = isEntry, + ).padding(horizontal = 10.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } +} + +@Composable +internal fun UnifiedActivity.HeroBootDialog( + onConfirm: (HeroBootChoice) -> Unit, + onDismissRequest: () -> Unit, +) { + var choice by remember { mutableStateOf(HeroBootChoice.Desktop) } + val graphicsTest = stringResource(R.string.hero_graphics_tests_title) + val test32 = graphicsTest + " " + stringResource(R.string.hero_graphics_test_32) + val test64 = graphicsTest + " " + stringResource(R.string.hero_graphics_test_64) + val title = + when (choice) { + HeroBootChoice.Desktop -> stringResource(R.string.hero_boot_to_desktop_title) + HeroBootChoice.Cube32 -> test32 + HeroBootChoice.Cube64 -> test64 + } + val registry = remember { PaneNavRegistry() } + Dialog(onDismissRequest = onDismissRequest) { + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onDismissRequest, onStart = { onConfirm(choice) }) + PopupDialog( + title = title, + icon = Icons.Outlined.DesktopWindows, + accentColor = Accent, + modifier = Modifier.widthIn(min = 220.dp, max = 290.dp), + content = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + HeroBootOptionRow( + label = stringResource(R.string.hero_boot_to_desktop_title), + selected = choice == HeroBootChoice.Desktop, + onClick = { choice = HeroBootChoice.Desktop }, + ) + HeroBootOptionRow( + label = test32, + selected = choice == HeroBootChoice.Cube32, + onClick = { choice = HeroBootChoice.Cube32 }, + ) + HeroBootOptionRow( + label = test64, + selected = choice == HeroBootChoice.Cube64, + onClick = { choice = HeroBootChoice.Cube64 }, + ) + } + }, + footer = { + HeroLaunchConfirmFooter(onCancel = onDismissRequest, onContinue = { onConfirm(choice) }) + }, + ) + } + } +} + +@Composable +internal fun UnifiedActivity.HeroBootOptionRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val glassBlue = Accent + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(glassBlue.copy(alpha = if (selected) 0.26f else 0.05f)) + .border(1.dp, glassBlue.copy(alpha = if (selected) 0.65f else 0.12f), RoundedCornerShape(8.dp)) + .paneNavItem(cornerRadius = 8.dp, onActivate = onClick, tapToSelect = true) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected) Color.White else glassBlue.copy(alpha = 0.5f), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +internal fun UnifiedActivity.HeroRemoveShortcutDialog( + gameName: String, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + val registry = remember { PaneNavRegistry() } + var isRemoving by remember { mutableStateOf(false) } + Dialog(onDismissRequest = onDismissRequest) { + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onDismissRequest) + PopupDialog( + title = stringResource(R.string.common_ui_shortcut), + message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, gameName), + icon = Icons.Outlined.Home, + accentColor = DangerRed, + confirmButtonColor = DangerRed, + progressLabel = stringResource(R.string.common_ui_working), + modifier = Modifier.widthIn(min = 280.dp, max = 360.dp), + footer = { + if (isRemoving) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(color = DangerRed, strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + Text( + stringResource(R.string.common_ui_working), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PaneFooterAction( + label = stringResource(R.string.common_ui_cancel), + textColor = TextSecondary, + onClick = onDismissRequest, + ) + PaneFooterAction( + label = stringResource(R.string.common_ui_remove), + textColor = DangerRed, + onClick = { + isRemoving = true + onConfirm() + }, + isEntry = true, + ) + } + } + }, + ) + } + } +} + +@Composable +internal fun UnifiedActivity.GameSettingsDialog( + app: SteamApp, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + var currentTab by remember { mutableStateOf(GameSettingsScreen.Menu) } + val scope = rememberCoroutineScope() + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + var shortcutRefreshKey by remember(app.id, isCustom, isEpic, epicId) { mutableStateOf(0) } + var pinnedShortcutOverride by remember(app.id, isCustom, isEpic, epicId) { mutableStateOf(null) } + val epicArtworkUrl by produceState(initialValue = null, key1 = isEpic, key2 = epicId) { + value = + if (isEpic) { + val epicGame = db.epicGameDao().getById(epicId) + epicGame?.primaryImageUrl ?: epicGame?.iconUrl + } else { + null + } + } + val currentRefreshSignal = this@GameSettingsDialog.libraryRefreshSignal + val homeShortcutState by produceState( + HomeShortcutUiState(), + app.id, + isCustom, + isEpic, + epicId, + currentRefreshSignal, + shortcutRefreshKey, + ) { + value = + withContext(Dispatchers.IO) { + val shortcut = findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) + HomeShortcutUiState( + shortcut = shortcut, + isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + ) + } + } + val artworkRefreshListener = + remember(app.id, isCustom, isEpic, epicId) { + object : EventDispatcher.JavaEventListener { + override fun onEvent(event: Any) { + if (event is AndroidEvent.LibraryArtworkChanged) { + shortcutRefreshKey++ + } + } + } + } + DisposableEffect(artworkRefreshListener) { + PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) + onDispose { + PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) + } + } + val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned + + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri != null) { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val os = context.contentResolver.openOutputStream(uri) ?: return@launch + val zos = java.util.zip.ZipOutputStream(java.io.BufferedOutputStream(os)) + + val containerManager = + com.winlator.cmod.runtime.container + .ContainerManager(context) + val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + + val dirsToZip = mutableListOf() + + val goldbergSaves = java.io.File(SteamService.getAppDirPath(app.id), "steam_settings/saves") + if (goldbergSaves.exists() && goldbergSaves.isDirectory) { + dirsToZip.add(goldbergSaves) + } + + if (shortcut != null) { + val prefixDir = java.io.File(shortcut.container.getRootDir(), ".wine/drive_c/users/xuser") + val docs = java.io.File(prefixDir, "Documents") + val savedGames = java.io.File(prefixDir, "Saved Games") + val appData = java.io.File(prefixDir, "AppData") + if (docs.exists()) dirsToZip.add(docs) + if (savedGames.exists()) dirsToZip.add(savedGames) + if (appData.exists()) dirsToZip.add(appData) + } + + fun zipDir( + dir: java.io.File, + baseName: String, + ) { + val children = dir.listFiles() ?: return + for (child in children) { + val name = if (baseName.isEmpty()) child.name else "$baseName/${child.name}" + if (child.isDirectory) { + zos.putNextEntry(java.util.zip.ZipEntry("$name/")) + zos.closeEntry() + zipDir(child, name) + } else { + zos.putNextEntry(java.util.zip.ZipEntry(name)) + val fis = java.io.FileInputStream(child) + val buf = ByteArray(1024 * 8) + var len: Int + while (fis.read(buf).also { len = it } > 0) { + zos.write(buf, 0, len) + } + fis.close() + zos.closeEntry() + } + } + } + + for (dir in dirsToZip) { + val baseName = dir.name + zos.putNextEntry(java.util.zip.ZipEntry("$baseName/")) + zos.closeEntry() + zipDir(dir, baseName) + } + + zos.close() + withContext(kotlinx.coroutines.Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.saves_import_export_exported, + android.widget.Toast.LENGTH_SHORT, + ) + onDismissRequest() + } + } catch (e: Exception) { + e.printStackTrace() + withContext(kotlinx.coroutines.Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.saves_import_export_exported_failed, e.message), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + val importLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + val `is` = context.contentResolver.openInputStream(uri) ?: return@launch + val zis = java.util.zip.ZipInputStream(java.io.BufferedInputStream(`is`)) + + val containerManager = + com.winlator.cmod.runtime.container + .ContainerManager(context) + val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + + val goldbergSavesParent = + java.io.File( + if (isEpic) app.gameDir else SteamService.getAppDirPath(app.id), + if (isEpic) "" else "steam_settings", + ) + val prefixDir = shortcut?.let { java.io.File(it.container.getRootDir(), ".wine/drive_c/users/xuser") } + + var ze: java.util.zip.ZipEntry? + while (zis.nextEntry.also { ze = it } != null) { + val entry = ze!! + val name = entry.name + var destFile: java.io.File? = null + if (name.startsWith("saves/")) { + destFile = java.io.File(goldbergSavesParent, name) + } else if (prefixDir != null) { + if (name.startsWith("Documents/") || name.startsWith("Saved Games/") || name.startsWith("AppData/")) { + destFile = java.io.File(prefixDir, name) + } + } + + if (destFile != null) { + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + val fos = java.io.FileOutputStream(destFile) + val buf = ByteArray(1024 * 8) + var len: Int + while (zis.read(buf).also { len = it } > 0) { + fos.write(buf, 0, len) + } + fos.close() + } + } + zis.closeEntry() + } + zis.close() + withContext(kotlinx.coroutines.Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.saves_import_export_imported, + android.widget.Toast.LENGTH_SHORT, + ) + onDismissRequest() + } + } catch (e: Exception) { + e.printStackTrace() + withContext(kotlinx.coroutines.Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.saves_import_export_imported_failed, e.message), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + GameSettingsDialogFrame( + title = app.name, + onDismissRequest = onDismissRequest, + wide = currentTab == GameSettingsScreen.CloudSaves, + contentKey = currentTab, + ) { + when (currentTab) { + GameSettingsScreen.Menu -> { + val actions = + listOf( + GameSettingsActionItem( + title = stringResource(R.string.common_ui_settings), + icon = Icons.Outlined.Settings, + onClick = { + val containerManager = ContainerManager(context) + val shortcut = + findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + ?: if (isCustom) { + null + } else { + ShortcutSettingsComposeDialog.createLibraryShortcut( + context = context, + containerManager = containerManager, + source = if (isEpic) "EPIC" else "STEAM", + appId = if (isEpic) epicId else app.id, + gogId = null, + appName = app.name, + ) + } + if (shortcut != null) { + ShortcutSettingsComposeDialog(this@GameSettingsDialog, shortcut).show() + } + onDismissRequest() + }, + ), + GameSettingsActionItem( + title = stringResource(R.string.hero_boot_to_desktop_title), + icon = Icons.Outlined.DesktopWindows, + onClick = { + val shortcut = + findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) + if (shortcut != null) { + context.startActivity( + Intent(context, XServerDisplayActivity::class.java) + .putExtra("container_id", shortcut.container.id), + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show(context, R.string.shortcuts_list_not_available) + } + onDismissRequest() + }, + ), + GameSettingsActionItem( + title = + stringResource( + if (hasPinnedShortcut) { + R.string.common_ui_remove + } else { + R.string.common_ui_shortcut + }, + ), + icon = Icons.Outlined.Home, + accentColor = if (hasPinnedShortcut) DangerRed else Accent, + onClick = { + if (hasPinnedShortcut) { + currentTab = GameSettingsScreen.Shortcut + } else { + scope.launch { + val created = + withContext(Dispatchers.IO) { + addLibraryShortcutToHomeScreen( + context, + app, + isCustom, + isEpic, + epicId, + epicArtworkUrl, + ) + } + if (!created) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString( + R.string.library_games_failed_to_create_shortcut, + app.name, + ), + ) + } + } + } + }, + ), + GameSettingsActionItem( + title = stringResource(R.string.cloud_saves_title), + icon = Icons.Outlined.CloudSync, + onClick = { currentTab = GameSettingsScreen.CloudSaves }, + ), + + GameSettingsActionItem( + title = + if (isCustom) { + stringResource( + R.string.common_ui_remove, + ) + } else { + stringResource(R.string.common_ui_uninstall) + }, + icon = Icons.Outlined.Delete, + accentColor = DangerRed, + onClick = { currentTab = GameSettingsScreen.Uninstall }, + ), + ) + + GameSettingsActionGrid(actions = actions) + } + + GameSettingsScreen.Shortcut -> { + ShortcutRemovalConfirmation( + message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, app.name), + onConfirm = { + scope.launch { + val removed = + withContext(Dispatchers.IO) { + homeShortcutState.shortcut?.let { + LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) + } == true + } + pinnedShortcutOverride = if (removed) false else hasPinnedShortcut + shortcutRefreshKey++ + currentTab = GameSettingsScreen.Menu + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (removed) { + context.getString(R.string.shortcuts_list_removed) + } else { + context.getString(R.string.common_ui_unknown_error) + }, + ) + } + }, + onCancel = { currentTab = GameSettingsScreen.Menu }, + ) + } + + GameSettingsScreen.CloudSaves -> { + var isWorking by remember { mutableStateOf(false) } + val shortcut = + remember(app.id, epicId, isCustom, isEpic) { + findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) + } + var cloudSyncEnabled by remember(shortcut?.file?.absolutePath) { + mutableStateOf(isShortcutCloudSyncEnabled(shortcut)) + } + var offlineModeEnabled by remember(shortcut?.file?.absolutePath) { + mutableStateOf(isShortcutOfflineMode(shortcut)) + } + + val gameSource = + when { + isEpic -> GameSaveBackupManager.GameSource.EPIC + isCustom -> GameSaveBackupManager.GameSource.CUSTOM + else -> GameSaveBackupManager.GameSource.STEAM + } + val gameIdStr = + when { + isEpic -> epicId.toString() + isCustom -> shortcut?.let { GameSaveBackupManager.customGameId(it) } ?: app.name + else -> app.id.toString() + } + val providerLabel = + when (gameSource) { + GameSaveBackupManager.GameSource.EPIC -> + stringResource(R.string.preloader_platform_epic) + GameSaveBackupManager.GameSource.CUSTOM -> + stringResource(R.string.preloader_platform_custom) + else -> + stringResource(R.string.preloader_platform_steam) + } + + CloudSavesContent( + activity = this@GameSettingsDialog, + isWorking = isWorking, + cloudSyncEnabled = cloudSyncEnabled, + offlineModeEnabled = offlineModeEnabled, + gameSource = gameSource, + gameId = gameIdStr, + gameName = app.name, + shortcut = shortcut, + onCloudSyncToggle = { enabled -> + cloudSyncEnabled = enabled + setShortcutCloudSyncEnabled(shortcut, enabled) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (enabled) { + context.getString(R.string.cloud_sync_enabled_summary) + } else { + context.getString(R.string.cloud_sync_disabled_summary) + }, + android.widget.Toast.LENGTH_SHORT, + ) + }, + onOfflineModeToggle = { enabled -> + offlineModeEnabled = enabled + setShortcutOfflineMode(shortcut, enabled) + }, + onSyncFromCloud = { + if (!isWorking) { + isWorking = true + scope.launch(Dispatchers.IO) { + val ok = + CloudSyncHelper.downloadCloudSaves( + context, + gameSource, + gameIdStr, + shortcut, + ) + withContext(Dispatchers.Main) { + isWorking = false + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (ok) { + context.getString( + R.string.cloud_saves_sync_from_provider_success, + providerLabel, + ) + } else { + context.getString( + R.string.cloud_saves_sync_from_provider_failed, + providerLabel, + ) + }, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onBack = { currentTab = GameSettingsScreen.Menu }, + ) + } + + GameSettingsScreen.Uninstall -> { + UninstallConfirmation( + message = + if (isCustom) { + getString(R.string.library_games_remove_confirm, app.name) + } else { + getString(R.string.library_games_uninstall_confirm, app.name) + }, + confirmLabel = + if (isCustom) { + stringResource( + R.string.common_ui_remove, + ) + } else { + stringResource(R.string.common_ui_uninstall) + }, + onConfirm = { + if (isCustom) { + scope.launch(Dispatchers.IO) { + val cm = ContainerManager(context) + val sc = findLibraryShortcutForGame(cm, app, isCustom, isEpic, epicId) + sc?.let { LibraryShortcutUtils.deleteShortcutArtifacts(context, it) } + PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(app.id)) + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_removed, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + onDismissRequest() + } + } + } else if (isEpic) { + scope.launch(Dispatchers.IO) { + val result = EpicService.deleteGame(context, epicId) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message + ?: getString(R.string.common_ui_unknown_error), + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + } else { + SteamService.uninstallApp(app.id) { success -> + if (success) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_failed_to_uninstall), + android.widget.Toast.LENGTH_SHORT, + ) + } + onDismissRequest() + } + } + }, + onCancel = { currentTab = GameSettingsScreen.Menu }, + ) + } + } + } +} + +@Composable +internal fun UnifiedActivity.GOGGameSettingsDialog( + app: GOGGame, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + var currentTab by remember { mutableStateOf(GameSettingsScreen.Menu) } + val scope = rememberCoroutineScope() + var shortcutRefreshKey by remember(app.id) { mutableStateOf(0) } + var pinnedShortcutOverride by remember(app.id) { mutableStateOf(null) } + val currentRefreshSignal = this@GOGGameSettingsDialog.libraryRefreshSignal + val homeShortcutState by produceState( + HomeShortcutUiState(), + app.id, + currentRefreshSignal, + shortcutRefreshKey, + ) { + value = + withContext(Dispatchers.IO) { + val shortcut = + ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id + } + HomeShortcutUiState( + shortcut = shortcut, + isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + ) + } + } + val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned + + GameSettingsDialogFrame( + title = app.title, + onDismissRequest = onDismissRequest, + wide = currentTab == GameSettingsScreen.CloudSaves, + contentKey = currentTab, + ) { + when (currentTab) { + GameSettingsScreen.Menu -> { + GameSettingsActionGrid( + actions = + listOf( + GameSettingsActionItem( + title = stringResource(R.string.common_ui_settings), + icon = Icons.Outlined.Settings, + onClick = { + val containerManager = ContainerManager(context) + val shortcut = + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id + } ?: ShortcutSettingsComposeDialog.createLibraryShortcut( + context = context, + containerManager = containerManager, + source = "GOG", + appId = gogPseudoId(app.id), + gogId = app.id, + appName = app.title, + ) + if (shortcut != null) { + ShortcutSettingsComposeDialog(this@GOGGameSettingsDialog, shortcut).show() + } + onDismissRequest() + }, + ), + GameSettingsActionItem( + title = + stringResource( + if (hasPinnedShortcut) { + R.string.common_ui_remove + } else { + R.string.common_ui_shortcut + }, + ), + icon = Icons.Outlined.Home, + accentColor = if (hasPinnedShortcut) DangerRed else Accent, + onClick = { + if (hasPinnedShortcut) { + currentTab = GameSettingsScreen.Shortcut + } else { + scope.launch { + val artworkUrl = app.imageUrl.ifEmpty { app.iconUrl } + val created = + withContext(Dispatchers.IO) { + addGogShortcutToHomeScreen(context, app, artworkUrl) + } + if (!created) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString( + R.string.library_games_failed_to_create_shortcut, + app.title, + ), + ) + } + } + } + }, + ), + GameSettingsActionItem( + title = stringResource(R.string.cloud_saves_title), + icon = Icons.Outlined.CloudSync, + onClick = { currentTab = GameSettingsScreen.CloudSaves }, + ), + GameSettingsActionItem( + title = stringResource(R.string.common_ui_uninstall), + icon = Icons.Outlined.Delete, + accentColor = DangerRed, + onClick = { currentTab = GameSettingsScreen.Uninstall }, + ), + ), + ) + } + + GameSettingsScreen.Shortcut -> { + ShortcutRemovalConfirmation( + message = stringResource(R.string.shortcuts_list_remove_game_shortcut_message, app.title), + onConfirm = { + scope.launch { + val removed = + withContext(Dispatchers.IO) { + homeShortcutState.shortcut?.let { + LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) + } == true + } + pinnedShortcutOverride = if (removed) false else hasPinnedShortcut + shortcutRefreshKey++ + currentTab = GameSettingsScreen.Menu + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (removed) { + context.getString(R.string.shortcuts_list_removed) + } else { + context.getString(R.string.common_ui_unknown_error) + }, + android.widget.Toast.LENGTH_SHORT, + ) + } + }, + onCancel = { currentTab = GameSettingsScreen.Menu }, + ) + } + + GameSettingsScreen.CloudSaves -> { + var isWorking by remember { mutableStateOf(false) } + val shortcut = + remember(app.id) { + ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id + } + } + var cloudSyncEnabled by remember(shortcut?.file?.absolutePath) { + mutableStateOf(isShortcutCloudSyncEnabled(shortcut)) + } + var offlineModeEnabled by remember(shortcut?.file?.absolutePath) { + mutableStateOf(isShortcutOfflineMode(shortcut)) + } + + val gogProviderLabel = stringResource(R.string.preloader_platform_gog) + + CloudSavesContent( + activity = this@GOGGameSettingsDialog, + isWorking = isWorking, + cloudSyncEnabled = cloudSyncEnabled, + offlineModeEnabled = offlineModeEnabled, + gameSource = GameSaveBackupManager.GameSource.GOG, + gameId = app.id, + gameName = app.title, + shortcut = shortcut, + onCloudSyncToggle = { enabled -> + cloudSyncEnabled = enabled + setShortcutCloudSyncEnabled(shortcut, enabled) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (enabled) { + context.getString(R.string.cloud_sync_enabled_summary) + } else { + context.getString(R.string.cloud_sync_disabled_summary) + }, + android.widget.Toast.LENGTH_SHORT, + ) + }, + onOfflineModeToggle = { enabled -> + offlineModeEnabled = enabled + setShortcutOfflineMode(shortcut, enabled) + }, + onSyncFromCloud = { + if (!isWorking) { + isWorking = true + scope.launch(Dispatchers.IO) { + val ok = + CloudSyncHelper.downloadCloudSaves( + context, + GameSaveBackupManager.GameSource.GOG, + app.id, + shortcut, + ) + withContext(Dispatchers.Main) { + isWorking = false + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (ok) { + context.getString( + R.string.cloud_saves_sync_from_provider_success, + gogProviderLabel, + ) + } else { + context.getString( + R.string.cloud_saves_sync_from_provider_failed, + gogProviderLabel, + ) + }, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onBack = { currentTab = GameSettingsScreen.Menu }, + ) + } + + GameSettingsScreen.Uninstall -> { + UninstallConfirmation( + message = getString(R.string.library_games_uninstall_confirm, app.title), + onConfirm = { + scope.launch(Dispatchers.IO) { + val result = GOGService.deleteGame( + context, + LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG), + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.title), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message + ?: getString(R.string.common_ui_unknown_error), + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + }, + onCancel = { currentTab = GameSettingsScreen.Menu }, + ) + } + } + } +} + +@Composable +internal fun UnifiedActivity.LibraryGameDetailDialog( + app: SteamApp, + gogGame: GOGGame? = null, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var currentScreen by remember { mutableStateOf(LibraryDetailScreen.Main) } + var activePopup by remember { mutableStateOf(null) } + var showAchievements by remember(app.id) { mutableStateOf(false) } + var shortcutRefreshKey by remember(app.id, gogGame?.id) { mutableStateOf(0) } + var pinnedShortcutOverride by remember(app.id, gogGame?.id) { mutableStateOf(null) } + var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val isGog = gogGame != null + val epicId = if (isEpic) app.id - 2000000000 else 0 + + val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( + initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), + ) + val hasBlockingSteamDownloadForLibrary = + !isCustom && !isEpic && !isGog && + libraryDownloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_STEAM && + it.storeGameId == app.id.toString() && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + val hasBlockingEpicDownloadForLibrary = + isEpic && + libraryDownloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_EPIC && + it.storeGameId == epicId.toString() && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + val hasBlockingGogDownloadForLibrary = + isGog && + libraryDownloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_GOG && + it.storeGameId == gogGame?.id && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + + val epicGame by produceState(initialValue = null, key1 = epicId) { + value = if (isEpic) db.epicGameDao().getById(epicId) else null + } + + val epicArtworkUrl by produceState(initialValue = null, key1 = isEpic, key2 = epicId) { + value = + if (isEpic) { + val eg = db.epicGameDao().getById(epicId) + eg?.primaryImageUrl ?: eg?.iconUrl + } else { + null + } + } + val currentRefreshSignal = this@LibraryGameDetailDialog.libraryRefreshSignal + val homeShortcutState by produceState( + HomeShortcutUiState(), + app.id, + gogGame?.id, + isCustom, + isEpic, + isGog, + epicId, + currentRefreshSignal, + shortcutRefreshKey, + ) { + value = + withContext(Dispatchers.IO) { + val shortcut = + when { + isGog -> { + ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id + } + } + + else -> { + findLibraryShortcutForGame(ContainerManager(context), app, isCustom, isEpic, epicId) + } + } + HomeShortcutUiState( + shortcut = shortcut, + isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true, + ) + } + } + val artworkRefreshListener = + remember(app.id, gogGame?.id) { + object : EventDispatcher.JavaEventListener { + override fun onEvent(event: Any) { + if (event is AndroidEvent.LibraryArtworkChanged) { + shortcutRefreshKey++ + } + } + } + } + DisposableEffect(artworkRefreshListener) { + PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) + onDispose { + PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, artworkRefreshListener) + } + } + val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned + + BackHandler(enabled = activePopup != null) { + activePopup = null + } + + // Hero image + val customHeroImageFile = + homeShortcutState.shortcut + ?.getExtra("customLibraryHeroArtPath") + ?.takeIf { it.isNotBlank() } + ?.let { java.io.File(it) } + ?.takeIf { it.exists() } + val customHeroImageCacheKey = + customHeroImageFile?.let { + "library_custom_hero:${it.absolutePath}:${it.lastModified()}" + } + val heroImageUrl: Any? = + customHeroImageFile ?: when { + isGog -> { + StoreArtworkCache.imageModel(context, StoreArtworkCache.gogHeroRef(gogGame!!)) + } + + isEpic -> { + epicGame?.let { StoreArtworkCache.imageModel(context, StoreArtworkCache.epicHeroRef(it)) } + } + + isCustom -> { + val customCoverArt = + homeShortcutState.shortcut + ?.getExtra("customCoverArtPath") + ?.takeIf { it.isNotBlank() } + ?.let { java.io.File(it) } + ?.takeIf { it.exists() } + customCoverArt ?: run { + val safeName = app.name.replace("/", "_").replace("\\", "_") + val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") + if (iconFile.exists()) iconFile else null + } + } + + else -> { + val heroUrl = app.getHeroUrl() + StoreArtworkCache.imageModel(context, StoreArtworkCache.steamRef(app, "hero", heroUrl)) + } + } + + val subtitle = + when { + isGog -> { + gogGame!!.developer + } + + isCustom -> { + stringResource(R.string.library_games_custom_game) + } + + isEpic -> { + epicGame?.developer ?: "" + } + + else -> { + listOfNotNull( + app.developer.takeIf { it.isNotBlank() }, + app.publisher.takeIf { it.isNotBlank() }, + ).distinctBy { it.trim().lowercase() }.joinToString(" • ") + } + } + + // Playtime info + val playtimePrefs = + remember { + context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) + } + val searchKey = + remember(app) { + if (app.id >= 2000000000 || app.id < 0) { + app.name + } else { + app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") + } + } + val lastPlayed = playtimePrefs.getLong("${searchKey}_last_played", 0L) + val totalPlaytime = playtimePrefs.getLong("${searchKey}_playtime", 0L) + val playCount = playtimePrefs.getInt("${searchKey}_play_count", 0) + + val sourceLabel = + when { + isGog -> "GOG" + isEpic -> "Epic Games" + isCustom -> "Custom" + else -> "Steam" + } + + // Install path + val installPath = + remember(app, gogGame) { + when { + isGog -> { + gogGame!!.installPath + } + + isEpic -> { + epicGame?.installPath ?: "" + } + + isCustom -> { + app.gameDir + } + + else -> { + try { + SteamService.getAppDirPath(app.id) + } catch (_: Exception) { + "" + } + } + } + } + + // Install size (computed async) + val installSizeText by produceState(initialValue = null, key1 = installPath) { + value = + if (installPath.isNotBlank()) { + withContext(Dispatchers.IO) { + try { + val bytes = StorageUtils.getFolderSize(installPath) + if (bytes > 0) StorageUtils.formatBinarySize(bytes) else null + } catch (_: Exception) { + null + } + } + } else { + null + } + } + + // Export / Import launchers (reuse GameSettingsDialog pattern) + + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + if (uri != null) { + scope.launch(Dispatchers.IO) { + try { + val os = context.contentResolver.openOutputStream(uri) ?: return@launch + val zos = java.util.zip.ZipOutputStream(java.io.BufferedOutputStream(os)) + val containerManager = ContainerManager(context) + val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + val dirsToZip = mutableListOf() + val goldbergSaves = java.io.File(SteamService.getAppDirPath(app.id), "steam_settings/saves") + if (goldbergSaves.exists() && goldbergSaves.isDirectory) dirsToZip.add(goldbergSaves) + if (shortcut != null) { + val prefixDir = java.io.File(shortcut.container.getRootDir(), ".wine/drive_c/users/xuser") + listOf("Documents", "Saved Games", "AppData").forEach { name -> + val dir = java.io.File(prefixDir, name) + if (dir.exists()) dirsToZip.add(dir) + } + } + + fun zipDir( + dir: java.io.File, + baseName: String, + ) { + val children = dir.listFiles() ?: return + for (child in children) { + val name = if (baseName.isEmpty()) child.name else "$baseName/${child.name}" + if (child.isDirectory) { + zos.putNextEntry(java.util.zip.ZipEntry("$name/")) + zos.closeEntry() + zipDir(child, name) + } else { + zos.putNextEntry(java.util.zip.ZipEntry(name)) + child.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + } + } + } + for (dir in dirsToZip) { + zos.putNextEntry(java.util.zip.ZipEntry("${dir.name}/")) + zos.closeEntry() + zipDir(dir, dir.name) + } + zos.close() + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.saves_import_export_exported, + android.widget.Toast.LENGTH_SHORT, + ) + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.saves_import_export_exported_failed, e.message), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + val importLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + scope.launch(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) ?: return@launch + val zis = java.util.zip.ZipInputStream(java.io.BufferedInputStream(inputStream)) + val containerManager = ContainerManager(context) + val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + val goldbergSavesParent = + java.io.File( + if (isEpic) app.gameDir else SteamService.getAppDirPath(app.id), + if (isEpic) "" else "steam_settings", + ) + val prefixDir = shortcut?.let { java.io.File(it.container.getRootDir(), ".wine/drive_c/users/xuser") } + var ze: java.util.zip.ZipEntry? + while (zis.nextEntry.also { ze = it } != null) { + val entry = ze!! + val name = entry.name + var destFile: java.io.File? = null + if (name.startsWith("saves/")) { + destFile = java.io.File(goldbergSavesParent, name) + } else if (prefixDir != null && + (name.startsWith("Documents/") || name.startsWith("Saved Games/") || name.startsWith("AppData/")) + ) { + destFile = java.io.File(prefixDir, name) + } + if (destFile != null) { + if (entry.isDirectory) { + destFile.mkdirs() + } else { + destFile.parentFile?.mkdirs() + java.io.FileOutputStream(destFile).use { fos -> zis.copyTo(fos) } + } + } + zis.closeEntry() + } + zis.close() + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.saves_import_export_imported, + android.widget.Toast.LENGTH_SHORT, + ) + } + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.saves_import_export_imported_failed, e.message), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + val uninstallGame: () -> Unit = { + if (isGog) { + scope.launch(Dispatchers.IO) { + val result = GOGService.deleteGame( + context, + LibraryItem( + "GOG_${gogGame!!.id}", + gogGame.title, + com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG, + ), + ) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message ?: getString(R.string.common_ui_unknown_error), + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + } else if (isCustom) { + scope.launch(Dispatchers.IO) { + val cm = ContainerManager(context) + val sc = findLibraryShortcutForGame(cm, app, isCustom, isEpic, epicId) + sc?.let { LibraryShortcutUtils.deleteShortcutArtifacts(context, it) } + java.io + .File( + context.filesDir, + "custom_icons/${app.name.replace("/", "_")}.png", + ).delete() + PluviaApp.events.emit(AndroidEvent.LibraryInstallStatusChanged(app.id)) + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_removed, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + onDismissRequest() + } + } + } else if (isEpic) { + scope.launch(Dispatchers.IO) { + val result = EpicService.deleteGame(context, epicId) + withContext(Dispatchers.Main) { + if (result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message ?: "", + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + } else { + SteamService.uninstallApp(app.id) { success -> + if (success) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_game_uninstalled, app.name), + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.library_games_failed_to_uninstall), + android.widget.Toast.LENGTH_SHORT, + ) + } + onDismissRequest() + } + } + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape, + color = Color.Black, + ) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + val showHero = currentScreen == LibraryDetailScreen.Main + val subScreenTitle = + when (currentScreen) { + LibraryDetailScreen.Shortcut -> stringResource(R.string.common_ui_shortcut) + LibraryDetailScreen.Uninstall -> + stringResource( + if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall, + ) + else -> "" + } + // Sub-screens get a compact title bar. The main launch view owns the full + // screen and draws artwork edge-to-edge in its content branch. + if (!showHero) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(SurfaceDark) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { currentScreen = LibraryDetailScreen.Main }) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.common_ui_back), + tint = TextPrimary, + ) + } + Text( + subScreenTitle, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 4.dp), + ) + Text( + app.name, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 16.dp), + ) + } + HorizontalDivider(color = CardBorder, thickness = 0.5.dp) + } + + // Bottom content + when (currentScreen) { + LibraryDetailScreen.Main -> { + // Lock Play while VERIFY / UPDATE is rewriting depots in place + // for this game — launching mid-write can corrupt the install. + val activePlayBlockingTask = + if (isCustom) { + null + } else if (isGog) { + val gogIdStr = gogGame!!.id + libraryDownloadRecords.firstOrNull { rec -> + rec.store == com.winlator.cmod.app.db.download + .DownloadRecord.STORE_GOG && + rec.storeGameId == gogIdStr && + rec.status == + com.winlator.cmod.app.db.download + .DownloadRecord.STATUS_DOWNLOADING && + ( + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_VERIFY || + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_UPDATE + ) + }?.taskType + } else if (isEpic) { + val appIdStr = epicId.toString() + libraryDownloadRecords.firstOrNull { rec -> + rec.store == com.winlator.cmod.app.db.download + .DownloadRecord.STORE_EPIC && + rec.storeGameId == appIdStr && + rec.status == + com.winlator.cmod.app.db.download + .DownloadRecord.STATUS_DOWNLOADING && + ( + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_VERIFY || + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_UPDATE + ) + }?.taskType + } else { + val appIdStr = app.id.toString() + libraryDownloadRecords.firstOrNull { rec -> + rec.store == com.winlator.cmod.app.db.download + .DownloadRecord.STORE_STEAM && + rec.storeGameId == appIdStr && + rec.status == + com.winlator.cmod.app.db.download + .DownloadRecord.STATUS_DOWNLOADING && + ( + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_VERIFY || + rec.taskType == + com.winlator.cmod.app.db.download + .DownloadRecord.TASK_UPDATE + ) + }?.taskType + } + val playEnabled = activePlayBlockingTask == null + val playDisabledLabel = + when (activePlayBlockingTask) { + com.winlator.cmod.app.db.download.DownloadRecord.TASK_VERIFY -> + stringResource(R.string.downloads_queue_phase_verifying) + com.winlator.cmod.app.db.download.DownloadRecord.TASK_UPDATE -> + stringResource(R.string.downloads_queue_phase_updating) + else -> null + } + val launchAppName = + when { + isEpic -> epicGame?.title?.takeIf { it.isNotBlank() } ?: app.name + isGog -> gogGame?.title?.takeIf { it.isNotBlank() } ?: app.name + else -> app.name + } + val heroToastAnchor = LocalView.current + var heroPopup by remember { mutableStateOf(null) } + var bootShortcut by remember { mutableStateOf(null) } + val resolveOrCreateShortcut: () -> com.winlator.cmod.runtime.container.Shortcut? = { + val containerManager = ContainerManager(context) + when { + isGog -> + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "GOG" && + it.getExtra("gog_id") == gogGame!!.id + } ?: ShortcutSettingsComposeDialog.createLibraryShortcut( + context = context, + containerManager = containerManager, + source = "GOG", + appId = gogPseudoId(gogGame!!.id), + gogId = gogGame.id, + appName = app.name, + ) + isCustom -> findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + else -> + findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + ?: ShortcutSettingsComposeDialog.createLibraryShortcut( + context = context, + containerManager = containerManager, + source = if (isEpic) "EPIC" else "STEAM", + appId = if (isEpic) epicId else app.id, + gogId = null, + appName = app.name, + ) + } + } + LibraryGameLaunchScreen( + appName = launchAppName, + subtitle = subtitle, + sourceLabel = sourceLabel, + heroImageUrl = heroImageUrl, + customHeroImageCacheKey = customHeroImageCacheKey, + releaseDateEpochSeconds = app.releaseDate, + totalPlaytimeMillis = totalPlaytime, + playCount = playCount, + lastPlayedMillis = lastPlayed, + installSizeText = installSizeText, + isCustom = isCustom, + hasPinnedShortcut = hasPinnedShortcut, + playEnabled = playEnabled, + playDisabledLabel = playDisabledLabel, + onBack = onDismissRequest, + onPlay = { + val containerManager = ContainerManager(context) + if (isCustom) { + launchCustomGame(context, containerManager, app.name) + } else if (isGog) { + launchGogGame(context, containerManager, gogGame!!) + } else if (isEpic) { + epicGame?.let { launchEpicGame(context, containerManager, it) } + } else { + launchSteamGame(context, containerManager, app) + } + onDismissRequest() + }, + onSettings = { + val shortcut = resolveOrCreateShortcut() + if (shortcut != null) { + // Layer the settings dialog on top; keep the detail dialog open underneath. + ShortcutSettingsComposeDialog(this@LibraryGameDetailDialog, shortcut).show() + } + }, + onBootToDesktop = { + val shortcut = resolveOrCreateShortcut() + if (shortcut != null) { + bootShortcut = shortcut + heroPopup = HeroLaunchPopup.BootToDesktop + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.shortcuts_list_not_available, + heroToastAnchor, + ) + } + }, + onAchievements = if (!isCustom && !isEpic && !isGog) { + { showAchievements = true } + } else null, + onShortcut = { + if (hasPinnedShortcut) { + heroPopup = HeroLaunchPopup.RemoveShortcut + } else { + scope.launch { + val created = + withContext(Dispatchers.IO) { + if (isGog) { + val artworkUrl = gogGame!!.imageUrl.ifEmpty { gogGame.iconUrl } + addGogShortcutToHomeScreen(context, gogGame, artworkUrl) + } else { + addLibraryShortcutToHomeScreen( + context, + app, + isCustom, + isEpic, + epicId, + epicArtworkUrl, + ) + } + } + if (!created) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString( + R.string.library_games_failed_to_create_shortcut, + app.name, + ), + ) + } + } + } + }, + onCloudSaves = { activePopup = LibraryDetailPopup.CloudSaves }, + onUninstall = uninstallGame, + // Store source tag actions. Steam exposes verify/update/workshop; + // Epic and GOG expose verify/update for installed games. + steamMenuEnabled = !isCustom && + (!isEpic || epicGame?.isInstalled == true) && + (!isGog || gogGame?.isInstalled == true), + showVerifyFiles = !isCustom && + (!isEpic || epicGame?.isInstalled == true) && + (!isGog || gogGame?.isInstalled == true), + showCheckForUpdate = !isCustom && + (!isEpic || epicGame?.isInstalled == true) && + (!isGog || gogGame?.isInstalled == true), + showWorkshop = !isEpic && !isGog, + areSteamActionsEnabled = + when { + isEpic -> !hasBlockingEpicDownloadForLibrary + isGog -> !hasBlockingGogDownloadForLibrary + else -> !hasBlockingSteamDownloadForLibrary + }, + onVerifyFiles = { + context.runIfOnlineOrToast { + scope.launch { + val started = + withContext(Dispatchers.IO) { + when { + isEpic -> EpicService.verifyGameFiles(context, epicId) + isGog -> GOGService.verifyGameFiles(context, gogGame!!.id) + else -> SteamService.downloadAppForVerify(app.id) + } + } + if (started != null) { + // Hand off to the activity-root host so the + // pop-up + completion notice outlive this dialog. + showTaskProgressPopup( + started, + if (isGog) gogGame!!.title else app.name, + getString(R.string.store_game_verify_complete), + getString(R.string.store_game_verify_failed_notice), + completeAsToast = true, + ) + } + if (started == null) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_download_already_active), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onCheckForUpdate = { + when { + isEpic -> startEpicUpdateCheck(epicId, app.name) + isGog -> startGogUpdateCheck(gogGame!!.id, gogGame.title) + else -> startUpdateCheck(app.id, app.name) + } + }, + onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true }, + ) + + when (heroPopup) { + HeroLaunchPopup.BootToDesktop -> + HeroBootDialog( + onConfirm = { choice -> + heroPopup = null + bootShortcut?.let { sc -> + val intent = + Intent(context, XServerDisplayActivity::class.java) + .putExtra("container_id", sc.container.id) + when (choice) { + HeroBootChoice.Desktop -> {} + HeroBootChoice.Cube32 -> + intent + .putExtra("shortcut_path", sc.file.absolutePath) + .putExtra("boot_exe", "C:\\ProgramData\\Microsoft\\Windows\\Graphics-Test-32bit.exe") + HeroBootChoice.Cube64 -> + intent + .putExtra("shortcut_path", sc.file.absolutePath) + .putExtra("boot_exe", "C:\\ProgramData\\Microsoft\\Windows\\Graphics-Test-64bit.exe") + } + context.startActivity(intent) + onDismissRequest() + } + }, + onDismissRequest = { heroPopup = null }, + ) + HeroLaunchPopup.RemoveShortcut -> + HeroRemoveShortcutDialog( + gameName = if (isGog) gogGame!!.title else app.name, + onConfirm = { + scope.launch { + val removed = + withContext(Dispatchers.IO) { + homeShortcutState.shortcut?.let { + LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) + } == true + } + pinnedShortcutOverride = if (removed) false else hasPinnedShortcut + shortcutRefreshKey++ + heroPopup = null + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (removed) { + context.getString(R.string.shortcuts_list_removed) + } else { + context.getString(R.string.common_ui_unknown_error) + }, + ) + } + }, + onDismissRequest = { heroPopup = null }, + ) + null -> {} + } + } + + LibraryDetailScreen.Shortcut -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.common_ui_shortcut), + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + fontWeight = FontWeight.Bold, + letterSpacing = 1.1.sp, + ) + + Spacer(Modifier.weight(1f)) + + ShortcutRemovalConfirmation( + message = + stringResource( + R.string.shortcuts_list_remove_game_shortcut_message, + if (isGog) gogGame!!.title else app.name, + ), + onConfirm = { + scope.launch { + val removed = + withContext(Dispatchers.IO) { + homeShortcutState.shortcut?.let { + LibraryShortcutUtils.disablePinnedHomeShortcut(context, it) + } == true + } + pinnedShortcutOverride = if (removed) false else hasPinnedShortcut + shortcutRefreshKey++ + currentScreen = LibraryDetailScreen.Main + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (removed) { + context.getString(R.string.shortcuts_list_removed) + } else { + context.getString(R.string.common_ui_unknown_error) + }, + ) + } + }, + onCancel = { currentScreen = LibraryDetailScreen.Main }, + ) + } + } + + LibraryDetailScreen.CloudSaves -> { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding(), + ) { + var isWorking by remember { mutableStateOf(false) } + + val detailGameSource = + when { + isGog -> GameSaveBackupManager.GameSource.GOG + isEpic -> GameSaveBackupManager.GameSource.EPIC + else -> GameSaveBackupManager.GameSource.STEAM + } + val detailGameId = + when { + isGog -> gogGame!!.id + isEpic -> epicId.toString() + else -> app.id.toString() + } + val detailShortcut = + remember(app.id, gogGame?.id, epicId, isGog, isEpic, isCustom) { + val containerManager = ContainerManager(context) + when { + isGog -> { + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id + } + } + + else -> { + findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + } + } + } + var cloudSyncEnabled by remember(detailShortcut?.file?.absolutePath) { + mutableStateOf(isShortcutCloudSyncEnabled(detailShortcut)) + } + var offlineModeEnabled by remember(detailShortcut?.file?.absolutePath) { + mutableStateOf(isShortcutOfflineMode(detailShortcut)) + } + + val detailProviderLabel = + when (detailGameSource) { + GameSaveBackupManager.GameSource.GOG -> + stringResource(R.string.preloader_platform_gog) + GameSaveBackupManager.GameSource.EPIC -> + stringResource(R.string.preloader_platform_epic) + GameSaveBackupManager.GameSource.CUSTOM -> + stringResource(R.string.preloader_platform_custom) + GameSaveBackupManager.GameSource.STEAM -> + stringResource(R.string.preloader_platform_steam) + } + + CloudSavesContent( + activity = this@LibraryGameDetailDialog, + isWorking = isWorking, + cloudSyncEnabled = cloudSyncEnabled, + offlineModeEnabled = offlineModeEnabled, + gameSource = detailGameSource, + gameId = detailGameId, + gameName = app.name, + shortcut = detailShortcut, + onCloudSyncToggle = { enabled -> + cloudSyncEnabled = enabled + setShortcutCloudSyncEnabled(detailShortcut, enabled) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (enabled) { + context.getString(R.string.cloud_sync_enabled_summary) + } else { + context.getString(R.string.cloud_sync_disabled_summary) + }, + android.widget.Toast.LENGTH_SHORT, + ) + }, + onOfflineModeToggle = { enabled -> + offlineModeEnabled = enabled + setShortcutOfflineMode(detailShortcut, enabled) + }, + onSyncFromCloud = { + if (!isWorking) { + isWorking = true + scope.launch(Dispatchers.IO) { + val ok = + CloudSyncHelper.downloadCloudSaves( + context, + detailGameSource, + detailGameId, + detailShortcut, + ) + withContext(Dispatchers.Main) { + isWorking = false + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (ok) { + context.getString( + R.string.cloud_saves_sync_from_provider_success, + detailProviderLabel, + ) + } else { + context.getString( + R.string.cloud_saves_sync_from_provider_failed, + detailProviderLabel, + ) + }, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + showBottomBack = false, + onBack = { currentScreen = LibraryDetailScreen.Main }, + ) + } + } + + LibraryDetailScreen.Uninstall -> { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource( + if (isCustom) R.string.library_games_remove_game else R.string.library_games_uninstall_game, + ), + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + fontWeight = FontWeight.Bold, + letterSpacing = 1.1.sp, + ) + + Spacer(Modifier.weight(1f)) + + UninstallConfirmation( + message = + if (isCustom) { + getString(R.string.library_games_remove_confirm, app.name) + } else { + getString(R.string.library_games_uninstall_confirm, app.name) + }, + confirmLabel = + stringResource( + if (isCustom) R.string.common_ui_remove else R.string.common_ui_uninstall, + ), + onConfirm = uninstallGame, + onCancel = { currentScreen = LibraryDetailScreen.Main }, + ) + } + } + } + } + + if (showAchievements) { + Dialog( + onDismissRequest = { showAchievements = false }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + com.winlator.cmod.feature.stores.steam.achievements.SteamAchievementsScreen( + appId = app.id, + appName = app.name, + onClose = { showAchievements = false }, + ) + } + } + + activePopup?.let { popup -> + LibraryDetailPopupFrame( + title = + when (popup) { + LibraryDetailPopup.CloudSaves -> + stringResource( + R.string.cloud_saves_title_for_provider, + when { + isGog -> stringResource(R.string.preloader_platform_gog) + isEpic -> stringResource(R.string.preloader_platform_epic) + isCustom -> stringResource(R.string.preloader_platform_custom) + else -> stringResource(R.string.preloader_platform_steam) + }, + app.name, + ) + }, + wide = popup == LibraryDetailPopup.CloudSaves, + onDismissRequest = { activePopup = null }, + ) { + when (popup) { + LibraryDetailPopup.CloudSaves -> { + var isWorking by remember { mutableStateOf(false) } + + val detailGameSource = + when { + isGog -> GameSaveBackupManager.GameSource.GOG + isEpic -> GameSaveBackupManager.GameSource.EPIC + isCustom -> GameSaveBackupManager.GameSource.CUSTOM + else -> GameSaveBackupManager.GameSource.STEAM + } + val detailShortcut = + remember(app.id, gogGame?.id, epicId, isGog, isEpic, isCustom) { + val containerManager = ContainerManager(context) + when { + isGog -> { + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame!!.id + } + } + + else -> { + findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) + } + } + } + val detailGameId = + when { + isGog -> gogGame!!.id + isEpic -> epicId.toString() + isCustom -> + detailShortcut?.let { GameSaveBackupManager.customGameId(it) } + ?: app.name + else -> app.id.toString() + } + var cloudSyncEnabled by remember(detailShortcut?.file?.absolutePath) { + mutableStateOf(isShortcutCloudSyncEnabled(detailShortcut)) + } + var offlineModeEnabled by remember(detailShortcut?.file?.absolutePath) { + mutableStateOf(isShortcutOfflineMode(detailShortcut)) + } + + val detailProviderLabel = + when (detailGameSource) { + GameSaveBackupManager.GameSource.GOG -> + stringResource(R.string.preloader_platform_gog) + GameSaveBackupManager.GameSource.EPIC -> + stringResource(R.string.preloader_platform_epic) + GameSaveBackupManager.GameSource.CUSTOM -> + stringResource(R.string.preloader_platform_custom) + GameSaveBackupManager.GameSource.STEAM -> + stringResource(R.string.preloader_platform_steam) + } + + CloudSavesContent( + activity = this@LibraryGameDetailDialog, + isWorking = isWorking, + cloudSyncEnabled = cloudSyncEnabled, + offlineModeEnabled = offlineModeEnabled, + gameSource = detailGameSource, + gameId = detailGameId, + gameName = app.name, + shortcut = detailShortcut, + onCloudSyncToggle = { enabled -> + cloudSyncEnabled = enabled + setShortcutCloudSyncEnabled(detailShortcut, enabled) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (enabled) { + context.getString(R.string.cloud_sync_enabled_summary) + } else { + context.getString(R.string.cloud_sync_disabled_summary) + }, + android.widget.Toast.LENGTH_SHORT, + ) + }, + onOfflineModeToggle = { enabled -> + offlineModeEnabled = enabled + setShortcutOfflineMode(detailShortcut, enabled) + }, + onSyncFromCloud = { + if (!isWorking) { + isWorking = true + scope.launch(Dispatchers.IO) { + val ok = + CloudSyncHelper.downloadCloudSaves( + context, + detailGameSource, + detailGameId, + detailShortcut, + ) + withContext(Dispatchers.Main) { + isWorking = false + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (ok) { + context.getString( + R.string.cloud_saves_sync_from_provider_success, + detailProviderLabel, + ) + } else { + context.getString( + R.string.cloud_saves_sync_from_provider_failed, + detailProviderLabel, + ) + }, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + showTitle = false, + showBottomBack = false, + onBack = { activePopup = null }, + ) + } + } + } + } + + if ( + currentScreen != LibraryDetailScreen.Main && + currentScreen != LibraryDetailScreen.CloudSaves + ) { + // Close button overlay + IconButton( + onClick = onDismissRequest, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .size(42.dp) + .shadow(8.dp, CircleShape, spotColor = Color.Black.copy(alpha = 0.35f)) + .clip(CircleShape) + .background(BgDark.copy(alpha = 0.7f)), + ) { + Icon(Icons.Outlined.Close, contentDescription = "Close", tint = TextPrimary) + } + } + } + + if (showWorkshopDialog) { + WorkshopDialog( + appId = app.id, + gameTitle = app.name, + onDismissRequest = { showWorkshopDialog = false }, + ) + } + } + } +} diff --git a/app/src/main/app/shell/UnifiedActivityHub.kt b/app/src/main/app/shell/UnifiedActivityHub.kt new file mode 100644 index 000000000..ea96a69d6 --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityHub.kt @@ -0,0 +1,2604 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.TabDef + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Main hub scaffold + top bar + glasses sheet + library carousel, split out of UnifiedActivity.kt (behavior-identical). + +@Composable +internal fun UnifiedActivity.UnifiedHub() { + val horizontalNavigationInsets = + WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + val initialLibraryLayoutMode = startupLibraryLayoutMode + val initialStoreVisible = startupStoreVisible ?: mapOf("steam" to true, "epic" to true, "gog" to true) + val initialContentFilters = startupContentFilters ?: mapOf("games" to true, "dlc" to false, "applications" to false, "tools" to false) + if (!startupBootstrapReady || initialLibraryLayoutMode == null) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(BgDark) + .windowInsetsPadding(horizontalNavigationInsets), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = Accent) + Text( + text = stringResource(R.string.common_ui_app_name), + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + ) + } + } + return + } + + val storeVisible = remember { mutableStateMapOf(*initialStoreVisible.entries.map { it.key to it.value }.toTypedArray()) } + var showAddCustomGame by remember { mutableStateOf(false) } + var showExitDialog by remember { mutableStateOf(false) } + var searchQueryTfv by remember { mutableStateOf(TextFieldValue("")) } + val searchQuery = searchQueryTfv.text + var localLibraryRefreshKey by remember { mutableIntStateOf(0) } + var shortcutDataRefreshKey by remember { mutableIntStateOf(0) } + var iconRefreshKey by remember { mutableIntStateOf(0) } + + val currentRefreshSignal = this@UnifiedHub.libraryRefreshSignal + val libraryRefreshKey = currentRefreshSignal + localLibraryRefreshKey + val shortcutRefreshKey = libraryRefreshKey + shortcutDataRefreshKey + val playtimeRefreshKey = this@UnifiedHub.libraryPlaytimeRefreshSignal + + val contentFilters = remember { mutableStateMapOf(*initialContentFilters.entries.map { it.key to it.value }.toTypedArray()) } + var libraryLayoutMode by remember { + mutableStateOf( + runCatching { LibraryLayoutMode.valueOf(PrefManager.libraryLayoutMode) } + .getOrElse { initialLibraryLayoutMode }, + ) + } + var immersiveMode by remember { mutableStateOf(PrefManager.libraryImmersiveMode) } + var immersiveBlur by remember { mutableStateOf(PrefManager.libraryImmersiveBlur) } + val tabs = remember(storeVisible.toMap()) { buildTabs(storeVisible) } + var selectedIdx by rememberSaveable { mutableIntStateOf(0) } + var selectedDownloadId by remember { mutableStateOf(null) } + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + LaunchedEffect(drawerState.isOpen) { + drawerOpen = drawerState.isOpen + if (!drawerState.isOpen) drawerNavBridge.controllerActive = false + } + val isLoggedIn by SteamService.isLoggedInFlow.collectAsState() + val chatServiceEnabled by SteamService.chatServiceEnabledFlow.collectAsState() + val isEpicLoggedIn by EpicAuthManager.isLoggedInFlow.collectAsState() + val isGogLoggedIn by GOGAuthManager.isLoggedInFlow.collectAsState() + val steamApps by db.steamAppDao().getAllOwnedApps().collectAsState(initial = emptyList()) + val context = LocalContext.current + val persona by SteamService.instance?.localPersona?.collectAsState() + ?: remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val rightDrawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val friends by SteamService.instance?.friendsList?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + var chatFriend by remember { mutableStateOf(null) } + val friendsDrawerOpen = rightDrawerState.isOpen + LaunchedEffect(rightDrawerState.isOpen) { + rightDrawerOpen = rightDrawerState.isOpen + if (!rightDrawerState.isOpen) friendsDrawerNavBridge.controllerActive = false + } + LaunchedEffect(Unit) { + (context as? UnifiedActivity)?.openFriendsSignal?.collect { + if (rightDrawerState.isOpen) rightDrawerState.close() else rightDrawerState.open() + } + } + var installedFriendGameIds by remember { mutableStateOf>(emptySet()) } + LaunchedEffect(friends) { + val ids = friends.map { it.gameAppId }.filter { it > 0 }.distinct() + installedFriendGameIds = + withContext(Dispatchers.IO) { ids.filter { SteamService.isAppInstalled(it) }.toSet() } + } + LaunchedEffect(isLoggedIn, chatServiceEnabled) { + if (isLoggedIn && chatServiceEnabled) { + while (true) { + runCatching { SteamService.instance?.refreshFriends() } + kotlinx.coroutines.delay(30_000L) + } + } + } + LaunchedEffect(isLoggedIn, friendsDrawerOpen, chatServiceEnabled) { + if (isLoggedIn && friendsDrawerOpen && chatServiceEnabled) { + while (true) { + runCatching { SteamService.instance?.syncFriendsPresence() } + kotlinx.coroutines.delay(5_000L) + } + } + } + LaunchedEffect(isLoggedIn, chatServiceEnabled) { + if (isLoggedIn && chatServiceEnabled) { + runCatching { com.winlator.cmod.feature.stores.steam.chat.ChatOverlayService.start(context) } + } + } + + val epicApps by db.epicGameDao().getAll().collectAsState(initial = emptyList()) + val gogApps by db.gogGameDao().getAll().collectAsState(initial = emptyList()) + + val controllerState = rememberControllerConnectionState() + val isControllerConnected = controllerState.isConnected + val isPS = controllerState.isPlayStation + val isLibraryTab = tabs.getOrNull(selectedIdx)?.key == "library" + + val libraryRefreshListener = + remember { + object : EventDispatcher.JavaEventListener { + override fun onEvent(event: Any) { + when (event) { + is AndroidEvent.LibraryInstallStatusChanged -> { + localLibraryRefreshKey++ + shortcutDataRefreshKey++ + iconRefreshKey++ + } + is AndroidEvent.LibraryArtworkChanged -> { + shortcutDataRefreshKey++ + iconRefreshKey++ + } + } + } + } + } + DisposableEffect(libraryRefreshListener) { + PluviaApp.events.onJava(AndroidEvent.LibraryInstallStatusChanged::class, libraryRefreshListener) + PluviaApp.events.onJava(AndroidEvent.LibraryArtworkChanged::class, libraryRefreshListener) + onDispose { + PluviaApp.events.offJava(AndroidEvent.LibraryInstallStatusChanged::class, libraryRefreshListener) + PluviaApp.events.offJava(AndroidEvent.LibraryArtworkChanged::class, libraryRefreshListener) + } + } + + LaunchedEffect(isEpicLoggedIn) { + if (isEpicLoggedIn) { + EpicService.start(context) + } + } + + LaunchedEffect(isGogLoggedIn) { + if (isGogLoggedIn) { + GOGService.start(context) + } + } + + val epicLoginLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + val code = result.data?.getStringExtra(EpicOAuthActivity.EXTRA_AUTH_CODE) + if (code != null) { + scope.launch { + val authResult = EpicAuthManager.authenticateWithCode(context, code) + if (authResult.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.stores_accounts_logged_in_epic, + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.stores_accounts_epic_login_failed, authResult.exceptionOrNull()?.message), + android.widget.Toast.LENGTH_LONG, + ) + } + } + } + } + } + + val gogLoginLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + val code = result.data?.getStringExtra(GOGOAuthActivity.EXTRA_AUTH_CODE) + if (!code.isNullOrBlank()) { + scope.launch { + val authResult = GOGAuthManager.authenticateWithCode(context, code) + if (authResult.isSuccess) { + GOGService.start(context) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.stores_accounts_logged_in_gog, + android.widget.Toast.LENGTH_SHORT, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.stores_accounts_gog_login_failed, authResult.exceptionOrNull()?.message), + android.widget.Toast.LENGTH_LONG, + ) + } + } + } + } + } + + val filteredSteamApps = + remember(steamApps, contentFilters.toMap()) { + steamApps.filter { app -> + when (app.type) { + com.winlator.cmod.feature.stores.steam.enums.AppType.game -> contentFilters["games"] == true + com.winlator.cmod.feature.stores.steam.enums.AppType.demo -> contentFilters["games"] == true + com.winlator.cmod.feature.stores.steam.enums.AppType.dlc -> contentFilters["dlc"] == true + com.winlator.cmod.feature.stores.steam.enums.AppType.application -> contentFilters["applications"] == true + com.winlator.cmod.feature.stores.steam.enums.AppType.tool -> contentFilters["tools"] == true + com.winlator.cmod.feature.stores.steam.enums.AppType.config -> contentFilters["tools"] == true + else -> contentFilters["games"] == true + } + } + } + + var globalSettingsApp by remember { mutableStateOf(null) } + var globalSettingsGogGame by remember { mutableStateOf(null) } + + LaunchedEffect(tabs.size) { if (selectedIdx >= tabs.size) selectedIdx = 0 } + LaunchedEffect(isLoggedIn, persona) { + if (isLoggedIn && persona == null) { + SteamService.requestUserPersona() + } + } + + val activity = LocalContext.current as? UnifiedActivity + + LaunchedEffect(tabs) { + activity?.keyEventFlow?.collect { event -> + val key = tabs.getOrNull(selectedIdx)?.key ?: "library" + when (event.keyCode) { + android.view.KeyEvent.KEYCODE_BUTTON_L1 -> { + selectedIdx = if (selectedIdx > 0) selectedIdx - 1 else tabs.size - 1 + } + + android.view.KeyEvent.KEYCODE_BUTTON_R1 -> { + selectedIdx = (selectedIdx + 1) % tabs.size + } + + android.view.KeyEvent.KEYCODE_BUTTON_START -> { + navigateToSettings(SettingsNavItem.STORES) + } + + android.view.KeyEvent.KEYCODE_BUTTON_SELECT -> { + if (key != "downloads") { + if (drawerState.isOpen) drawerState.close() else drawerState.open() + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_X -> { + if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { + activity?.openHeroForFocusedSignal?.tryEmit(Unit) + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_THUMBL -> { + if (key == "library") { + activity?.openSearchSignal?.tryEmit(Unit) + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_THUMBR -> { + if (key == "library") { + showAddCustomGame = true + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_B -> { + if (chatFriend != null) { + chatFriend = null + } else if (rightDrawerState.isOpen) { + rightDrawerState.close() + } else if (drawerState.isOpen) { + drawerState.close() + } else if (globalSettingsApp != null) { + globalSettingsApp = null + } else if (globalSettingsGogGame != null) { + globalSettingsGogGame = null + } else if (showAddCustomGame) { + showAddCustomGame = false + } else { + showExitDialog = true + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_Y -> { + if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { + if (selectedLibrarySource == "GOG") { + globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId } + return@collect + } + val isCustom = selectedSteamAppId < 0 + val epicId = if (selectedSteamAppId >= 2000000000) selectedSteamAppId - 2000000000 else 0 + + globalSettingsApp = ( + steamApps.find { it.id == selectedSteamAppId } + ?: if (isCustom) { + SteamApp(id = selectedSteamAppId, name = selectedSteamAppName, developer = "Custom") + } else if (epicId > 0) { + val epic = epicApps.find { it.id == epicId } + SteamApp( + id = selectedSteamAppId, + name = selectedSteamAppName, + developer = epic?.developer ?: "Epic Games", + gameDir = epic?.installPath ?: "", + ) + } else { + null + } + ) + } + } + + android.view.KeyEvent.KEYCODE_BUTTON_A, android.view.KeyEvent.KEYCODE_DPAD_CENTER -> { + if (key == "library" && (selectedSteamAppId != 0 || selectedGogGameId.isNotEmpty())) { + val isCustom = selectedSteamAppId < 0 + val epicId = if (selectedSteamAppId >= 2000000000) selectedSteamAppId - 2000000000 else 0 + val containerManager = ContainerManager(context) + if (isCustom) { + launchCustomGame(context, containerManager, selectedSteamAppName) + } else if (selectedLibrarySource == "GOG") { + gogApps.find { it.id == selectedGogGameId }?.let { + launchGogGame(context, containerManager, it) + } + } else if (epicId > 0) { + val epic = epicApps.find { it.id == epicId } + if (epic != null && epic.isInstalled) { + val dummyApp = + SteamApp(id = selectedSteamAppId, name = selectedSteamAppName, gameDir = epic.installPath) + launchSteamGame(context, containerManager, dummyApp) + } + } else { + val steam = steamApps.find { it.id == selectedSteamAppId } + if (steam != null) { + launchSteamGame(context, containerManager, steam) + } + } + } else if (key != "library" && key != "downloads") { + storeItemClickCallback?.invoke(storeFocusIndex.value) + } + } + + } + } + } + + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Rtl, + ) { + ModalNavigationDrawer( + drawerState = rightDrawerState, + drawerContent = { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, + ) { + com.winlator.cmod.feature.stores.steam.friends.FriendsDrawerContent( + isOpen = rightDrawerState.isOpen, + self = persona ?: com.winlator.cmod.feature.stores.steam.data.SteamFriend(), + friends = friends, + installedGameIds = installedFriendGameIds, + chatEnabled = chatServiceEnabled, + onSetState = { st -> scope.launch { SteamService.setPersonaState(st) } }, + onOpenChat = { f -> chatFriend = f; scope.launch { rightDrawerState.close() } }, + onJoinGame = { f -> + scope.launch { rightDrawerState.close() } + scope.launch { + val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) } + val installed = withContext(Dispatchers.IO) { SteamService.getInstalledApp(f.gameAppId) } + val label = f.gameName.ifBlank { context.getString(R.string.steam_join_the_game) } + if (app != null && installed != null) { + android.widget.Toast.makeText( + context, context.getString(R.string.steam_join_joining, f.name, label), android.widget.Toast.LENGTH_SHORT, + ).show() + launchSteamGame(context, ContainerManager(context), app, f.connectString) + } else { + android.widget.Toast.makeText( + context, + if (app != null) context.getString(R.string.steam_join_install, label, f.name) + else context.getString(R.string.steam_join_not_owned, label), + android.widget.Toast.LENGTH_LONG, + ).show() + } + } + }, + onPlayGame = { f -> + scope.launch { rightDrawerState.close() } + scope.launch { + val app = withContext(Dispatchers.IO) { SteamService.getAppInfoOf(f.gameAppId) } + if (app != null) { + launchSteamGame(context, ContainerManager(context), app, null) + } + } + }, + ) + } + }, + scrimColor = Color.Black.copy(alpha = 0.5f), + gesturesEnabled = rightDrawerState.isOpen, + ) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides androidx.compose.ui.unit.LayoutDirection.Ltr, + ) { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + DrawerContent( + persona = persona, + isOpen = drawerState.isOpen, + context = context, + scope = scope, + storeVisible = storeVisible, + contentFilters = contentFilters, + libraryLayoutMode = libraryLayoutMode, + immersiveMode = immersiveMode, + immersiveBlur = immersiveBlur, + onLibraryLayoutSelected = { + libraryLayoutMode = it + PrefManager.libraryLayoutMode = it.name + }, + onStoreVisibleChanged = { key, value -> + storeVisible[key] = value + PrefManager.libraryStoreVisible = storeVisible.entries.filter { it.value }.joinToString(",") { it.key } + }, + onContentFiltersChanged = { key, value -> + contentFilters[key] = value + PrefManager.libraryContentFilters = contentFilters.entries.filter { it.value }.joinToString(",") { it.key } + }, + onImmersiveModeChanged = { + immersiveMode = it + PrefManager.libraryImmersiveMode = it + }, + onImmersiveBlurChanged = { + immersiveBlur = it + PrefManager.libraryImmersiveBlur = it + }, + onExportAll = { + scope.launch { + val count = + withContext(Dispatchers.IO) { + com.winlator.cmod.feature.shortcuts.FrontendExporter.exportAll(context) + } + val dir = com.winlator.cmod.feature.shortcuts.FrontendExporter.resolveExportDir(context) + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + if (count > 0) { + context.getString(R.string.shortcuts_export_all_done, count, dir?.path ?: "") + } else { + context.getString(R.string.shortcuts_export_all_none) + }, + ) + } + }, + onExitApp = { + AppTerminationHelper.exitApplication(this@UnifiedHub, "hub_drawer_exit") + }, + ) + }, + scrimColor = Color.Black.copy(alpha = 0.5f), + gesturesEnabled = drawerState.isOpen, + ) { + Box( + Modifier + .fillMaxSize() + .background(BgDark) + .windowInsetsPadding(horizontalNavigationInsets), + ) { + val currentTabKeyForImmersive = tabs.getOrNull(selectedIdx)?.key ?: "library" + val immersiveActive = immersiveMode && currentTabKeyForImmersive == "library" + DisposableEffect(immersiveActive) { + applyImmersiveSystemBars(immersiveActive) + onDispose { applyImmersiveSystemBars(false) } + } + if (immersiveMode && currentTabKeyForImmersive == "library") { + val immersiveModel by immersiveBackgroundRef.collectAsState() + val immersiveRequest = + remember(immersiveModel, immersiveBlur, context) { + val builder = ImageRequest.Builder(context).data(immersiveModel) + (immersiveModel as? java.io.File)?.takeIf { it.isFile }?.let { file -> + // Custom uploads can be overwritten in place. + val key = "library_immersive_bg:${file.absolutePath}:${file.lastModified()}" + builder.memoryCacheKey(if (immersiveBlur) "$key:blur" else key).diskCacheKey(key) + } + if (immersiveBlur) { + // Blur baked into the bitmap at decode (quarter-res + radius 2 ≈ 8px on screen), so drawing costs the same as a plain image. + val dm = context.resources.displayMetrics + builder + .size(dm.widthPixels / 4, dm.heightPixels / 4) + .scale(coil.size.Scale.FILL) + .transformations(BoxBlurTransformation(radius = 2)) + } + builder.crossfade(400).build() + } + AnimatedVisibility( + visible = immersiveModel != null, + enter = fadeIn(tween(400)), + exit = fadeOut(tween(400)), + modifier = Modifier.matchParentSize(), + ) { + Box(Modifier.matchParentSize()) { + AsyncImage( + model = immersiveRequest, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + Box( + Modifier + .matchParentSize() + .background(BgDark.copy(alpha = 0.5f)), + ) + } + } + } + val scaffoldContainer = if (immersiveMode && currentTabKeyForImmersive == "library") Color.Transparent else BgDark + val openFileManager: () -> Unit = { + val internalPath = android.os.Environment.getExternalStorageDirectory().absolutePath + val managedRoots = driveRoots(includeInternal = true) + val containerManager = com.winlator.cmod.runtime.container.ContainerManager(context) + val containers = + containerManager.getContainers().map { + DirectoryPickerDialog.ManagedContainer(it.id, it.getName()) + } + DirectoryPickerDialog.showManager( + activity = this@UnifiedHub, + initialPath = internalPath, + managedRoots = managedRoots, + containers = containers, + onRunFile = { exePath, containerId -> + val container = containerManager.getContainerById(containerId) + if (container != null) { + val winePath = + com.winlator.cmod.runtime.wine.WineUtils + .hostPathToMappedWinePath(container, exePath) + startActivity( + android.content.Intent( + this@UnifiedHub, + com.winlator.cmod.runtime.display.XServerDisplayActivity::class.java, + ).apply { + putExtra("container_id", container.id) + putExtra("boot_exe", winePath) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + }, + onCreateShortcut = { exePath -> + val exeFile = java.io.File(exePath) + addCustomGame( + context, + exeFile.nameWithoutExtension, + exePath, + exeFile.parent ?: exePath, + ) + localLibraryRefreshKey++ + }, + ) + } + Scaffold( + containerColor = scaffoldContainer, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopBar(tabs, selectedIdx, { + selectedIdx = it + }, persona, context, scope, isControllerConnected, isPS, isLibraryTab, searchQueryTfv, { + searchQueryTfv = + it + }, onFilterClicked = { scope.launch { drawerState.open() } }, onFriendsClicked = { scope.launch { rightDrawerState.open() } }) { + if (selectedLibrarySource == "GOG") { + globalSettingsGogGame = gogApps.find { it.id == selectedGogGameId } + } else { + globalSettingsApp = ( + steamApps.find { it.id == selectedSteamAppId } + ?: if (selectedSteamAppId < 0) { + SteamApp( + id = selectedSteamAppId, + name = selectedSteamAppName, + developer = "Custom", + ) + } else if (selectedSteamAppId >= 2000000000) { + val epicId = selectedSteamAppId - 2000000000 + val epic = epicApps.find { it.id == epicId } + SteamApp( + id = selectedSteamAppId, + name = selectedSteamAppName, + developer = epic?.developer ?: "Epic Games", + gameDir = epic?.installPath ?: "", + ) + } else { + null + } + ) + } + } + }, + ) { padding -> + LaunchedEffect(selectedIdx, tabs) { + currentTabKey = tabs.getOrNull(selectedIdx)?.key ?: "library" + storeFocusIndex.value = 0 + downloadsNavBridge.controllerActive = false + } + + val key = tabs.getOrNull(selectedIdx)?.key ?: "library" + val innerBoxBg = if (immersiveMode && key == "library") Color.Transparent else BgDark + + Box(Modifier.padding(padding).fillMaxSize().background(innerBoxBg)) { + + LaunchedEffect(key) { libraryTabActive.value = (key == "library") } + + // Keep Library composed so its state survives tab switches. + Box( + Modifier.fillMaxSize().let { + if (key == "library") { + it + } else { + it.alpha(0f).pointerInput(Unit) { /* block ghost taps */ } + } + }, + ) { + LibraryCarousel( + isLoggedIn = isLoggedIn, + steamApps = filteredSteamApps, + epicApps = epicApps, + gogApps = gogApps, + layoutMode = libraryLayoutMode, + libraryRefreshKey = libraryRefreshKey, + shortcutRefreshKey = shortcutRefreshKey, + playtimeRefreshKey = playtimeRefreshKey, + iconRefreshKey = iconRefreshKey, + searchQuery = searchQuery, + isControllerConnected = isControllerConnected, + ) + } + + if (key != "library") { + AnimatedContent( + targetState = key, + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(150)) + }, + label = "tabContent", + ) { animatedKey -> + when (animatedKey) { + "downloads" -> { + DownloadsTab( + selectedDownloadId, + animationsActive = key == "downloads", + onSelectDownload = { selectedDownloadId = it }, + ) + } + + "steam" -> { + SteamStoreTab(isLoggedIn, filteredSteamApps, searchQuery, LibraryLayoutMode.GRID_4) + } + + "epic" -> { + EpicStoreTab(isEpicLoggedIn, epicApps, searchQuery, LibraryLayoutMode.GRID_4) { + epicLoginLauncher.launch(Intent(this@UnifiedHub, EpicOAuthActivity::class.java)) + } + } + + "gog" -> { + GOGStoreTab(isGogLoggedIn, gogApps, searchQuery, LibraryLayoutMode.GRID_4) { + gogLoginLauncher.launch(Intent(this@UnifiedHub, GOGOAuthActivity::class.java)) + } + } + + else -> {} + } + } + } + + val configuration = LocalConfiguration.current + val libraryFabBase = minOf(configuration.screenWidthDp, configuration.screenHeightDp) + val addGameFabSize = (libraryFabBase * 0.125f).dp.coerceIn(56.dp, 64.dp) + val addGameFabMargin = (libraryFabBase * 0.035f).dp.coerceIn(12.dp, 20.dp) + val addGameFabIconSize = (libraryFabBase * 0.055f).dp.coerceIn(24.dp, 28.dp) + val fabNavInsets = WindowInsets.navigationBars.asPaddingValues() + val fabEndInset = + (20.dp - fabNavInsets.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr)) + .coerceAtLeast(4.dp) + val fabStartInset = + (20.dp - fabNavInsets.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr)) + .coerceAtLeast(4.dp) + + if (drawerState.isClosed) { + DrawerSwipeHotZone( + modifier = Modifier.align(Alignment.CenterStart), + onOpenDrawer = { scope.launch { drawerState.open() } }, + ) + } + if (rightDrawerState.isClosed) { + DrawerSwipeHotZone( + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 22.dp), + isRightSide = true, + onOpenDrawer = { scope.launch { rightDrawerState.open() } }, + ) + } + + // Composed after the hot zones so the FAB stays on top for hit-testing. + if (key == "library") { + Column( + modifier = + Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), + ) + .padding(end = fabEndInset, bottom = addGameFabMargin), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isControllerConnected) { + ControllerBadge("R3") + Spacer(Modifier.height(8.dp)) + } + Box( + modifier = + Modifier + .size(addGameFabSize) + .drawBehind { + drawCircle( + brush = + Brush.radialGradient( + colors = listOf(Accent.copy(alpha = 0.22f), Color.Transparent), + center = center, + radius = size.minDimension * 0.64f, + ), + radius = size.minDimension * 0.64f, + ) + } + .clip(CircleShape) + .background(Color.Transparent, CircleShape) + .border(1.5.dp, Accent.copy(alpha = 0.55f), CircleShape) + .focusProperties { canFocus = false } // No specific button for this, handle via long press or touch + .clickable( + interactionSource = null, + indication = androidx.compose.material3.ripple(color = Accent), + ) { showAddCustomGame = true }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.Add, + contentDescription = "Add Custom Game", + tint = Accent, + modifier = Modifier.size(addGameFabIconSize), + ) + } + } + } + + if (key == "library" || key == "downloads") { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .windowInsetsPadding( + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom), + ) + .padding(start = fabStartInset, bottom = addGameFabMargin) + .size(addGameFabSize) + .drawBehind { + drawCircle( + brush = + Brush.radialGradient( + colors = listOf(Accent.copy(alpha = 0.22f), Color.Transparent), + center = center, + radius = size.minDimension * 0.64f, + ), + radius = size.minDimension * 0.64f, + ) + } + .clip(CircleShape) + .background(Color.Transparent, CircleShape) + .border(1.5.dp, Accent.copy(alpha = 0.55f), CircleShape) + .focusProperties { canFocus = false } + .clickable( + interactionSource = null, + indication = androidx.compose.material3.ripple(color = Accent), + ) { openFileManager() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.FolderOpen, + contentDescription = "Files", + tint = Accent, + modifier = Modifier.size(addGameFabIconSize), + ) + } + } + } + } + } + } // end ModalNavigationDrawer + } // end inner LTR + } // end right friends ModalNavigationDrawer + } // end RTL provider + + if (globalSettingsApp != null) { + GameSettingsDialog( + app = globalSettingsApp!!, + onDismissRequest = { globalSettingsApp = null }, + ) + } + if (globalSettingsGogGame != null) { + GOGGameSettingsDialog( + app = globalSettingsGogGame!!, + onDismissRequest = { globalSettingsGogGame = null }, + ) + } + + if (showAddCustomGame) { + AddCustomGameDialog(onDismiss = { + showAddCustomGame = false + localLibraryRefreshKey++ + }) + } + + chatFriend?.let { cf -> + com.winlator.cmod.feature.stores.steam.friends.SteamChatScreen( + friend = friends.firstOrNull { it.steamId == cf.steamId } ?: cf, + onClose = { chatFriend = null }, + ) + } + + BackHandler(enabled = true) { + if (chatFriend != null) { + chatFriend = null + } else if (rightDrawerState.isOpen) { + scope.launch { rightDrawerState.close() } + } else if (drawerState.isOpen) { + scope.launch { drawerState.close() } + } else if (globalSettingsApp != null) { + globalSettingsApp = null + } else if (globalSettingsGogGame != null) { + globalSettingsGogGame = null + } else if (showAddCustomGame) { + showAddCustomGame = false + } else { + showExitDialog = true + } + } + + if (showExitDialog) { + Dialog( + onDismissRequest = { showExitDialog = false }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + ) { + Box( + modifier = + Modifier + .width(320.dp) + .clip(RoundedCornerShape(20.dp)) + .background(SurfaceDark) + .border(1.dp, Accent.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) + .padding(28.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.common_ui_exit_app_confirm), + style = MaterialTheme.typography.titleLarge, + color = TextPrimary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(24.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedButton( + onClick = { showExitDialog = false }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = TextSecondary), + border = androidx.compose.foundation.BorderStroke(1.dp, TextSecondary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.common_ui_cancel), fontWeight = FontWeight.Medium) + } + Button( + onClick = { + AppTerminationHelper.exitApplication(this@UnifiedHub, "hub_exit_menu") + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE53935)), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.common_ui_exit), color = Color.White, fontWeight = FontWeight.Bold) + } + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.DrawerSwipeHotZone( + modifier: Modifier = Modifier, + isRightSide: Boolean = false, + onOpenDrawer: () -> Unit, +) { + val density = LocalDensity.current + val openThresholdPx = with(density) { 36.dp.toPx() } + + Box( + modifier = + modifier + .fillMaxHeight() + .width(if (isRightSide) 30.dp else 40.dp) + .pointerInput(openThresholdPx, isRightSide) { + var accumulatedDrag = 0f + var opened = false + + detectHorizontalDragGestures( + onDragStart = { + accumulatedDrag = 0f + opened = false + }, + onHorizontalDrag = { change, dragAmount -> + val delta = if (isRightSide) -dragAmount else dragAmount + if (delta <= 0f || opened) return@detectHorizontalDragGestures + + accumulatedDrag += delta + change.consume() + + if (accumulatedDrag >= openThresholdPx) { + opened = true + onOpenDrawer() + } + }, + ) + }, + ) +} + +@Composable +internal fun UnifiedActivity.GlassesSettingsSheet(onDismiss: () -> Unit) { + val gm = com.winlator.cmod.runtime.display.GlassesManager + val settings by gm.settings.collectAsState() + val brightnessMax = gm.brightnessMax() + val volumeMax = gm.volumeMax() + val brightness = if (settings.brightness < 0) brightnessMax else settings.brightness + val volume = if (settings.volume < 0) volumeMax else settings.volume + val registry = remember { PaneNavRegistry() } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onDismiss) + androidx.compose.material3.Surface( + shape = RoundedCornerShape(24.dp), + color = SurfaceDark, + modifier = Modifier.fillMaxWidth(0.82f), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 22.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Eyeglasses2Icon, contentDescription = null, tint = Accent, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(10.dp)) + Text(gm.modelName(), color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GlassesLabel(stringResource(R.string.glasses_panel_refresh)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(60, 90, 120).forEach { hz -> + val selected = settings.refreshHz == hz + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background(if (selected) Accent else TextSecondary.copy(alpha = 0.12f)) + .paneNavItem(cornerRadius = 11.dp, onActivate = { gm.setRefreshHz(hz) }, isEntry = hz == 60) + .clickable { gm.setRefreshHz(hz) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text("$hz", color = if (selected) SurfaceDark else TextPrimary, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassesToggleTile(stringResource(R.string.glasses_panel_sunblock), + settings.sunblock, Modifier.weight(1f)) { gm.setSunblock(it) } + GlassesToggleTile(stringResource(R.string.session_drawer_output_3d), + settings.threeD, Modifier.weight(1f)) { gm.set3D(it) } + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + GlassesPercentSlider(stringResource(R.string.session_drawer_output_brightness), + brightness, brightnessMax) { gm.setBrightness(it) } + GlassesPercentSlider(stringResource(R.string.session_drawer_output_volume), + volume, volumeMax) { gm.setVolume(it) } + } + } + } + } + } + } +} + +@Composable +internal fun UnifiedActivity.GlassesLabel(text: String) { + Text(text, color = TextSecondary, fontSize = 13.sp, fontWeight = FontWeight.Medium) +} + +@Composable +internal fun UnifiedActivity.GlassesPercentSlider(label: String, level: Int, max: Int, onChange: (Int) -> Unit) { + val pct = if (max > 0) Math.round(level * 100f / max) else 0 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + GlassesLabel(label) + Text("$pct%", color = Accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + androidx.compose.material3.Slider( + value = level.toFloat(), + onValueChange = { onChange(it.roundToInt()) }, + valueRange = 0f..max.toFloat(), + steps = (max - 1).coerceAtLeast(0), + modifier = Modifier.paneNavItem( + cornerRadius = 8.dp, + onAdjust = { dir -> onChange((level + dir).coerceIn(0, max)) }, + ), + colors = androidx.compose.material3.SliderDefaults.colors( + thumbColor = Accent, + activeTrackColor = Accent, + inactiveTrackColor = TextSecondary.copy(alpha = 0.2f), + ), + ) + } +} + +@Composable +internal fun UnifiedActivity.GlassesToggleTile(label: String, checked: Boolean, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) { + Column( + modifier = modifier + .clip(RoundedCornerShape(13.dp)) + .background(if (checked) Accent.copy(alpha = 0.16f) else TextSecondary.copy(alpha = 0.08f)) + .paneNavItem(cornerRadius = 13.dp, onActivate = { onChange(!checked) }) + .clickable { onChange(!checked) } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(label, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) + androidx.compose.material3.Switch( + checked = checked, + onCheckedChange = onChange, + colors = androidx.compose.material3.SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Accent, + ), + ) + } +} + +@Composable +internal fun UnifiedActivity.TopBar( + tabs: List, + selectedIdx: Int, + onSelect: (Int) -> Unit, + persona: com.winlator.cmod.feature.stores.steam.data.SteamFriend?, + context: android.content.Context, + scope: kotlinx.coroutines.CoroutineScope, + isControllerConnected: Boolean, + isPS: Boolean, + isLibraryTab: Boolean, + searchQuery: TextFieldValue, + onSearchQueryChange: (TextFieldValue) -> Unit, + onFilterClicked: () -> Unit, + onFriendsClicked: () -> Unit = {}, + onGameSettingsClicked: () -> Unit, +) { + var isSearchExpanded by remember { mutableStateOf(false) } + val searchFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val isDownloadsTab = tabs.getOrNull(selectedIdx)?.key == "downloads" + val glassesConnected by com.winlator.cmod.runtime.display.GlassesManager.connected.collectAsState() + var showGlassesPanel by remember { mutableStateOf(false) } + + LaunchedEffect(selectedIdx) { + if (isSearchExpanded) { + onSearchQueryChange(TextFieldValue("")) + isSearchExpanded = false + } + } + + // Auto-focus the search field when expanded + LaunchedEffect(isSearchExpanded) { + if (isSearchExpanded) { + kotlinx.coroutines.delay(150) + searchFocusRequester.requestFocus() + } else if (searchQuery.text.isNotEmpty()) { + onSearchQueryChange(TextFieldValue("")) + } + } + + val controllerSearchActivity = LocalContext.current as? UnifiedActivity + LaunchedEffect(Unit) { + controllerSearchActivity?.openSearchSignal?.collect { + if (!isDownloadsTab) isSearchExpanded = true + } + } + LaunchedEffect(Unit) { + controllerSearchActivity?.openGlassesSignal?.collect { + if (glassesConnected) showGlassesPanel = true + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = UnifiedTopBarHorizontalPadding, + end = UnifiedTopBarHorizontalPadding, + top = UnifiedTopBarTopPadding, + ) + .height(UnifiedTopBarHeight), + ) { + // Center Block: Tabs (absolutely centered, unaffected by left/right content) + Row( + modifier = Modifier.align(Alignment.Center).zIndex(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + @Suppress("DEPRECATION") + CompositionLocalProvider( + androidx.compose.material3.LocalRippleConfiguration provides null, + ) { + val tabWidth = 100.dp + val tabSideGutter = 12.dp + val tabBarShape = RoundedCornerShape(18.dp) + val visibleCount = minOf(3, tabs.size) + val tabListState = rememberLazyListState() + val snapFlingBehavior = rememberSnapFlingBehavior(lazyListState = tabListState) + + LaunchedEffect(selectedIdx) { + val scrollTo = maxOf(0, selectedIdx - 1) + tabListState.animateScrollToItem(scrollTo) + } + + Box( + modifier = + Modifier + .width(tabWidth * visibleCount + tabSideGutter * 2) + .height(44.dp) + .shadow(8.dp, tabBarShape, spotColor = Color.Black.copy(alpha = 0.5f)) + .clip(tabBarShape) + .background(CardDark) + .border(1.dp, CardBorder, tabBarShape), + ) { + LazyRow( + state = tabListState, + flingBehavior = snapFlingBehavior, + modifier = + Modifier + .align(Alignment.Center) + .width(tabWidth * visibleCount) + .fillMaxHeight() + .focusProperties { canFocus = !isLibraryTab }, + userScrollEnabled = tabs.size > visibleCount, + ) { + itemsIndexed(tabs) { index, tab -> + val selected = selectedIdx == index + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val tabScale by animateFloatAsState( + targetValue = if (isPressed) 0.92f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessHigh), + label = "tabScale", + ) + val textColor by animateColorAsState( + targetValue = if (selected) Accent else TextSecondary, + animationSpec = tween(280), + label = "tabTextColor", + ) + + Box( + modifier = + Modifier + .width(tabWidth) + .fillMaxHeight() + .focusProperties { canFocus = false } + .graphicsLayer { + scaleX = tabScale + scaleY = tabScale + }.clickable( + interactionSource = interactionSource, + indication = null, + ) { onSelect(index) }, + contentAlignment = Alignment.Center, + ) { + Text( + text = tab.label.uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp, + maxLines = 1, + color = textColor, + ) + } + } + } + if (isControllerConnected) { + ControllerBadge( + "L1", + Modifier.align(Alignment.CenterStart).padding(start = 4.dp), + compact = true, + ) + ControllerBadge( + "R1", + Modifier.align(Alignment.CenterEnd).padding(end = 4.dp), + compact = true, + ) + } + } + } + } + + Row( + modifier = Modifier.align(Alignment.CenterStart).fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.Transparent) + .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) + .focusProperties { canFocus = !isLibraryTab }, + contentAlignment = Alignment.Center, + ) { + @Suppress("DEPRECATION") + CompositionLocalProvider( + androidx.compose.material3.LocalRippleConfiguration provides + androidx.compose.material3.RippleConfiguration(color = Accent), + ) { + IconButton(onClick = { + navigateToSettings(SettingsNavItem.STORES) + }, modifier = Modifier.size(44.dp), enabled = true) { + Icon(Icons.Outlined.Settings, contentDescription = "Menu", tint = Accent, modifier = Modifier.size(24.dp)) + } + } + } + if (isControllerConnected) { + Spacer(Modifier.width(4.dp)) + ControllerBadge(if (isPS) "\u2261" else "Start") + } + + Spacer(Modifier.width(6.dp)) + + val searchIconRotation by animateFloatAsState( + targetValue = if (isSearchExpanded) 90f else 0f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "searchIconRotation", + ) + + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background( + if (isSearchExpanded) { + Accent.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + ).border( + 1.dp, + Accent.copy(alpha = if (isDownloadsTab) 0.25f else 0.5f), + CircleShape, + ).focusProperties { canFocus = !isLibraryTab }, + contentAlignment = Alignment.Center, + ) { + @Suppress("DEPRECATION") + CompositionLocalProvider( + androidx.compose.material3.LocalRippleConfiguration provides + androidx.compose.material3.RippleConfiguration(color = Accent), + ) { + IconButton( + onClick = { + if (!isDownloadsTab) { + if (isSearchExpanded) { + onSearchQueryChange(TextFieldValue("")) + isSearchExpanded = false + } else { + isSearchExpanded = true + } + } + }, + modifier = Modifier.size(44.dp), + enabled = !isDownloadsTab, + ) { + Icon( + Icons.Outlined.Search, + contentDescription = "Search", + tint = + if (isDownloadsTab) { + TextSecondary.copy(alpha = 0.4f) + } else { + Accent + }, + modifier = + Modifier + .size(24.dp) + .graphicsLayer { rotationZ = searchIconRotation }, + ) + } + } + } + if (isControllerConnected) { + Spacer(Modifier.width(4.dp)) + ControllerBadge("L3") + } + } + + val topBarView = androidx.compose.ui.platform.LocalView.current + val topBarDensity = androidx.compose.ui.platform.LocalDensity.current + val topBarOrientation = androidx.compose.ui.platform.LocalConfiguration.current.orientation + val navRightInset = remember(topBarOrientation, topBarView) { + val px = androidx.core.view.ViewCompat.getRootWindowInsets(topBarView) + ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.right ?: 0 + with(topBarDensity) { px.toDp() } + } + Row( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().zIndex(2f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + + if (glassesConnected) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.Transparent) + .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) + .clickable { showGlassesPanel = true }, + contentAlignment = Alignment.Center, + ) { + Icon(Eyeglasses2Icon, contentDescription = "Glasses", tint = Accent, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(12.dp)) + } + + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.Transparent) + .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) + .focusProperties { canFocus = !isLibraryTab } + .clickable( + interactionSource = null, + indication = androidx.compose.material3.ripple(color = Accent), + ) { onFilterClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.FilterList, contentDescription = "Filter", tint = Accent, modifier = Modifier.size(24.dp)) + } + if (isControllerConnected) { + Spacer(Modifier.width(4.dp)) + ControllerBadge("Select") + } + + Spacer(Modifier.width(6.dp)) + + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.Transparent) + .border(1.dp, Accent.copy(alpha = 0.5f), CircleShape) + .clickable { onFriendsClicked() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.People, contentDescription = "Friends", tint = Accent, modifier = Modifier.size(24.dp)) + } + if (isControllerConnected && navRightInset <= 0.dp) { + Spacer(Modifier.width(8.dp)) + Box( + modifier = + Modifier + .background(Color(0xFF394048), RoundedCornerShape(15.dp)) + .border(1.dp, Color(0xFF8B949E).copy(alpha = 0.5f), RoundedCornerShape(15.dp)) + .padding(horizontal = 7.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = "Guide", + tint = Color(0xFFE6EDF3), + modifier = Modifier.size(16.dp), + ) + } + } + } + + if (isControllerConnected && navRightInset > 0.dp) { + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .offset(x = 38.dp) + .zIndex(2f) + .background(Color(0xFF394048), RoundedCornerShape(15.dp)) + .border(1.dp, Color(0xFF8B949E).copy(alpha = 0.5f), RoundedCornerShape(15.dp)) + .padding(horizontal = 7.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = "Guide", + tint = Color(0xFFE6EDF3), + modifier = Modifier.size(16.dp), + ) + } + } + } + + if (showGlassesPanel) GlassesSettingsSheet(onDismiss = { showGlassesPanel = false }) + + AnimatedVisibility( + visible = isSearchExpanded && !isDownloadsTab, + enter = + expandVertically( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(200)), + exit = + shrinkVertically( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(120)), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .widthIn(max = 600.dp) + .fillMaxWidth(0.7f) + .height(44.dp) + .shadow(8.dp, RoundedCornerShape(24.dp), spotColor = Color.Black.copy(alpha = 0.4f)) + .clip(RoundedCornerShape(24.dp)) + .background(SurfaceDark), + contentAlignment = Alignment.CenterStart, + ) { + Row( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Search, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(12.dp)) + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + singleLine = true, + textStyle = + TextStyle( + color = TextPrimary, + fontSize = 15.sp, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }), + cursorBrush = Brush.verticalGradient(listOf(Accent, AccentGlow)), + modifier = + Modifier + .weight(1f) + .focusRequester(searchFocusRequester), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchQuery.text.isEmpty()) { + Text( + "Search games", + style = + TextStyle( + color = TextSecondary, + fontSize = 15.sp, + ), + ) + } + innerTextField() + } + }, + ) + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { onSearchQueryChange(TextFieldValue("")) }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Outlined.Close, + contentDescription = "Clear", + tint = TextSecondary, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + } + } // end Column +} + +@Composable +internal fun UnifiedActivity.LibraryCarousel( + isLoggedIn: Boolean, + steamApps: List, + epicApps: List, + gogApps: List, + layoutMode: LibraryLayoutMode, + libraryRefreshKey: Int = 0, + shortcutRefreshKey: Int = 0, + playtimeRefreshKey: Int = 0, + iconRefreshKey: Int = 0, + searchQuery: String = "", + isControllerConnected: Boolean = false, +) { + val context = LocalContext.current + + var cachedShortcuts by remember { mutableStateOf>(emptyList()) } + var customApps by remember { mutableStateOf>(emptyList()) } + var localLibraryRefreshKey by remember { mutableIntStateOf(0) } + var shortcutsLoaded by remember { mutableStateOf(false) } + var pullRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey) { + shortcutsLoaded = false + + val shortcutScanResult = + runCatching { + withContext(Dispatchers.IO) { + val cm = ContainerManager(context) + cm.upgradeShortcuts { + localLibraryRefreshKey++ + } + val allShortcuts = cm.loadShortcuts() + val apps = + allShortcuts + .mapNotNull { shortcut -> + if (!LibraryShortcutUtils.isCustomLibraryShortcut(shortcut)) { + return@mapNotNull null + } + + val displayName = + shortcut + .getExtra("custom_name", shortcut.name) + .ifBlank { shortcut.name } + + val uuid = shortcut.getExtra("uuid") + val customId = if (uuid.isNotEmpty()) { + -(uuid.hashCode().and(0x7FFFFFFF) + 1) + } else { + -(displayName.hashCode().and(0x7FFFFFFF) + 1) + } + + SteamApp( + id = customId, + name = displayName, + developer = "Custom", + gameDir = + shortcut.getExtra( + "game_install_path", + shortcut.getExtra("custom_game_folder", ""), + ), + ) + } + + allShortcuts to apps + } + }.getOrNull() + + if (shortcutScanResult != null) { + cachedShortcuts = shortcutScanResult.first + customApps = shortcutScanResult.second + } + + shortcutsLoaded = true + } + + // Move library filtering and file checks off the main thread. + var mergedInstalledApps by remember { mutableStateOf>(emptyList()) } + var installedApps by remember { mutableStateOf>(emptyList()) } + var stableInstalledApps by remember { mutableStateOf>(emptyList()) } + var gogByPseudoId by remember { mutableStateOf>(emptyMap()) } + var epicByPseudoId by remember { mutableStateOf>(emptyMap()) } + var stableGogByPseudoId by remember { mutableStateOf>(emptyMap()) } + var stableEpicByPseudoId by remember { mutableStateOf>(emptyMap()) } + var customArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var customGridArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var customCarouselArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var customListArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var customIconPathByAppId by remember { mutableStateOf>(emptyMap()) } + var stableCustomArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var stableCustomGridArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var stableCustomCarouselArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var stableCustomListArtworkPathByAppId by remember { mutableStateOf>(emptyMap()) } + var stableCustomIconPathByAppId by remember { mutableStateOf>(emptyMap()) } + var artworkCacheRefreshKey by remember { mutableIntStateOf(0) } + var libraryLoaded by remember { mutableStateOf(false) } + // Suppress transient empty states before background recomputation starts. + val scanInputToken = + remember(steamApps, epicApps, gogApps, customApps, libraryRefreshKey, localLibraryRefreshKey) { Any() } + var processedScanToken by remember { mutableStateOf(null) } + + LaunchedEffect(scanInputToken) { + withContext(Dispatchers.IO) { + val steamInstalled = steamApps.filter { SteamService.isAppInstalled(it.id) } + + val epicInstalled = epicApps.filter { it.isInstalled } + + // Match Epic's DB-backed install filter during verify/update. + val gogInstalled = gogApps.filter { it.isInstalled } + + val gogMap = gogInstalled.associateBy { gogPseudoId(it.id) } + val epicMap = epicInstalled.associateBy { 2000000000 + it.id } + + val playtimePrefs = context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) + val allPlaytime = playtimePrefs.all + val mappedEpic = + epicInstalled.map { epic -> + SteamApp( + id = 2000000000 + epic.id, + name = epic.title, + developer = epic.developer, + gameDir = epic.installPath, + ) + } + val mappedGog = + gogInstalled.map { gog -> + SteamApp( + id = gogPseudoId(gog.id), + name = gog.title, + developer = gog.developer, + gameDir = gog.installPath, + ) + } + val merged = steamInstalled + customApps + mappedEpic + mappedGog + val sorted = + merged.sortedByDescending { app -> + val searchKey = + if (app.id >= 2000000000 || app.id < 0) { + app.name + } else { + app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") + } + (allPlaytime["${searchKey}_last_played"] as? Long) ?: 0L + } + + withContext(Dispatchers.Main) { + gogByPseudoId = gogMap + epicByPseudoId = epicMap + mergedInstalledApps = merged + installedApps = sorted + if (sorted.isNotEmpty()) { + stableInstalledApps = sorted + stableGogByPseudoId = gogMap + stableEpicByPseudoId = epicMap + } + libraryLoaded = true + processedScanToken = scanInputToken + pullRefreshing = false + } + } + } + + LaunchedEffect(installedApps, gogByPseudoId, cachedShortcuts, iconRefreshKey) { + val appsSnapshot = installedApps + val gogSnapshot = gogByPseudoId + val shortcutsSnapshot = cachedShortcuts + + val artworkPaths = + withContext(Dispatchers.IO) { + buildMap { + appsSnapshot.forEach { app -> + val gogGame = gogSnapshot[app.id] + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + val shortcut = + if (gogGame != null) { + shortcutsSnapshot.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + } else { + findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) + } + val customPath = + shortcut + ?.getExtra("customLibraryIconPath") + ?.ifBlank { shortcut.getExtra("customCoverArtPath") } + if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { + put(app.id, customPath) + } + } + } + } + + val gridArtworkPaths = + withContext(Dispatchers.IO) { + buildMap { + appsSnapshot.forEach { app -> + val gogGame = gogSnapshot[app.id] + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + val shortcut = + if (gogGame != null) { + shortcutsSnapshot.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + } else { + findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) + } + val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.GRID.extraKey) + if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { + put(app.id, customPath) + } + } + } + } + + val carouselArtworkPaths = + withContext(Dispatchers.IO) { + buildMap { + appsSnapshot.forEach { app -> + val gogGame = gogSnapshot[app.id] + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + val shortcut = + if (gogGame != null) { + shortcutsSnapshot.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + } else { + findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) + } + val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL.extraKey) + if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { + put(app.id, customPath) + } + } + } + } + + val listArtworkPaths = + withContext(Dispatchers.IO) { + buildMap { + appsSnapshot.forEach { app -> + val gogGame = gogSnapshot[app.id] + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + val shortcut = + if (gogGame != null) { + shortcutsSnapshot.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + } else { + findShortcutForGame(shortcutsSnapshot, app, isCustom, isEpic, epicId) + } + val customPath = shortcut?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.LIST.extraKey) + if (!customPath.isNullOrBlank() && java.io.File(customPath).exists()) { + put(app.id, customPath) + } + } + } + } + + val customIconPaths = + withContext(Dispatchers.IO) { + buildMap { + appsSnapshot.forEach { app -> + if (app.id >= 0) return@forEach + val safeName = app.name.replace("/", "_").replace("\\", "_") + val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") + if (iconFile.exists()) { + put(app.id, iconFile.absolutePath) + } + } + } + } + + customArtworkPathByAppId = artworkPaths + customGridArtworkPathByAppId = gridArtworkPaths + customCarouselArtworkPathByAppId = carouselArtworkPaths + customListArtworkPathByAppId = listArtworkPaths + customIconPathByAppId = customIconPaths + if (appsSnapshot.isNotEmpty()) { + stableCustomArtworkPathByAppId = artworkPaths + stableCustomGridArtworkPathByAppId = gridArtworkPaths + stableCustomCarouselArtworkPathByAppId = carouselArtworkPaths + stableCustomListArtworkPathByAppId = listArtworkPaths + stableCustomIconPathByAppId = customIconPaths + } + } + + LaunchedEffect(mergedInstalledApps, playtimeRefreshKey) { + if (mergedInstalledApps.isEmpty()) { + installedApps = emptyList() + return@LaunchedEffect + } + + val sorted = + withContext(Dispatchers.IO) { + val playtimePrefs = context.getSharedPreferences("playtime_stats", android.content.Context.MODE_PRIVATE) + val allPlaytime = playtimePrefs.all + mergedInstalledApps.sortedByDescending { app -> + val searchKey = + if (app.id >= 2000000000 || app.id < 0) { + app.name + } else { + app.name.replace(LIBRARY_NAME_SANITIZE_REGEX, "") + } + (allPlaytime["${searchKey}_last_played"] as? Long) ?: 0L + } + } + + installedApps = sorted + } + + val awaitingShortcutScan = installedApps.isEmpty() && !shortcutsLoaded + val keepPreviousLibraryVisible = + installedApps.isEmpty() && + stableInstalledApps.isNotEmpty() && + (processedScanToken !== scanInputToken || awaitingShortcutScan) + val visibleInstalledApps = if (keepPreviousLibraryVisible) stableInstalledApps else installedApps + val visibleGogByPseudoId = if (keepPreviousLibraryVisible) stableGogByPseudoId else gogByPseudoId + val visibleEpicByPseudoId = if (keepPreviousLibraryVisible) stableEpicByPseudoId else epicByPseudoId + val visibleCustomArtworkPathByAppId = + if (keepPreviousLibraryVisible) stableCustomArtworkPathByAppId else customArtworkPathByAppId + val visibleCustomGridArtworkPathByAppId = + if (keepPreviousLibraryVisible) stableCustomGridArtworkPathByAppId else customGridArtworkPathByAppId + val visibleCustomCarouselArtworkPathByAppId = + if (keepPreviousLibraryVisible) stableCustomCarouselArtworkPathByAppId else customCarouselArtworkPathByAppId + val visibleCustomListArtworkPathByAppId = + if (keepPreviousLibraryVisible) stableCustomListArtworkPathByAppId else customListArtworkPathByAppId + val visibleCustomIconPathByAppId = + if (keepPreviousLibraryVisible) stableCustomIconPathByAppId else customIconPathByAppId + + val displayedApps = + remember(visibleInstalledApps, searchQuery) { + if (searchQuery.isBlank()) { + visibleInstalledApps + } else { + visibleInstalledApps.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + LaunchedEffect( + visibleInstalledApps, + visibleGogByPseudoId, + visibleEpicByPseudoId, + visibleCustomArtworkPathByAppId, + visibleCustomGridArtworkPathByAppId, + visibleCustomCarouselArtworkPathByAppId, + visibleCustomListArtworkPathByAppId, + cachedShortcuts, + ) { + var deletedCustomOverrides = false + val refs = + visibleInstalledApps.flatMap { app -> + val gogGame = visibleGogByPseudoId[app.id] + val epicGame = visibleEpicByPseudoId[app.id] + val overriddenSlots = + customArtworkOverrideSlots( + app = app, + gogGame = gogGame, + epicGame = epicGame, + hasDefaultCustomArt = visibleCustomArtworkPathByAppId[app.id] != null, + hasGridCustomArt = visibleCustomGridArtworkPathByAppId[app.id] != null, + hasCarouselCustomArt = visibleCustomCarouselArtworkPathByAppId[app.id] != null, + hasListCustomArt = visibleCustomListArtworkPathByAppId[app.id] != null, + hasHeroCustomArt = + findLibraryArtworkShortcut(cachedShortcuts, app, gogGame, epicGame) + ?.hasExistingArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD.extraKey) == true, + ) + + if (overriddenSlots.isNotEmpty()) { + val cacheId = artworkCacheId(app, gogGame, epicGame) + if (cacheId != null) { + val deleted = + withContext(Dispatchers.IO) { + StoreArtworkCache.deleteSlots(context, cacheId.store, cacheId.gameId, overriddenSlots) + } + deletedCustomOverrides = deletedCustomOverrides || deleted + } + } + + StoreArtworkCache + .libraryRefs( + app = app, + gogGame = gogGame, + epicGame = epicGame, + ).filterNot { it.slot in overriddenSlots } + } + val cachedAny = + withContext(Dispatchers.IO) { + StoreArtworkCache.cacheAll(context, refs) + } + if (cachedAny || deletedCustomOverrides) artworkCacheRefreshKey++ + } + + // The startup bootstrap screen already masks the first frame. Do not + // force an extra minimum spinner duration here or the library visibly + // bounces through two loading states on launch. + // A logged-in store whose owned-apps list is still empty hasn't finished + // its initial library fetch yet — keep the spinner up instead of flashing + // "No games installed". This resolves itself once the store populates its + // DB (steamApps/epicApps/gogApps become non-empty) or if other sources + // (custom apps, other stores) already have installed games. + val awaitingStoreSync = + installedApps.isEmpty() && ( + (isLoggedIn && steamApps.isEmpty()) || + (epicApps.isEmpty() && EpicService.hasStoredCredentials(context)) || + (gogApps.isEmpty() && GOGAuthManager.isLoggedIn(context)) + ) + // Only block the surface while the first library result is unresolved. + // After that, keep the current content/empty state visible during + // background refreshes so the UI does not flicker back to a spinner. + val initialLibraryLoadPending = !libraryLoaded + val waitingForFirstEmptyStateResolution = + installedApps.isEmpty() && (processedScanToken !== scanInputToken || awaitingStoreSync || awaitingShortcutScan) + val showLoading = initialLibraryLoadPending || waitingForFirstEmptyStateResolution + if (showLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val spinAlpha by animateFloatAsState( + targetValue = 1f, + animationSpec = tween(durationMillis = 600), + label = "loaderFade", + ) + CircularProgressIndicator( + color = Accent, + strokeWidth = 3.dp, + modifier = Modifier.size(48.dp).alpha(spinAlpha), + ) + } + return + } + + if (visibleInstalledApps.isEmpty()) { + val epicLoggedIn by EpicAuthManager.isLoggedInFlow.collectAsState() + val gogLoggedIn by GOGAuthManager.isLoggedInFlow.collectAsState() + val anyLoggedIn = isLoggedIn || epicLoggedIn || gogLoggedIn + val hasAnyCredentials = + anyLoggedIn || + SteamService.hasStoredCredentials(context) || + EpicService.hasStoredCredentials(context) || + GOGAuthManager.isLoggedIn(context) + if (!anyLoggedIn && !hasAnyCredentials) { + LoginRequiredScreen("Library") { + navigateToSettings(SettingsNavItem.STORES) + } + } else if (anyLoggedIn) { + PullToRefreshBox( + isRefreshing = pullRefreshing, + onRefresh = { + pullRefreshing = true + localLibraryRefreshKey++ + }, + modifier = Modifier.fillMaxSize(), + ) { + Box( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center, + ) { + EmptyStateMessage(stringResource(R.string.library_games_no_games_installed)) + } + } + } + return + } + + var selectedAppForSettings by remember { mutableStateOf(null) } + var selectedGogGameForSettings by remember { mutableStateOf(null) } + var detailApp by remember { mutableStateOf(null) } + var detailGogGame by remember { mutableStateOf(null) } + val gridState = rememberLazyGridState() + val carouselState = rememberLazyListState() + val activity = LocalContext.current as? UnifiedActivity + + // Pause chasing borders on library cards while any dialog is open. + LaunchedEffect(selectedAppForSettings, selectedGogGameForSettings, detailApp) { + chasingBordersPaused.value = + selectedAppForSettings != null || selectedGogGameForSettings != null || detailApp != null + } + DisposableEffect(Unit) { + onDispose { chasingBordersPaused.value = false } + } + + LaunchedEffect(layoutMode) { + currentLibraryLayoutMode = layoutMode + } + + // Keep activity's item count in sync + LaunchedEffect(displayedApps.size) { + activity?.libraryItemCount = displayedApps.size + val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) + if (activity != null && displayedApps.isNotEmpty() && activity.libraryFocusIndex.value > lastIndex) { + activity.libraryFocusIndex.value = lastIndex + } + } + + // FocusRequesters for each grid item + val focusRequesters = + remember(displayedApps.size) { + List(displayedApps.size) { FocusRequester() } + } + + // Observe focus index changes from the activity and request focus on the target item + val focusIndex by (activity?.libraryFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() + LaunchedEffect(focusIndex, focusRequesters.size, layoutMode) { + if (searchQuery.isEmpty() && + layoutMode == LibraryLayoutMode.GRID_4 && + focusRequesters.isNotEmpty() && + focusIndex in focusRequesters.indices + ) { + gridState.animateScrollToItem(focusIndex) + try { + focusRequesters[focusIndex].requestFocus() + } catch (_: Exception) { + } + } + } + + // Track selected app for the top-right Game Settings button + LaunchedEffect(focusIndex, displayedApps) { + val app = displayedApps.getOrNull(focusIndex) ?: displayedApps.firstOrNull() + selectedSteamAppId = app?.id ?: 0 + selectedSteamAppName = app?.name ?: "" + val gogGame = app?.let { visibleGogByPseudoId[it.id] } + selectedLibrarySource = + when { + gogGame != null -> "GOG" + app == null -> "" + app.id >= 2000000000 -> "EPIC" + app.id < 0 -> "CUSTOM" + else -> "STEAM" + } + selectedGogGameId = gogGame?.id.orEmpty() + } + + val heroApps = rememberUpdatedState(displayedApps) + val heroFocus = rememberUpdatedState(focusIndex) + val heroGogMap = rememberUpdatedState(visibleGogByPseudoId) + LaunchedEffect(Unit) { + activity?.openHeroForFocusedSignal?.collect { + val list = heroApps.value + val app = list.getOrNull(heroFocus.value) ?: list.firstOrNull() + if (app != null) { + detailGogGame = heroGogMap.value[app.id] + detailApp = app + } + } + } + + // Publish the focused game's hero art (custom card > store hero > grid capsule) for the immersive background; shortcuts load once per refresh signal, not per focus move. + var immersiveShortcuts by remember { mutableStateOf?>(null) } + LaunchedEffect(shortcutRefreshKey, libraryRefreshKey, artworkCacheRefreshKey) { + immersiveShortcuts = + withContext(Dispatchers.IO) { ContainerManager(context).loadShortcuts() } + } + + LaunchedEffect(focusIndex, displayedApps, immersiveShortcuts) { + val shortcuts = immersiveShortcuts ?: return@LaunchedEffect + val app = displayedApps.getOrNull(focusIndex) ?: displayedApps.firstOrNull() + if (app == null) { + activity?.immersiveBackgroundRef?.value = null + return@LaunchedEffect + } + // Debounce so scrubbing the grid doesn't decode every intermediate hero. + delay(200) + val gogGame = visibleGogByPseudoId[app.id] + val epicGame = visibleEpicByPseudoId[app.id] + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val epicId = if (isEpic) app.id - 2000000000 else 0 + + val shortcut = + when { + gogGame != null -> + shortcuts.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + else -> findShortcutForGame(shortcuts, app, isCustom, isEpic, epicId) + } + val customHeroFile = + withContext(Dispatchers.IO) { + shortcut + ?.getExtra(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD.extraKey) + ?.takeIf { it.isNotBlank() } + ?.let { java.io.File(it) } + ?.takeIf { it.isFile } + } + + activity?.immersiveBackgroundRef?.value = + customHeroFile + ?: run { + val ref = + StoreArtworkCache.heroRef(app, gogGame, epicGame) + ?: StoreArtworkCache.primaryRef( + app, + gogGame, + epicGame, + useLibraryCapsule = false, + listMode = false, + ) + StoreArtworkCache.imageModel(context, ref) + } + } + + val openSettingsForApp: (Int, SteamApp) -> Unit = { index, app -> + activity?.libraryFocusIndex?.value = index + selectedSteamAppId = app.id + selectedSteamAppName = app.name + val gogGame = visibleGogByPseudoId[app.id] + selectedLibrarySource = + when { + gogGame != null -> "GOG" + app.id >= 2000000000 -> "EPIC" + app.id < 0 -> "CUSTOM" + else -> "STEAM" + } + selectedGogGameId = gogGame?.id.orEmpty() + + if (gogGame != null) { + selectedGogGameForSettings = gogGame + } else { + selectedAppForSettings = app + } + } + + PullToRefreshBox( + isRefreshing = pullRefreshing, + onRefresh = { + pullRefreshing = true + localLibraryRefreshKey++ + }, + modifier = Modifier.fillMaxSize(), + ) { + when (layoutMode) { + LibraryLayoutMode.GRID_4 -> { + FourByTwoGridView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + gridState = gridState, + contentPadding = TabGridContentPadding, + clipContent = false, + keyOf = { it.id }, + ) { app, index, rowHeight -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = index == focusIndex, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomGridArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { + openSettingsForApp(index, app) + }, + modifier = + Modifier + .height(rowHeight) + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } + } + + LibraryLayoutMode.CAROUSEL -> { + CarouselView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(top = TabCarouselTopPadding, bottom = TabCarouselBottomPadding), + listState = carouselState, + selectedIndex = focusIndex, + onCenteredIndexChanged = { centeredIndex -> + if (activity != null && activity.libraryFocusIndex.value != centeredIndex) { + activity.libraryFocusIndex.value = centeredIndex + } + }, + ) { app, index, isSelected, cardWidth, cardHeight -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = isSelected, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomCarouselArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { openSettingsForApp(index, app) }, + useLibraryCapsule = true, + modifier = + Modifier + .fillMaxSize() + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } + } + + LibraryLayoutMode.LIST -> { + val listViewState = rememberLazyListState() + ListView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + listState = listViewState, + contentPadding = TabListContentPadding, + selectedIndex = focusIndex, + onSelectedIndexChanged = { newIdx -> + activity?.libraryFocusIndex?.value = newIdx + }, + keyOf = { it.id }, + ) { app, index, isSelected -> + GameCapsule( + app = app, + gogGame = visibleGogByPseudoId[app.id], + epicGame = visibleEpicByPseudoId[app.id], + iconRefreshKey = iconRefreshKey, + artworkCacheRefreshKey = artworkCacheRefreshKey, + isFocusedOverride = isSelected, + isControllerActive = isControllerConnected, + customArtworkPath = visibleCustomListArtworkPathByAppId[app.id] ?: visibleCustomArtworkPathByAppId[app.id], + customIconPath = visibleCustomIconPathByAppId[app.id], + onClick = { + detailGogGame = visibleGogByPseudoId[app.id] + detailApp = app + }, + onLongClick = { openSettingsForApp(index, app) }, + listMode = true, + modifier = + Modifier + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) + } + JoystickListScroll( + listState = listViewState, + stickFlow = activity?.rightStickScrollState, + minSpeed = 2.5f, + maxSpeed = 16f, + quadratic = true, + ) + } + } + } + + if (selectedAppForSettings != null) { + GameSettingsDialog( + app = selectedAppForSettings!!, + onDismissRequest = { selectedAppForSettings = null }, + ) + } + if (selectedGogGameForSettings != null) { + GOGGameSettingsDialog( + app = selectedGogGameForSettings!!, + onDismissRequest = { selectedGogGameForSettings = null }, + ) + } + if (detailApp != null) { + LibraryGameDetailDialog( + app = detailApp!!, + gogGame = detailGogGame, + onDismissRequest = { + detailApp = null + detailGogGame = null + }, + ) + } +} diff --git a/app/src/main/app/shell/UnifiedActivityInput.kt b/app/src/main/app/shell/UnifiedActivityInput.kt new file mode 100644 index 000000000..9b08d45ab --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityInput.kt @@ -0,0 +1,449 @@ +package com.winlator.cmod.app.shell + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Gamepad/key nav dispatch + glasses combo + immersive bars, split out of UnifiedActivity.kt (behavior-identical). + +internal fun UnifiedActivity.updateGlassesCombo() { + val l2 = l2KeyDown || l2AxisDown + val r2 = r2KeyDown || r2AxisDown + if (l2 && r2) { + if (glassesComboArmed) { + glassesComboArmed = false + if (com.winlator.cmod.runtime.display.GlassesManager.isConnected()) { + openGlassesSignal.tryEmit(Unit) + } + } + } else { + glassesComboArmed = true + } +} + +internal fun UnifiedActivity.applyImmersiveSystemBars(enabled: Boolean) { + window.navigationBarColor = android.graphics.Color.TRANSPARENT + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } +} + +internal fun UnifiedActivity.moveLibraryFocus( + left: Boolean, + right: Boolean, + up: Boolean, + down: Boolean, +) { + val idx = libraryFocusIndex.value + val count = libraryItemCount + if (count <= 0) return + var newIdx = idx + when (currentLibraryLayoutMode) { + LibraryLayoutMode.GRID_4 -> { + if (left) newIdx = (idx - 1).coerceAtLeast(0) + if (right) newIdx = (idx + 1).coerceAtMost(count - 1) + if (up) newIdx = (idx - 4).coerceAtLeast(0) + if (down) newIdx = (idx + 4).coerceAtMost(count - 1) + } + + LibraryLayoutMode.CAROUSEL -> { + if (left) newIdx = (idx - 1).coerceAtLeast(0) + if (right) newIdx = (idx + 1).coerceAtMost(count - 1) + } + + LibraryLayoutMode.LIST -> { + if (up) newIdx = (idx - 1).coerceAtLeast(0) + if (down) newIdx = (idx + 1).coerceAtMost(count - 1) + } + } + libraryFocusIndex.value = newIdx +} + +internal fun UnifiedActivity.moveStoreFocus( + left: Boolean, + right: Boolean, + up: Boolean, + down: Boolean, +) { + val count = storeItemCount + if (count <= 0) return + val cols = storeColumns + + // Snap to visible content before applying another store-grid move. + var idx = storeFocusIndex.value + val grid = storeGridState + if (grid != null) { + val visibleItems = grid.layoutInfo.visibleItemsInfo + if (visibleItems.isNotEmpty()) { + val firstVisible = visibleItems.first().index + val lastVisible = visibleItems.last().index + if (idx < firstVisible || idx > lastVisible) { + idx = firstVisible + storeFocusIndex.value = idx + return + } + } + } + + var newIdx = idx + if (left) newIdx = (idx - 1).coerceAtLeast(0) + if (right) newIdx = (idx + 1).coerceAtMost(count - 1) + if (up) newIdx = (idx - cols).coerceAtLeast(0) + if (down) newIdx = (idx + cols).coerceAtMost(count - 1) + storeFocusIndex.value = newIdx +} + +internal fun UnifiedActivity.routeDownloadsNav( + left: Boolean, + right: Boolean, + up: Boolean, + down: Boolean, +) { + downloadsNavBridge.controllerActive = true + when { + left -> downloadsNavBridge.left() + right -> downloadsNavBridge.right() + up -> downloadsNavBridge.up() + down -> downloadsNavBridge.down() + } +} + +internal fun UnifiedActivity.gogPseudoId(gameId: String): Int { + val normalized = gameId.hashCode() and 0x1FFFFFFF + return 1_500_000_000 + normalized +} + +internal fun UnifiedActivity.injectKeyEvent(keyCode: Int) { + window.decorView.rootView.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, keyCode)) + window.decorView.rootView.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, keyCode)) +} + +internal fun UnifiedActivity.hideImeIfVisible(): Boolean { + val decor = window.decorView + val insets = androidx.core.view.ViewCompat.getRootWindowInsets(decor) ?: return false + if (!insets.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime())) return false + val target = currentFocus ?: decor + androidx.core.view.WindowInsetsControllerCompat(window, target) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + return true +} + +internal fun UnifiedActivity.applySettingsSidebarNav(keyCode: Int) { + when (keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_UP -> moveSettingsItem(-1) + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> moveSettingsItem(1) + android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> enterSettingsContent() + } +} + +internal fun UnifiedActivity.moveSettingsItem(delta: Int) { + val items = SettingsNavItem.entries + val index = items.indexOf(settingsNavBridge.selectedItem) + val next = index + delta + if (next in items.indices) settingsNavBridge.onSelectItem?.invoke(items[next]) +} + +internal fun UnifiedActivity.enterSettingsContent() { + settingsNavBridge.zone = SettingsFocusZone.CONTENT + settingsNavBridge.contentControllerActive = true +} + +internal fun UnifiedActivity.navigateSettingsContent(code: Int) { + settingsNavBridge.contentControllerActive = true + when (code) { + android.view.KeyEvent.KEYCODE_DPAD_LEFT -> settingsNavBridge.contentNavLeft() + android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> settingsNavBridge.contentNavRight() + android.view.KeyEvent.KEYCODE_DPAD_UP -> settingsNavBridge.contentNavUp() + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> settingsNavBridge.contentNavDown() + } +} + +internal fun UnifiedActivity.findVisibleFragmentContainer(view: android.view.View): android.view.View? { + if (view is androidx.fragment.app.FragmentContainerView && view.isShown && view.childCount > 0) { + return view + } + if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + findVisibleFragmentContainer(view.getChildAt(i))?.let { return it } + } + } + return null +} + +internal fun UnifiedActivity.handleSettingsStick(code: Int) { + if (settingsNavBridge.zone == SettingsFocusZone.SIDEBAR) { + applySettingsSidebarNav(code) + return + } + navigateSettingsContent(code) +} + +internal fun UnifiedActivity.handleGuideButton(action: Int, repeatCount: Int) { + when (action) { + android.view.KeyEvent.ACTION_DOWN -> { + if (repeatCount != 0) return + guideHoldRunnable?.let { guideHandler.removeCallbacks(it) } + guideHoldRunnable = null + if (rightDrawerOpen) { + val r = Runnable { openFriendsSignal.tryEmit(Unit) } + guideHoldRunnable = r + guideHandler.postDelayed(r, 400L) + } else if (!menuNavActive && !drawerOpen) { + openFriendsSignal.tryEmit(Unit) + } + } + + android.view.KeyEvent.ACTION_UP -> { + guideHoldRunnable?.let { guideHandler.removeCallbacks(it) } + guideHoldRunnable = null + } + } +} + +internal fun UnifiedActivity.reapplyPreferredRefreshRate() { + if (isFinishing || isDestroyed) return + RefreshRateUtils.applyPreferredRefreshRate(this) +} diff --git a/app/src/main/app/shell/UnifiedActivityLaunch.kt b/app/src/main/app/shell/UnifiedActivityLaunch.kt new file mode 100644 index 000000000..d7691b39b --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityLaunch.kt @@ -0,0 +1,1020 @@ +package com.winlator.cmod.app.shell + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Game launching (Steam/Epic/GOG/custom) + wine command builders, split out of UnifiedActivity.kt (behavior-identical). + +// Game launch with drive-aware mapping +internal fun UnifiedActivity.launchSteamGame( + context: android.content.Context, + containerManager: ContainerManager, + app: SteamApp, + joinConnect: String? = null, +) { + lifecycleScope.launch(Dispatchers.IO) { + val gameInstallPath = SteamService.getAppDirPath(app.id) + val gameDir = java.io.File(gameInstallPath) + if (!gameDir.exists()) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Game not installed: ${app.name}", + android.widget.Toast.LENGTH_SHORT, + ) + } + return@launch + } + + val shortcut = + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == app.id.toString() + } + val detectedLaunchExecutable = SteamService.getInstalledExe(app.id) + + if (shortcut != null) { + if (!SetupWizardActivity.isContainerUsable(context, shortcut.container)) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer( + context, + shortcut.container.wineVersion, + ) + } + return@launch + } + normalizeContainerDrives(shortcut.container) + shortcut.putExtra("game_source", "STEAM") + shortcut.putExtra("game_install_path", gameInstallPath) + val existingLaunchExecutable = shortcut.getExtra("launch_exe_path") + if (existingLaunchExecutable.isNullOrBlank() && detectedLaunchExecutable.isNotBlank()) { + shortcut.putExtra("launch_exe_path", detectedLaunchExecutable) + } + val loaderExec = "wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" + val lines = + com.winlator.cmod.shared.io.FileUtils + .readLines(shortcut.file) + val rewritten = StringBuilder() + var execUpdated = false + for (line in lines) { + if (line.startsWith("Exec=")) { + rewritten.append("Exec=").append(loaderExec).append("\n") + execUpdated = true + } else { + rewritten.append(line).append("\n") + } + } + if (!execUpdated) { + rewritten.append("Exec=").append(loaderExec).append("\n") + } + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcut.file, rewritten.toString()) + shortcut.saveData() + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", shortcut.container.id) + intent.putExtra("shortcut_path", shortcut.file.path) + intent.putExtra("shortcut_name", shortcut.name) + if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } else { + val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) + + if (container == null) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer(context) + } + return@launch + } + + normalizeContainerDrives(container) + + val execPath = "wine \"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" + + // Generate a shortcut dynamically + val desktopDir = container.getDesktopDir() + if (!desktopDir.exists()) desktopDir.mkdirs() + val shortcutFile = java.io.File(desktopDir, "${app.name.replace("/", "_")}.desktop") + val content = java.lang.StringBuilder() + content.append("[Desktop Entry]\n") + content.append("Type=Application\n") + content.append("Name=${app.name}\n") + content.append("Exec=$execPath\n") + content.append("Icon=steam_icon_${app.id}\n") + content.append("\n[Extra Data]\n") + content.append("game_source=STEAM\n") + content.append("app_id=${app.id}\n") + content.append("container_id=${container.id}\n") + content.append("game_install_path=${gameInstallPath}\n") + content.append("launch_exe_path=${detectedLaunchExecutable}\n") + content.append("use_container_defaults=1\n") + + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcutFile, content.toString()) + + container.saveData() + + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", container.id) + intent.putExtra("shortcut_path", shortcutFile.path) + intent.putExtra("shortcut_name", app.name) + if (!joinConnect.isNullOrBlank()) intent.putExtra("steam_join_connect", joinConnect) + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } + } +} + +internal fun UnifiedActivity.launchEpicGame( + context: android.content.Context, + containerManager: ContainerManager, + app: EpicGame, +) { + lifecycleScope.launch(Dispatchers.IO) { + val gameInstallPath = app.installPath.takeIf { it.isNotEmpty() } ?: EpicConstants.getGameInstallPath(context, app.appName) + val gameDir = java.io.File(gameInstallPath) + if (!gameDir.exists()) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Game not installed: ${app.title}", + android.widget.Toast.LENGTH_SHORT, + ) + } + return@launch + } + + // Try to find an existing shortcut first (preserves per-game settings) + val existingShortcut = + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == app.id.toString() + } + + if (existingShortcut != null) { + val launchContainer = + resolveShortcutLaunchContainer(containerManager, existingShortcut) + ?: existingShortcut.container + if (!SetupWizardActivity.isContainerUsable(context, launchContainer)) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer( + context, + launchContainer.wineVersion, + ) + } + return@launch + } + // Existing shortcut found: preserve per-game settings and update the mapped install path + val shortcut = existingShortcut + val epicDisplayName = + app.title.takeIf { it.isNotBlank() } + ?: shortcut.name.takeIf { it.isNotBlank() } + ?: app.appName + // Ensure game_install_path is always up-to-date + shortcut.putExtra("game_install_path", gameInstallPath) + shortcut.putExtra("container_id", launchContainer.id.toString()) + repairShortcutDisplayNameIfNeeded(shortcut, epicDisplayName, app.appName, app.id.toString()) + normalizeContainerDrives(launchContainer) + + // Repair broken Exec line if the executable is missing or still points at a legacy placeholder mapping. + val currentPath = shortcut.path + if (currentPath == null || currentPath == "D:\\" || currentPath == "D:\\\\" || + currentPath == "A:\\" || currentPath == "A:\\\\" || + currentPath.startsWith("A:\\") + ) { + val newExecCmd = + buildStoreWineExecCommandForSelectedExe( + launchContainer, + "EPIC", + gameInstallPath, + shortcut.getExtra("launch_exe_path"), + ) ?: run { + val exePath = EpicService.getInstalledExe(app.id) + if (exePath.isNotEmpty()) { + shortcut.putExtra("launch_exe_path", exePath) + buildStoreWineExecCommand( + launchContainer, + "EPIC", + gameInstallPath, + java.io.File(gameInstallPath, exePath.replace("\\", "/")), + ) + } else { + val exeFile = findGameExe(gameDir) + if (exeFile != null) { + shortcut.putExtra("launch_exe_path", exeFile.absolutePath) + buildStoreWineExecCommand(launchContainer, "EPIC", gameInstallPath, exeFile) + } else { + null + } + } + } + if (newExecCmd != null) { + // Rewrite the Exec line in the .desktop file while preserving all other content + val lines = + com.winlator.cmod.shared.io.FileUtils + .readLines(shortcut.file) + val sb = StringBuilder() + for (line in lines) { + if (line.startsWith("Exec=")) { + sb.append("Exec=$newExecCmd\n") + } else { + sb.append(line).append("\n") + } + } + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcut.file, sb.toString()) + } + } + + shortcut.saveData() + val launchShortcutFile = ensureShortcutFileInContainer(shortcut, launchContainer) + + // Provision the EOS overlay into this container. Best-effort — failures are + // non-fatal (games without the EOS SDK ignore it; games with the SDK still run + // without the in-game HUD). Tokens must be staged inside the prefix because + // the dosdevices map doesn't expose the app cache dir on any drive letter. + runCatching { + EpicService.installOverlay(context, launchContainer) + }.onFailure { + Log.w("EPIC", "EOS overlay install failed for ${app.appName}; launching anyway", it) + } + + val launchArgsResult = + EpicGameLauncher.buildLaunchParameters( + context = context, + game = app, + container = launchContainer, + ) + launchArgsResult.exceptionOrNull()?.let { err -> + // The launch can still proceed (offline-tolerant titles, single-player non-DRM + // games), so we don't abort — but surface the failure prominently so users + // know why a DRM/online title may bounce to its login screen. + Log.e("EPIC", "Failed to build Epic launch parameters for ${app.appName}: ${err.message}", err) + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Could not refresh Epic launch token: ${err.message ?: "unknown error"}", + android.widget.Toast.LENGTH_LONG, + ) + } + } + val args = launchArgsResult.getOrNull()?.joinToString(" ") ?: "" + + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", launchContainer.id) + intent.putExtra("shortcut_path", launchShortcutFile.path) + intent.putExtra("shortcut_name", epicDisplayName) + intent.putExtra("extra_exec_args", args) // Pass fresh tokens + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } else { + // No existing shortcut — create a new one + val exePath = EpicService.getInstalledExe(app.id) + val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) + + if (container == null) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer(context) + } + return@launch + } + + normalizeContainerDrives(container) + val execCmd = + if (exePath.isNotEmpty()) { + buildStoreWineExecCommand( + container, + "EPIC", + gameInstallPath, + java.io.File(gameInstallPath, exePath.replace("\\", "/")), + ) + } else { + val exeFile = findGameExe(gameDir) + if (exeFile != null) { + buildStoreWineExecCommand(container, "EPIC", gameInstallPath, exeFile) + } else { + "wine \"explorer.exe\"" + } + } + + val desktopDir = container.getDesktopDir() + if (!desktopDir.exists()) desktopDir.mkdirs() + val shortcutFile = java.io.File(desktopDir, "${app.appName}.desktop") + val content = java.lang.StringBuilder() + content.append("[Desktop Entry]\n") + content.append("Type=Application\n") + content.append("Name=${app.title}\n") + content.append("Exec=$execCmd\n") + content.append("Icon=epic_icon_${app.id}\n") + content.append("\n[Extra Data]\n") + content.append("game_source=EPIC\n") + content.append("app_id=${app.id}\n") + if (app.catalogId.isNotEmpty()) { + // Persist catalog_id so EpicGameFixHelper / GameFixes can dispatch the + // per-catalog registry/env/folder fixes without a DB round-trip on launch. + content.append("catalog_id=${app.catalogId}\n") + } + content.append("container_id=${container.id}\n") + content.append("game_install_path=${gameInstallPath}\n") + if (exePath.isNotEmpty()) { + content.append("launch_exe_path=${exePath}\n") + } + content.append("use_container_defaults=1\n") + + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcutFile, content.toString()) + + container.saveData() + + // Best-effort EOS overlay provisioning — see existing-shortcut branch above. + runCatching { + EpicService.installOverlay(context, container) + }.onFailure { + Log.w("EPIC", "EOS overlay install failed for ${app.appName}; launching anyway", it) + } + + val launchArgsResult = + EpicGameLauncher.buildLaunchParameters( + context = context, + game = app, + container = container, + ) + launchArgsResult.exceptionOrNull()?.let { err -> + Log.e("EPIC", "Failed to build Epic launch parameters for ${app.appName}: ${err.message}", err) + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Could not refresh Epic launch token: ${err.message ?: "unknown error"}", + android.widget.Toast.LENGTH_LONG, + ) + } + } + val args = launchArgsResult.getOrNull()?.joinToString(" ") ?: "" + + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", container.id) + intent.putExtra("shortcut_path", shortcutFile.path) + intent.putExtra("shortcut_name", app.title) + intent.putExtra("extra_exec_args", args) // Pass fresh tokens + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } + } +} + +internal fun UnifiedActivity.launchGogGame( + context: android.content.Context, + containerManager: ContainerManager, + app: GOGGame, +) { + lifecycleScope.launch(Dispatchers.IO) { + val gameInstallPath = app.installPath.takeIf { it.isNotEmpty() } ?: GOGConstants.getGameInstallPath(app.title) + val gameDir = java.io.File(gameInstallPath) + if (!gameDir.exists()) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Game not installed: ${app.title}", + android.widget.Toast.LENGTH_SHORT, + ) + } + return@launch + } + + val existingShortcut = + containerManager.loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id + } + + val gogAppId = "GOG_${app.id}" + GOGService.syncCloudSaves(context, gogAppId) + + if (existingShortcut != null) { + val shortcut = existingShortcut + if (!SetupWizardActivity.isContainerUsable(context, shortcut.container)) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer( + context, + shortcut.container.wineVersion, + ) + } + return@launch + } + shortcut.putExtra("game_install_path", gameInstallPath) + normalizeContainerDrives(shortcut.container) + + // Repair broken Exec line if the executable is missing or still points at a legacy placeholder mapping. + val currentPath = shortcut.path + if (currentPath == null || currentPath == "D:\\" || currentPath == "D:\\\\" || + currentPath == "A:\\" || currentPath == "A:\\\\" || + currentPath.startsWith("A:\\") + ) { + val newExecCmd = + buildStoreWineExecCommandForSelectedExe( + shortcut.container, + "GOG", + gameInstallPath, + shortcut.getExtra("launch_exe_path"), + ) ?: run { + val libraryItem = + LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG) + val exePath = GOGService.getInstalledExe(libraryItem) + if (exePath.isNotEmpty()) { + shortcut.putExtra("launch_exe_path", exePath) + buildStoreWineExecCommand( + shortcut.container, + "GOG", + gameInstallPath, + java.io.File(gameInstallPath, exePath.replace("\\", "/")), + ) + } else { + val exeFile = findGameExe(gameDir) + if (exeFile != null) { + shortcut.putExtra("launch_exe_path", exeFile.absolutePath) + buildStoreWineExecCommand(shortcut.container, "GOG", gameInstallPath, exeFile) + } else { + null + } + } + } + if (newExecCmd != null) { + val lines = + com.winlator.cmod.shared.io.FileUtils + .readLines(shortcut.file) + val sb = StringBuilder() + for (line in lines) { + if (line.startsWith("Exec=")) { + sb.append("Exec=$newExecCmd\n") + } else { + sb.append(line).append("\n") + } + } + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcut.file, sb.toString()) + } + } + + shortcut.saveData() + + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", shortcut.container.id) + intent.putExtra("shortcut_path", shortcut.file.path) + intent.putExtra("shortcut_name", shortcut.name) + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + return@launch + } + + val libraryItem = LibraryItem("GOG_${app.id}", app.title, com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG) + val exePath = GOGService.getInstalledExe(libraryItem) + + val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) + + if (container == null) { + withContext(Dispatchers.Main) { + SetupWizardActivity.promptToInstallWineOrCreateContainer(context) + } + return@launch + } + + normalizeContainerDrives(container) + val execCmd = + if (exePath.isNotEmpty()) { + buildStoreWineExecCommand( + container, + "GOG", + gameInstallPath, + java.io.File(gameInstallPath, exePath.replace("\\", "/")), + ) + } else { + val exeFile = findGameExe(gameDir) + if (exeFile != null) { + buildStoreWineExecCommand(container, "GOG", gameInstallPath, exeFile) + } else { + "wine \"explorer.exe\"" + } + } + + val desktopDir = container.getDesktopDir() + if (!desktopDir.exists()) desktopDir.mkdirs() + val shortcutFile = java.io.File(desktopDir, "${app.title.replace("/", "_")}.desktop") + val content = java.lang.StringBuilder() + content.append("[Desktop Entry]\n") + content.append("Type=Application\n") + content.append("Name=${app.title}\n") + content.append("Exec=$execCmd\n") + content.append("Icon=gog_icon_${app.id}\n") + content.append("\n[Extra Data]\n") + content.append("game_source=GOG\n") + content.append("gog_id=${app.id}\n") + content.append("app_id=${gogPseudoId(app.id)}\n") + content.append("container_id=${container.id}\n") + content.append("game_install_path=${gameInstallPath}\n") + if (exePath.isNotEmpty()) { + content.append("launch_exe_path=${exePath}\n") + } + content.append("use_container_defaults=1\n") + + com.winlator.cmod.shared.io.FileUtils + .writeString(shortcutFile, content.toString()) + container.saveData() + + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", container.id) + intent.putExtra("shortcut_path", shortcutFile.path) + intent.putExtra("shortcut_name", app.title) + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } +} + +internal fun UnifiedActivity.normalizeContainerDrives(container: com.winlator.cmod.runtime.container.Container) { + container.drives = + com.winlator.cmod.runtime.wine.WineUtils.normalizePersistentDrives( + this, + container.drives ?: com.winlator.cmod.runtime.container.Container.DEFAULT_DRIVES, + false, + ) +} + +internal fun UnifiedActivity.resolveShortcutLaunchContainer( + containerManager: ContainerManager, + shortcut: Shortcut, +): com.winlator.cmod.runtime.container.Container? { + val overrideContainerId = shortcut.getExtra("container_id").toIntOrNull()?.takeIf { it > 0 } + return overrideContainerId + ?.let { containerManager.getContainerById(it) } + ?: shortcut.container +} + +internal fun UnifiedActivity.ensureShortcutFileInContainer( + shortcut: Shortcut, + targetContainer: com.winlator.cmod.runtime.container.Container, +): java.io.File { + val targetDesktopDir = targetContainer.getDesktopDir() + val alreadyInTarget = + runCatching { + shortcut.file.parentFile?.canonicalFile == targetDesktopDir.canonicalFile + }.getOrDefault(false) + + if (alreadyInTarget) return shortcut.file + + if (!targetDesktopDir.exists()) targetDesktopDir.mkdirs() + shortcut.putExtra("container_id", targetContainer.id.toString()) + shortcut.saveData() + + val targetFile = java.io.File(targetDesktopDir, shortcut.file.name) + runCatching { + com.winlator.cmod.shared.io.FileUtils.copy(shortcut.file, targetFile) + val lnkFileName = shortcut.file.name.substringBeforeLast(".desktop") + ".lnk" + val oldLnkFile = java.io.File(shortcut.file.parentFile, lnkFileName) + if (oldLnkFile.exists()) { + com.winlator.cmod.shared.io.FileUtils.copy(oldLnkFile, java.io.File(targetDesktopDir, lnkFileName)) + oldLnkFile.delete() + } + shortcut.file.delete() + }.onFailure { + Log.w("EPIC", "Failed to move Epic shortcut ${shortcut.file.name} to container ${targetContainer.id}; launching original file", it) + return shortcut.file + } + + return targetFile +} + +internal fun UnifiedActivity.buildStoreWineExecCommand( + container: com.winlator.cmod.runtime.container.Container?, + source: String, + gameInstallPath: String, + exeFile: java.io.File, +): String { + val windowsPath = + container?.let { + com.winlator.cmod.runtime.wine.WineUtils.getDriveCGameWindowsPath( + it, + source, + gameInstallPath, + exeFile.absolutePath, + ) + } ?: run { + val relativePath = + try { + exeFile.relativeTo(java.io.File(gameInstallPath)).path.replace("/", "\\") + } catch (_: Exception) { + exeFile.name + } + val linkName = + com.winlator.cmod.runtime.wine.WineUtils.getDriveCGameLinkName(gameInstallPath) + "C:\\WinNative\\Games\\$source\\$linkName\\$relativePath" + } + return "wine \"$windowsPath\"" +} + +internal fun UnifiedActivity.buildStoreWineExecCommandForSelectedExe( + container: com.winlator.cmod.runtime.container.Container?, + source: String, + gameInstallPath: String, + selectedExePath: String?, +): String? { + if (selectedExePath.isNullOrBlank()) return null + + val selectedExe = java.io.File(selectedExePath) + if (!selectedExe.isFile) return null + + val normalizedBaseDir = + java.io + .File(gameInstallPath) + .absolutePath + .removeSuffix("/") + val normalizedExePath = selectedExe.absolutePath + return if (normalizedExePath == normalizedBaseDir || normalizedExePath.startsWith("$normalizedBaseDir/")) { + buildStoreWineExecCommand(container, source, gameInstallPath, selectedExe) + } else { + val hostPath = normalizedExePath.replace("/", "\\\\").let { if (it.startsWith("\\")) it else "\\$it" } + "wine \"Z:${hostPath}\"" + } +} + +// Launch custom game by shortcut name +internal fun UnifiedActivity.launchCustomGame( + context: android.content.Context, + containerManager: ContainerManager, + gameName: String, +) { + lifecycleScope.launch(Dispatchers.IO) { + val allShortcuts = containerManager.loadShortcuts() + + // Try matching by app_id (for non-official Steam/Epic), custom_name, or filename + var shortcut = + allShortcuts.find { it.getExtra("app_id") == gameName } + ?: allShortcuts.find { it.getExtra("custom_name") == gameName } + ?: allShortcuts.find { it.name == gameName } + ?: allShortcuts.find { it.name == gameName.replace("/", "_").replace("\\", "_") } + + // If still not found, try matching by looking at the safe filename directly + if (shortcut == null) { + val safeName = gameName.replace("/", "_").replace("\\", "_") + for (container in containerManager.containers) { + val desktopFile = java.io.File(container.getDesktopDir(), "$safeName.desktop") + if (desktopFile.exists()) { + shortcut = + com.winlator.cmod.runtime.container + .Shortcut(container, desktopFile) + break + } + } + } + + if (shortcut == null) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Custom game shortcut not found: $gameName", + android.widget.Toast.LENGTH_SHORT, + ) + } + return@launch + } + + // Backfill custom_name if missing (legacy shortcuts) + if (shortcut.getExtra("custom_name").isEmpty()) { + shortcut.putExtra("custom_name", gameName) + shortcut.saveData() + } + + // Refresh storage-root mappings; custom game paths launch through the drive_c game symlink. + val gameFolder = shortcut.getExtra("custom_game_folder", "") + if (gameFolder.isNotEmpty()) { + normalizeContainerDrives(shortcut.container) + shortcut.container.saveData() + } + val intent = Intent(context, XServerDisplayActivity::class.java) + intent.putExtra("container_id", shortcut.container.id) + intent.putExtra("shortcut_path", shortcut.file.path) + intent.putExtra("shortcut_name", gameName) + withContext(Dispatchers.Main) { + launchGame(context, intent) + } + } +} + +internal fun UnifiedActivity.launchGame( + context: android.content.Context, + intent: Intent, +) { + DownloadService.clearCompletedDownloads() + context.startActivity(intent) + // Suppress the default activity transition so the preloader stays seamless + if (context is android.app.Activity) { + com.winlator.cmod.shared.android.AppUtils + .applyOpenActivityTransition(context, 0, 0) + } +} + +internal fun UnifiedActivity.findGameExe(dir: java.io.File): java.io.File? { + // BFS: check each directory level fully before going deeper + val exclusions = + listOf( + "unins", + "redist", + "setup", + "dotnet", + "vcredist", + "dxsetup", + "helper", + "crash", + "ue4prereq", + "dxwebsetup", + "launcher", + ) + + var currentDirs = listOf(dir) + var depth = 0 + var fallbackExe: java.io.File? = null + + while (currentDirs.isNotEmpty() && depth <= 4) { + val nextDirs = mutableListOf() + val candidates = mutableListOf() + + for (d in currentDirs) { + val children = d.listFiles() ?: continue + for (f in children) { + if (f.isDirectory) { + nextDirs.add(f) + } else if (f.extension.equals("exe", ignoreCase = true)) { + val name = f.name.lowercase() + if (exclusions.none { name.contains(it) }) { + candidates.add(f) + } + } + } + } + + // Prefer 64-bit executable candidates at the current depth + val exe64 = + candidates.find { + it.name.lowercase().contains("64") || + it.parentFile + ?.name + ?.lowercase() + ?.contains("64") == true + } + if (exe64 != null) return exe64 + + // Collect the first valid candidate as a fallback + if (fallbackExe == null && candidates.isNotEmpty()) { + fallbackExe = candidates.first() + } + + currentDirs = nextDirs + depth++ + } + return fallbackExe +} diff --git a/app/src/main/app/shell/UnifiedActivityShortcuts.kt b/app/src/main/app/shell/UnifiedActivityShortcuts.kt new file mode 100644 index 000000000..c74eba777 --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityShortcuts.kt @@ -0,0 +1,642 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.ArtworkCacheId + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Shortcut lookup + artwork resolution + home-screen pinning, split out of UnifiedActivity.kt (behavior-identical). + +internal fun UnifiedActivity.findLibraryShortcutForGame( + containerManager: ContainerManager, + app: SteamApp, + isCustom: Boolean, + isEpic: Boolean, + epicId: Int, +): Shortcut? = findShortcutForGame(containerManager.loadShortcuts(), app, isCustom, isEpic, epicId) + + +internal fun UnifiedActivity.findShortcutForGame( + shortcuts: List, + app: SteamApp, + isCustom: Boolean, + isEpic: Boolean, + epicId: Int, +): Shortcut? = + when { + isEpic -> { + shortcuts.find { + it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == epicId.toString() + } + } + + else -> { + shortcuts.find { + it.getExtra("app_id") == app.id.toString() || it.getExtra("custom_name") == app.name || it.name == app.name + } + } + } + + +internal fun UnifiedActivity.findLibraryArtworkShortcut( + shortcuts: List, + app: SteamApp, + gogGame: GOGGame?, + epicGame: EpicGame?, +): Shortcut? = + when { + gogGame != null -> { + shortcuts.find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == gogGame.id + } + } + + epicGame != null -> { + shortcuts.find { + it.getExtra("game_source") == "EPIC" && it.getExtra("app_id") == epicGame.id.toString() + } + } + + else -> { + findShortcutForGame( + shortcuts = shortcuts, + app = app, + isCustom = app.id < 0, + isEpic = app.id >= 2000000000, + epicId = if (app.id >= 2000000000) app.id - 2000000000 else 0, + ) + } + } + + +internal fun UnifiedActivity.artworkCacheId( + app: SteamApp, + gogGame: GOGGame?, + epicGame: EpicGame?, +): ArtworkCacheId? = + when { + gogGame != null -> ArtworkCacheId("gog", gogGame.id) + epicGame != null -> ArtworkCacheId("epic", epicGame.id.toString()) + app.id >= 0 -> ArtworkCacheId("steam", app.id.toString()) + else -> null + } + + +internal fun UnifiedActivity.customArtworkOverrideSlots( + app: SteamApp, + gogGame: GOGGame?, + epicGame: EpicGame?, + hasDefaultCustomArt: Boolean, + hasGridCustomArt: Boolean, + hasCarouselCustomArt: Boolean, + hasListCustomArt: Boolean, + hasHeroCustomArt: Boolean, +): Set { + val overridesPrimary = hasDefaultCustomArt || hasGridCustomArt || hasCarouselCustomArt || hasListCustomArt + if (!overridesPrimary && !hasHeroCustomArt) return emptySet() + + return when { + gogGame != null -> { + buildSet { + if (overridesPrimary) { + add("cover") + add("icon") + } + if (hasHeroCustomArt) add("hero") + } + } + + epicGame != null -> { + buildSet { + if (overridesPrimary) { + add("cover") + add("square") + add("logo") + } + if (hasHeroCustomArt) add("hero") + } + } + + app.id >= 0 -> { + buildSet { + if (hasDefaultCustomArt || hasGridCustomArt) add("capsule") + if (hasDefaultCustomArt || hasCarouselCustomArt) add("library_capsule") + if (hasDefaultCustomArt || hasListCustomArt) add("small_capsule") + if (hasHeroCustomArt) add("hero") + } + } + + else -> emptySet() + } +} + +internal fun UnifiedActivity.isShortcutCloudSyncEnabled(shortcut: Shortcut?): Boolean = + shortcut == null || shortcut.getExtra("cloud_sync_disabled", "0") != "1" + + +internal fun UnifiedActivity.setShortcutCloudSyncEnabled( + shortcut: Shortcut?, + enabled: Boolean, +) { + if (shortcut == null) return + shortcut.putExtra("cloud_sync_disabled", if (enabled) null else "1") + if (enabled) { + shortcut.putExtra("cloud_force_download", null) + } + shortcut.saveData() +} + +internal fun UnifiedActivity.isShortcutOfflineMode(shortcut: Shortcut?): Boolean = + shortcut != null && shortcut.getExtra("offline_mode", "0") == "1" + + +internal fun UnifiedActivity.setShortcutOfflineMode( + shortcut: Shortcut?, + enabled: Boolean, +) { + if (shortcut == null) return + shortcut.putExtra("offline_mode", if (enabled) "1" else null) + shortcut.saveData() +} + +internal fun UnifiedActivity.repairShortcutDisplayNameIfNeeded( + shortcut: Shortcut, + displayName: String, + vararg technicalNames: String, +) { + if (displayName.isBlank() || !shortcut.file.isFile) return + + runCatching { + val technicalNameSet = (technicalNames.toList() + shortcut.file.nameWithoutExtension) + .filter { it.isNotBlank() } + .toSet() + val lines = com.winlator.cmod.shared.io.FileUtils.readLines(shortcut.file) + val sb = StringBuilder() + var changed = false + var sawName = false + + for (line in lines) { + if (line.startsWith("Name=")) { + sawName = true + val currentName = line.removePrefix("Name=").trim() + if (currentName.isBlank() || currentName in technicalNameSet) { + sb.append("Name=").append(displayName).append('\n') + changed = true + } else { + sb.append(line).append('\n') + } + } else { + sb.append(line).append('\n') + } + } + + if (!sawName) { + val desktopHeader = "[Desktop Entry]\n" + val insertIndex = + if (sb.startsWith(desktopHeader)) { + desktopHeader.length + } else { + 0 + } + sb.insert(insertIndex, "Name=$displayName\n") + changed = true + } + + if (changed) { + com.winlator.cmod.shared.io.FileUtils.writeString(shortcut.file, sb.toString()) + } + }.onFailure { + Log.w("SHORTCUTS", "Failed to repair shortcut display name for ${shortcut.file.name}", it) + } +} + +internal fun UnifiedActivity.resolveLibraryShortcutArtworkModel( + context: android.content.Context, + app: SteamApp, + isCustom: Boolean, + isEpic: Boolean, + epicArtworkUrl: String?, +): Any? = + when { + isCustom -> { + val safeName = app.name.replace("/", "_").replace("\\", "_") + val iconFile = java.io.File(context.filesDir, "custom_icons/$safeName.png") + if (iconFile.exists()) iconFile else null + } + + isEpic -> { + epicArtworkUrl?.takeIf { it.isNotBlank() } + } + + else -> { + app.getCapsuleUrl() + } + } + + +internal suspend fun UnifiedActivity.loadArtworkBitmap( + context: android.content.Context, + artworkModel: Any?, +): Bitmap? { + if (artworkModel == null) return null + return try { + val request = + ImageRequest + .Builder(context) + .data(artworkModel) + .allowHardware(false) + .size(192, 192) + .build() + val result = context.imageLoader.execute(request) + val drawable = result.drawable ?: return null + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 192 + val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 192 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + bitmap + } + } catch (_: Exception) { + null + } +} + +internal suspend fun UnifiedActivity.requestPinnedHomeShortcut( + context: android.content.Context, + shortcut: Shortcut, + artworkModel: Any? = null, +): Boolean { + if (shortcut.getExtra("uuid").isEmpty()) { + shortcut.genUUID() + } + val shortcutId = shortcut.getExtra("uuid") + if (shortcutId.isEmpty()) return false + val canonicalShortcutPath = shortcut.file.absolutePath + val shortcutPathHash = canonicalShortcutPath.hashCode() + val containerIdForLaunch = shortcut.getExtra("container_id").toIntOrNull() ?: shortcut.container.id + val pinShortcutId = "shortcut_${shortcut.container.id}_${shortcutId}_${shortcutPathHash.toUInt().toString(16)}" + + val shortcutManager = context.getSystemService(android.content.pm.ShortcutManager::class.java) ?: return false + if (!shortcutManager.isRequestPinShortcutSupported) return false + + val launchIntent = + Intent(context, XServerDisplayActivity::class.java).apply { + val launchData = + Uri + .Builder() + .scheme("winnative") + .authority(BuildConfig.APPLICATION_ID) + .appendPath("shortcut") + .appendQueryParameter("uuid", shortcutId) + .appendQueryParameter("container", containerIdForLaunch.toString()) + .appendQueryParameter("hash", shortcutPathHash.toString()) + .build() + action = Intent.ACTION_VIEW + data = launchData + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra("container_id", containerIdForLaunch) + putExtra("shortcut_path", canonicalShortcutPath) + putExtra("shortcut_name", shortcut.name) + putExtra("shortcut_uuid", shortcutId) + putExtra("shortcut_path_hash", shortcutPathHash) + putExtra(XServerDisplayActivity.EXTRA_LAUNCHED_FROM_PINNED_SHORTCUT, true) + } + + val customIconPath = + shortcut + .getExtra("customLibraryIconPath") + .ifBlank { shortcut.getExtra("customCoverArtPath") } + val customArtworkModel = + customIconPath + .takeIf { it.isNotBlank() } + ?.let { java.io.File(it) } + ?.takeIf { it.exists() } + + val artworkBitmap = loadArtworkBitmap(context, customArtworkModel) ?: loadArtworkBitmap(context, artworkModel) + val shortcutIcon = + artworkBitmap?.let { + android.graphics.drawable.Icon + .createWithBitmap(it) + } + ?: shortcut.icon?.let { + android.graphics.drawable.Icon + .createWithBitmap(it) + } + ?: android.graphics.drawable.Icon + .createWithResource(context, R.drawable.icon_shortcut) + + val pinShortcutInfo = + android.content.pm.ShortcutInfo + .Builder(context, pinShortcutId) + .setShortLabel(shortcut.name) + .setLongLabel(shortcut.name) + .setIcon(shortcutIcon) + .setIntent(launchIntent) + .build() + + val callbackIntent = + Intent(context, ShortcutBroadcastReceiver::class.java).apply { + action = ShortcutBroadcastReceiver.ACTION_PIN_SHORTCUT_RESULT + putExtra("shortcut_path", canonicalShortcutPath) + putExtra("shortcut_name", shortcut.name) + } + val callbackFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val callback = + PendingIntent.getBroadcast( + context, + pinShortcutId.hashCode(), + callbackIntent, + callbackFlags, + ) + + val result = + ShortcutsFragment.pinOrUpdateShortcut( + shortcutManager, + pinShortcutInfo, + ShortcutsFragment.buildPinnedShortcutIds(containerIdForLaunch, shortcutId, canonicalShortcutPath), + callback.intentSender, + ) + if (result == ShortcutsFragment.PinShortcutResult.REUSED_EXISTING) { + val toastIcon = artworkBitmap ?: shortcut.icon + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + R.string.shortcuts_list_readded_existing, + toastIcon, + ) + } + return result != ShortcutsFragment.PinShortcutResult.FAILED +} + +internal suspend fun UnifiedActivity.addLibraryShortcutToHomeScreen( + context: android.content.Context, + app: SteamApp, + isCustom: Boolean, + isEpic: Boolean, + epicId: Int, + epicArtworkUrl: String? = null, +): Boolean { + val containerManager = ContainerManager(context) + val shortcut = findLibraryShortcutForGame(containerManager, app, isCustom, isEpic, epicId) ?: return false + val artworkModel = resolveLibraryShortcutArtworkModel(context, app, isCustom, isEpic, epicArtworkUrl) + return requestPinnedHomeShortcut(context, shortcut, artworkModel) +} + +internal suspend fun UnifiedActivity.addGogShortcutToHomeScreen( + context: android.content.Context, + app: GOGGame, + artworkUrl: String?, +): Boolean { + val shortcut = + ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "GOG" && it.getExtra("gog_id") == app.id + } ?: return false + val artworkModel = artworkUrl?.takeIf { it.isNotBlank() } + return requestPinnedHomeShortcut(context, shortcut, artworkModel) +} diff --git a/app/src/main/app/shell/UnifiedActivityStartup.kt b/app/src/main/app/shell/UnifiedActivityStartup.kt new file mode 100644 index 000000000..0c587030d --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityStartup.kt @@ -0,0 +1,571 @@ +package com.winlator.cmod.app.shell +import com.winlator.cmod.app.shell.UnifiedActivity.PendingNavigation +import com.winlator.cmod.app.shell.UnifiedActivity.TabDef + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Settings-intent routing + desktop launch + startup bootstrap + tab building, split out of UnifiedActivity.kt (behavior-identical). + +internal fun UnifiedActivity.navigateToSettings( + item: SettingsNavItem = SettingsNavItem.CONTAINERS, + profileId: Int = 0, + editContainerId: Int = 0, + returnToGameOnBack: Boolean = false, +) { + // In-activity settings navigation does not trigger Activity resume. + reapplyPreferredRefreshRate() + val route = buildSettingsRoute(item, profileId, editContainerId, returnToGameOnBack) + val nav = rootNavController + if (nav == null) { + pendingNavigation = PendingNavigation(item, profileId, editContainerId, returnToGameOnBack) + return + } + isPoppingSettings = false + nav.navigate(route) { + launchSingleTop = true + } +} + +internal fun UnifiedActivity.buildSettingsRoute( + item: SettingsNavItem = SettingsNavItem.CONTAINERS, + profileId: Int = 0, + editContainerId: Int = 0, + returnToGameOnBack: Boolean = false, +): String = + "settings?item=${item.name}&profileId=$profileId&editContainerId=$editContainerId&returnToGameOnBack=$returnToGameOnBack" + + +internal fun UnifiedActivity.extractSettingsNavigation(intent: Intent?): PendingNavigation? { + if (intent == null) return null + + val editContainerId = intent.getIntExtra("edit_container_id", 0) + if (editContainerId > 0) { + return PendingNavigation(SettingsNavItem.CONTAINERS, 0, editContainerId) + } + + if (intent.getBooleanExtra("edit_input_controls", false)) { + val profileId = intent.getIntExtra("selected_profile_id", 0) + val returnToGameOnBack = intent.getBooleanExtra("return_to_game_on_back", false) + return PendingNavigation(SettingsNavItem.INPUT_CONTROLS, profileId, 0, returnToGameOnBack) + } + + val selectedMenuItemId = intent.getIntExtra("selected_menu_item_id", 0) + if (selectedMenuItemId > 0) { + val target = SettingsNavItem.fromMenuId(selectedMenuItemId) ?: SettingsNavItem.CONTAINERS + return PendingNavigation(target, 0, 0) + } + + return null +} + +internal fun UnifiedActivity.consumeSettingsIntent(intent: Intent?) { + intent ?: return + intent.removeExtra("edit_container_id") + intent.removeExtra("edit_input_controls") + intent.removeExtra("selected_profile_id") + intent.removeExtra("selected_menu_item_id") + intent.removeExtra("return_to_game_on_back") +} + +internal fun UnifiedActivity.handleSettingsIntent(intent: Intent?) { + val request = extractSettingsNavigation(intent) ?: return + consumeSettingsIntent(intent) + navigateToSettings(request.item, request.profileId, request.editContainerId, request.returnToGameOnBack) +} + +internal fun UnifiedActivity.maybeForwardFrontendLaunch(): Boolean { + val source = intent ?: return false + val path = resolveIncomingDesktopPath(source) ?: return false + startActivity( + Intent(this, XServerDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + putExtra("shortcut_path", path) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + }, + ) + finish() + return true +} + +internal fun UnifiedActivity.resolveIncomingDesktopPath(source: Intent): String? { + materializeDesktop(source.data)?.let { return it } + source.clipData?.let { clip -> + for (i in 0 until clip.itemCount) { + materializeDesktop(clip.getItemAt(i).uri)?.let { return it } + materializeDesktop(clip.getItemAt(i).text?.toString())?.let { return it } + } + } + val extras = source.extras ?: return null + for (key in extras.keySet()) { + materializeDesktop(extras.get(key))?.let { return it } + } + return null +} + +internal fun UnifiedActivity.materializeDesktop(value: Any?): String? = + when (value) { + is android.net.Uri -> materializeDesktopUri(value) + is String -> + if (value.startsWith("content://") || value.startsWith("file://")) { + materializeDesktopUri(android.net.Uri.parse(value)) + } else { + java.io.File(value).takeIf { it.isFile && looksLikeDesktopFile(it) }?.absolutePath + } + else -> null + } + + +internal fun UnifiedActivity.materializeDesktopUri(uri: android.net.Uri): String? { + when (uri.scheme?.lowercase()) { + "file" -> { + val file = uri.path?.let { java.io.File(it) } + if (file != null && file.isFile && looksLikeDesktopFile(file)) return file.absolutePath + } + "content" -> { + val resolved = com.winlator.cmod.shared.io.FileUtils.getFilePathFromUri(this, uri) + if (!resolved.isNullOrEmpty()) { + val file = java.io.File(resolved) + if (file.isFile && looksLikeDesktopFile(file)) return file.absolutePath + } + return copyUriToCacheDesktop(uri) + } + } + return null +} + +internal fun UnifiedActivity.copyUriToCacheDesktop(uri: android.net.Uri): String? = + runCatching { + val out = java.io.File(cacheDir, "frontend_launch.desktop") + val copied = + contentResolver.openInputStream(uri)?.use { input -> + out.outputStream().use { output -> input.copyTo(output) } + true + } ?: false + if (copied && out.isFile && looksLikeDesktopFile(out)) out.absolutePath else null + }.getOrNull() + + +internal fun UnifiedActivity.looksLikeDesktopFile(file: java.io.File): Boolean { + if (!file.isFile || file.length() > 1_000_000L) return false + return runCatching { + val text = file.readText() + text.contains("[Desktop Entry]") || text.contains("container_id") + }.getOrDefault(false) +} + +internal fun UnifiedActivity.bootstrapStartupState() { + startupBootstrapReady = false + startupLibraryLayoutMode = null + startupStoreVisible = null + startupContentFilters = null + + lifecycleScope.launch(Dispatchers.IO) { + val appContext = applicationContext + val resolvedLayoutMode = + runCatching { + PrefManager.init(appContext) + LibraryLayoutMode.valueOf(PrefManager.libraryLayoutMode) + }.getOrElse { error -> + Log.w("UnifiedActivity", "Failed to resolve initial library layout", error) + LibraryLayoutMode.GRID_4 + } + + val resolvedStoreVisible = + runCatching { + val saved = PrefManager.libraryStoreVisible.split(",").toSet() + mapOf("steam" to ("steam" in saved), "epic" to ("epic" in saved), "gog" to ("gog" in saved)) + }.getOrElse { mapOf("steam" to true, "epic" to true, "gog" to true) } + + val resolvedContentFilters = + runCatching { + val saved = PrefManager.libraryContentFilters.split(",").toSet() + mapOf( + "games" to ("games" in saved), + "dlc" to ("dlc" in saved), + "applications" to ("applications" in saved), + "tools" to ("tools" in saved), + ) + }.getOrElse { mapOf("games" to true, "dlc" to false, "applications" to false, "tools" to false) } + + runCatching { dbProvider.get() } + .onFailure { Log.w("UnifiedActivity", "Database warmup failed", it) } + runCatching { EpicAuthManager.updateLoginStatus(appContext) } + .onFailure { Log.w("UnifiedActivity", "Epic auth warmup failed", it) } + runCatching { GOGAuthManager.updateLoginStatus(appContext) } + .onFailure { Log.w("UnifiedActivity", "GOG auth warmup failed", it) } + runCatching { SteamService.initLoginStatus(appContext) } + .onFailure { Log.w("UnifiedActivity", "Steam auth warmup failed", it) } + + withContext(Dispatchers.Main.immediate) { + startupLibraryLayoutMode = resolvedLayoutMode + currentLibraryLayoutMode = resolvedLayoutMode + startupStoreVisible = resolvedStoreVisible + startupContentFilters = resolvedContentFilters + startupBootstrapReady = true + } + } +} + +/** When the "Sign in to Google on launch" toggle is on, attempt a silent Play Games sign-in once per launch. */ +internal fun UnifiedActivity.maybeAutoSignInGoogleOnLaunch() { + if (!com.winlator.cmod.feature.sync.google.CloudSyncManager.isAutoSignInOnLaunchEnabled(this)) return + runCatching { + com.winlator.cmod.feature.sync.google.PlayGamesBootstrap.ensureInitialized(this) + com.google.android.gms.games.PlayGames + .getGamesSignInClient(this) + .signIn() + .addOnCompleteListener { task -> + val authed = task.isSuccessful && task.result?.isAuthenticated == true + if (authed) { + com.winlator.cmod.feature.sync.google.GameSaveBackupManager + .setDriveConnected(applicationContext, true) + } + } + }.onFailure { + timber.log.Timber.tag("UnifiedActivity").w(it, "Auto Google sign-in on launch failed") + } +} + +internal fun UnifiedActivity.scheduleDeferredStoreBootstrap() { + window.decorView.post { + if (isFinishing || isDestroyed) return@post + lifecycleScope.launch(Dispatchers.IO) { + if (EpicService.hasStoredCredentials(this@scheduleDeferredStoreBootstrap)) { + EpicService.start(this@scheduleDeferredStoreBootstrap) + // Keep token validation off the first-frame path. + EpicAuthManager.getStoredCredentials(this@scheduleDeferredStoreBootstrap) + com.winlator.cmod.feature.stores.epic.service.EpicTokenRefreshWorker + .schedule(this@scheduleDeferredStoreBootstrap) + } + + if (SteamService.hasStoredCredentials(this@scheduleDeferredStoreBootstrap)) { + SteamService.start(this@scheduleDeferredStoreBootstrap) + } + + if (GOGAuthManager.isLoggedIn(this@scheduleDeferredStoreBootstrap)) { + GOGService.start(this@scheduleDeferredStoreBootstrap) + } + + SteamService.maybeRepairInstalledMetadataOnStartup(this@scheduleDeferredStoreBootstrap) + } + } +} + +internal fun UnifiedActivity.buildTabs(storeVisible: Map): List { + val base = + mutableListOf( + TabDef(getString(R.string.common_ui_library), "library"), + TabDef(getString(R.string.common_ui_downloads), "downloads"), + ) + if (storeVisible["steam"] != false) base.add(TabDef("Steam", "steam")) + if (storeVisible["epic"] != false) base.add(TabDef("Epic", "epic")) + if (storeVisible["gog"] != false) base.add(TabDef("GOG", "gog")) + return base +} + +@Composable +internal fun UnifiedActivity.rememberSteamInstallStateMap(apps: List): Map { + var installStateMap by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(apps) { + installStateMap = + withContext(Dispatchers.IO) { + apps.associate { it.id to SteamService.isAppInstalled(it.id) } + } + } + + return installStateMap +} + +@Composable +internal fun UnifiedActivity.rememberInstallPathStateMap(entries: List>): Map + where K : Any { + var installStateMap by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(entries) { + installStateMap = + withContext(Dispatchers.IO) { + entries.associate { (key, path) -> + key to (path?.isNotBlank() == true && java.io.File(path).exists()) + } + } + } + + return installStateMap +} + +@Composable +internal fun UnifiedActivity.rememberEpicInstallStateMap( + context: android.content.Context, + apps: List, +): Map { + var installStateMap by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(apps) { + installStateMap = + withContext(Dispatchers.IO) { + apps.associate { it.id to EpicService.isGameInstalled(context, it.id) } + } + } + + return installStateMap +} + +@Composable +internal fun UnifiedActivity.rememberGogInstallStateMap(apps: List): Map { + var installStateMap by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(apps) { + installStateMap = + withContext(Dispatchers.IO) { + apps.associate { it.id to GOGService.isGameInstalled(it.id) } + } + } + + return installStateMap +} diff --git a/app/src/main/app/shell/UnifiedActivityStores.kt b/app/src/main/app/shell/UnifiedActivityStores.kt new file mode 100644 index 000000000..c7cdf1e9a --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityStores.kt @@ -0,0 +1,2189 @@ +package com.winlator.cmod.app.shell + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Epic/GOG/Steam store tabs, capsules and manager dialogs, split out of UnifiedActivity.kt (behavior-identical). + +@Composable +internal fun UnifiedActivity.CompactActionButton( + icon: ImageVector, + label: String, + tint: Color = TextPrimary, + bgColor: Color = SurfaceDark, + modifier: Modifier = Modifier, + height: Dp = 36.dp, + fontSize: TextUnit = 13.sp, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.93f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 800f), + label = "btnScale", + ) + val glowAlpha by animateFloatAsState( + targetValue = if (isPressed) 0.18f else 0f, + animationSpec = tween(durationMillis = 120), + label = "btnGlow", + ) + Surface( + modifier = + modifier + .fillMaxWidth() + .height(height) + .graphicsLayer { + scaleX = scale + scaleY = scale + }.clip(RoundedCornerShape(10.dp)) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + color = bgColor, + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, tint.copy(alpha = glowAlpha)), + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) + Spacer(Modifier.width(6.dp)) + Text( + label, + color = tint, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +// Single game capsule for carousel / grid / list +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun UnifiedActivity.GameCapsule( + app: SteamApp, + gogGame: GOGGame? = null, + epicGame: EpicGame? = null, + iconRefreshKey: Int = 0, + artworkCacheRefreshKey: Int = 0, + isFocusedOverride: Boolean = false, + isControllerActive: Boolean = false, + customArtworkPath: String? = null, + customIconPath: String? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + useLibraryCapsule: Boolean = false, + listMode: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val isCustom = app.id < 0 + val isEpic = app.id >= 2000000000 + val defaultClick: () -> Unit = { + val containerManager = + com.winlator.cmod.runtime.container + .ContainerManager(context) + if (isCustom) { + launchCustomGame(context, containerManager, app.name) + } else if (gogGame != null) { + launchGogGame(context, containerManager, gogGame) + } else if (isEpic) { + epicGame?.let { launchEpicGame(context, containerManager, it) } + } else { + launchSteamGame(context, containerManager, app) + } + } + val clickInteraction = remember { MutableInteractionSource() } + val isPressed by clickInteraction.collectIsPressedAsState() + val isFocused = isControllerActive && isFocusedOverride + val glowAlpha by animateFloatAsState( + targetValue = if (isPressed) 0.7f else 0f, + animationSpec = if (isPressed) tween(100) else tween(400), + label = "capsuleGlow", + ) + val clickModifier = + Modifier + .then( + if (glowAlpha > 0f) { + Modifier.drawWithContent { + drawContent() + drawRoundRect( + color = AccentGlow, + alpha = glowAlpha * 0.25f, + cornerRadius = CornerRadius(12.dp.toPx()), + ) + } + } else { + Modifier + }, + ).combinedClickable( + interactionSource = clickInteraction, + indication = null, + onClick = onClick ?: defaultClick, + onLongClick = onLongClick, + ) + + @Composable + fun ArtContent(artModifier: Modifier) { + val customArtworkFile = + customArtworkPath + ?.let { java.io.File(it) } + + if (customArtworkFile != null) { + val customArtworkCacheKey = + "library_custom_icon:${customArtworkFile.absolutePath}:${customArtworkFile.lastModified()}" + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(customArtworkFile) + .memoryCacheKey(customArtworkCacheKey) + .diskCacheKey(customArtworkCacheKey) + .crossfade(300) + .build(), + contentDescription = app.name, + modifier = artModifier, + contentScale = ContentScale.Crop, + ) + } else if (isCustom) { + val iconFile = customIconPath?.let { path -> java.io.File(path) } + if (iconFile != null) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(iconFile) + .crossfade(300) + .build(), + contentDescription = app.name, + modifier = artModifier, + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = artModifier.background(SurfaceDark), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = app.name, + tint = Accent.copy(alpha = 0.6f), + modifier = Modifier.size(48.dp), + ) + } + } + } else { + val imageModel = + remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, artworkCacheRefreshKey) { + StoreArtworkCache.imageModel( + context, + StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode), + ) + } + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(imageModel) + .crossfade(300) + .build(), + contentDescription = app.name, + modifier = artModifier, + contentScale = ContentScale.Crop, + ) + } + } + + if (listMode) { + // Horizontal row card with hero background + val heroRef = if (!isCustom && gogGame == null && !isEpic) StoreArtworkCache.heroRef(app, null, null) else null + val heroModel = + remember(app.id, heroRef, artworkCacheRefreshKey) { + StoreArtworkCache.imageModel(context, heroRef) + } + + Box( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .then( + if (isControllerActive && !isFocused) { + Modifier.border(1.dp, CardBorder, RoundedCornerShape(14.dp)) + } else { + Modifier + }, + ).chasingBorder( + isFocused = isFocused, + paused = chasingBordersPaused.value || !libraryTabActive.value, + cornerRadius = 14.dp, + ).background(CardDark, RoundedCornerShape(14.dp)) + .focusable() + .then(clickModifier), + ) { + // Hero background layer (falls back to CardDark if image fails) + if (heroRef != null) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(heroModel) + .crossfade(300) + .build(), + contentDescription = null, + modifier = + Modifier + .matchParentSize() + .graphicsLayer { alpha = 0.25f }, + contentScale = ContentScale.Crop, + ) + } + + // Foreground content + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = + Modifier + .height(52.dp) + .aspectRatio(462f / 174f) + .clip(RoundedCornerShape(8.dp)), + ) { + ArtContent(Modifier.fillMaxSize()) + } + + Spacer(Modifier.width(14.dp)) + + Text( + text = app.name, + modifier = + Modifier + .weight(1f) + .then(if (isFocused) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else { + // Vertical card: art on top, title below + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier + .fillMaxWidth() + .then( + if (isFocused) { + Modifier + } else { + Modifier.border(1.dp, CardDark, RoundedCornerShape(12.dp)) + }, + ).chasingBorder( + isFocused = isFocused, + paused = chasingBordersPaused.value || !libraryTabActive.value, + cornerRadius = 12.dp, + ).background(CardDark, RoundedCornerShape(12.dp)) + .focusable() + .then(clickModifier), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + ) { + ArtContent(Modifier.fillMaxSize()) + } + + Text( + text = app.name, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + .then(if (isFocused) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + style = MaterialTheme.typography.bodySmall, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} + +// Epic Store Tab +@Composable +internal fun UnifiedActivity.EpicStoreTab( + isLoggedIn: Boolean, + epicApps: List, + searchQuery: String = "", + layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, + onLoginClick: () -> Unit, +) { + val context = LocalContext.current + + if (!isLoggedIn) { + LoginRequiredScreen("Epic Games", onLoginClick) + return + } + + val selectedAppId = remember { mutableStateOf(null) } + val gridState = rememberLazyGridState() + val activity = LocalContext.current as? UnifiedActivity + + // Ensure library updates from cloud + LaunchedEffect(Unit) { + if (epicApps.isEmpty()) { + EpicService.triggerLibrarySync(context) + } + } + + val displayedApps = + remember(epicApps, searchQuery) { + if (searchQuery.isBlank()) { + epicApps + } else { + epicApps.filter { it.title.contains(searchQuery, ignoreCase = true) } + } + } + val installStateById = rememberEpicInstallStateMap(context, displayedApps) + + // Sync store focus infrastructure + LaunchedEffect(displayedApps.size) { + activity?.storeItemCount = displayedApps.size + val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) + if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { + activity.storeFocusIndex.value = lastIndex + } + } + DisposableEffect(displayedApps) { + val clickCallback: (Int) -> Unit = { idx -> + displayedApps.getOrNull(idx)?.let { selectedAppId.value = it.id } + } + activity?.storeItemClickCallback = clickCallback + activity?.storeGridState = gridState + onDispose { + if (activity?.storeItemClickCallback === clickCallback) { + activity?.storeItemClickCallback = null + activity?.storeGridState = null + } + } + } + + if (layoutMode == LibraryLayoutMode.LIST) { + val listViewState = rememberLazyListState() + JoystickListScroll(listViewState, activity?.rightStickScrollState) + ListView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + listState = listViewState, + contentPadding = TabListContentPadding, + keyOf = { it.id }, + ) { app, _, _ -> + EpicStoreCapsule( + app, + isInstalled = installStateById[app.id] == true, + listMode = true, + isControllerActive = ControllerHelper.isControllerConnected(), + ) { + selectedAppId.value = + app.id + } + } + } else { + val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() + val focusRequesters = + remember(displayedApps.size) { + List(displayedApps.size) { FocusRequester() } + } + LaunchedEffect(focusIndex, focusRequesters.size) { + if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { + gridState.animateScrollToItem(focusIndex) + try { + focusRequesters[focusIndex].requestFocus() + } catch (_: Exception) { + } + } + } + JoystickGridScroll(gridState, activity?.rightStickScrollState) + FourByTwoGridView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), + gridState = gridState, + keyOf = { it.id }, + ) { app, index, rowHeight -> + Box( + Modifier.height(rowHeight).then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) { + EpicStoreCapsule( + app, + isInstalled = installStateById[app.id] == true, + isFocusedOverride = index == focusIndex, + isControllerActive = ControllerHelper.isControllerConnected(), + ) { + selectedAppId.value = + app.id + } + } + } + } + + val selectedApp = epicApps.find { it.id == selectedAppId.value } + if (selectedApp != null) { + EpicGameManagerDialog( + app = selectedApp, + onDismissRequest = { selectedAppId.value = null }, + ) + } +} + +@Composable +internal fun UnifiedActivity.StoreInstalledBadge( + modifier: Modifier = Modifier, + compact: Boolean = false, + attachedCorner: Boolean = false, +) { + val shape = + if (attachedCorner) { + RoundedCornerShape(topStart = 8.dp) + } else { + RoundedCornerShape(4.dp) + } + Box( + modifier = + modifier + .background(StatusOnline, shape) + .border(1.dp, Color.White.copy(alpha = 0.34f), shape) + .padding( + start = if (compact) 6.dp else 9.dp, + end = if (compact) 6.dp else 9.dp, + top = if (compact) 2.dp else 4.dp, + bottom = if (compact) 1.dp else 2.dp, + ), + ) { + Text( + stringResource(R.string.library_games_installed_badge), + color = Color(0xFF06140A), + fontSize = if (compact) 9.sp else 11.sp, + fontWeight = FontWeight.Black, + letterSpacing = 0.6.sp, + maxLines = 1, + ) + } +} + +@Composable +internal fun UnifiedActivity.EpicStoreCapsule( + app: com.winlator.cmod.feature.stores.epic.data.EpicGame, + isInstalled: Boolean, + listMode: Boolean = false, + isFocusedOverride: Boolean = false, + isControllerActive: Boolean = false, + onClick: () -> Unit, +) { + val context = LocalContext.current + var isFocused by remember { mutableStateOf(false) } + val clickInteraction = remember { MutableInteractionSource() } + val isPressed by clickInteraction.collectIsPressedAsState() + val glowAlpha by animateFloatAsState( + targetValue = if (isPressed) 0.7f else 0f, + animationSpec = if (isPressed) tween(100) else tween(400), + label = "epicCapsuleGlow", + ) + val effectiveFocus = isControllerActive && (isFocusedOverride || isFocused) + val imageUrl = app.primaryImageUrl ?: app.iconUrl + + val borderColor = if (isControllerActive) CardBorder else Color.Transparent + + if (listMode) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border(1.dp, borderColor, RoundedCornerShape(14.dp)) + .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 14.dp) + .background(CardDark, RoundedCornerShape(14.dp)) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .then( + if (glowAlpha > 0f) { + Modifier.drawWithContent { + drawContent() + drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(14.dp.toPx())) + } + } else { + Modifier + }, + ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .height(52.dp) + .aspectRatio(462f / 174f) + .clip(RoundedCornerShape(8.dp)), + ) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(imageUrl) + .crossfade(300) + .build(), + contentDescription = app.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + compact = true, + ) + } + } + Spacer(Modifier.width(14.dp)) + Text( + app.title, + modifier = + Modifier + .weight(1f) + .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxSize() + .border(1.dp, borderColor, RoundedCornerShape(16.dp)) + .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 16.dp) + .background(CardDark, RoundedCornerShape(16.dp)) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .then( + if (glowAlpha > 0f) { + Modifier.drawWithContent { + drawContent() + drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(16.dp.toPx())) + } + } else { + Modifier + }, + ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), + ) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), + ) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(imageUrl) + .crossfade(300) + .build(), + contentDescription = app.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd), + attachedCorner = true, + ) + } + } + + Text( + app.title, + modifier = + Modifier + .padding(horizontal = 4.dp, vertical = 4.dp) + .fillMaxWidth() + .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + style = MaterialTheme.typography.bodySmall, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +internal fun UnifiedActivity.EpicGameManagerDialog( + app: EpicGame, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val installed = EpicService.isGameInstalled(context, app.id) + val scope = rememberCoroutineScope() + + var isLoading by remember { mutableStateOf(!installed) } + var manifestSizes by remember { mutableStateOf(null) } + var dlcApps by remember { mutableStateOf>(emptyList()) } + val selectedDlcIds = remember { mutableStateListOf() } + var customPath by remember { mutableStateOf(null) } + var showCustomPathWarning by remember { mutableStateOf(false) } + var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } + var updateInfo by remember(app.id) { mutableStateOf(null) } + var updateStatusText by remember(app.id) { mutableStateOf(null) } + val epicDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( + initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), + ) + val hasBlockingEpicDownload = + epicDownloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_EPIC && + it.storeGameId == app.id.toString() && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + val updateActionEnabled = !hasBlockingEpicDownload + val activeEpicDownloadText = stringResource(R.string.store_game_download_already_active) + val noUpdateAvailableText = stringResource(R.string.store_game_no_update_available) + val updateAvailableText = stringResource(R.string.store_game_update_available) + val updateFailedText = stringResource(R.string.store_game_update_check_failed) + + if (showCustomPathWarning) { + CustomPathWarningDialog( + onDismiss = { showCustomPathWarning = false }, + onProceed = { + showCustomPathWarning = false + DirectoryPickerDialog.show( + activity = this@EpicGameManagerDialog, + initialPath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName), + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + }, + ) + } + + LaunchedEffect(app.id, installed) { + if (!installed) { + val (sizes, sizedDlcs) = + withContext(Dispatchers.IO) { + val baseSizes = EpicService.fetchManifestSizes(context, app.id) + val dlcs = EpicService.getDLCForGameSuspend(app.id) + val dlcsWithSizes = + dlcs + .map { dlc -> + async { + if (dlc.downloadSize > 0L || dlc.installSize > 0L) { + dlc + } else { + val dlcSizes = EpicService.fetchManifestSizes(context, dlc.id) + dlc.copy( + downloadSize = dlcSizes.downloadSize, + installSize = dlcSizes.installSize, + ) + } + } + }.awaitAll() + baseSizes to dlcsWithSizes + } + manifestSizes = sizes + dlcApps = sizedDlcs + isLoading = false + } else { + dlcApps = + withContext(Dispatchers.IO) { + EpicService + .getDLCForGameSuspend(app.id) + .map { dlc -> + async { + if (dlc.downloadSize > 0L || dlc.installSize > 0L) { + dlc + } else { + val dlcSizes = EpicService.fetchManifestSizes(context, dlc.id) + dlc.copy( + downloadSize = dlcSizes.downloadSize, + installSize = dlcSizes.installSize, + ) + } + } + }.awaitAll() + } + } + } + + val baseDownloadSize = manifestSizes?.downloadSize ?: 0L + val baseInstallSize = manifestSizes?.installSize ?: 0L + val selectedDlcDownloadBytes = + dlcApps.filter { it.id in selectedDlcIds }.sumOf { it.downloadSize.coerceAtLeast(0L) } + val selectedDlcInstallBytes = + dlcApps.filter { it.id in selectedDlcIds }.sumOf { it.installSize.coerceAtLeast(0L) } + val totalDownloadSize = baseDownloadSize + selectedDlcDownloadBytes + val totalInstallSize = baseInstallSize + selectedDlcInstallBytes + val defaultPathSet = + if (PrefManager.useSingleDownloadFolder) { + PrefManager.defaultDownloadFolder.isNotEmpty() + } else { + PrefManager.epicDownloadFolder + .isNotEmpty() + } + val effectivePath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName) + val availableBytes = + try { + StorageUtils.getAvailableSpace(effectivePath) + } catch (e: Exception) { + 0L + } + // Installed game: base content is on disk, so only gate on the newly-selected DLC bytes. + val requiredBytes = if (installed) selectedDlcInstallBytes else totalInstallSize + val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes + val installActionEnabled = isInstallEnabled && !hasBlockingEpicDownload + val installPathDisplay = if (installed) app.installPath else (customPath ?: EpicConstants.defaultEpicGamesPath(context)) + + val dlcItems = + remember(dlcApps) { + dlcApps.map { dlc -> + val size = + dlc.downloadSize.takeIf { it > 0L } + ?: dlc.installSize + StoreDlcItem(id = dlc.id, name = dlc.title, downloadSize = size, isInstalled = dlc.isInstalled) + } + } + val customPathLabel = + when { + customPath != null -> stringResource(R.string.common_ui_custom) + defaultPathSet -> stringResource(R.string.common_ui_already_set) + else -> stringResource(R.string.common_ui_custom) + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape, + color = Color.Black, + ) { + StoreGameDetailScreen( + title = app.title, + subtitle = + listOfNotNull( + app.developer.takeIf { it.isNotBlank() }, + app.publisher.takeIf { + it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) + }, + ).joinToString(" • "), + sourceLabel = "Epic Games", + heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.epicHeroRef(app)), + isLoading = isLoading, + isInstalled = installed, + installPathDisplay = installPathDisplay, + downloadSize = totalDownloadSize, + installSize = totalInstallSize, + availableBytes = availableBytes, + isInstallEnabled = isInstallEnabled, + isDownloadActionEnabled = installActionEnabled, + customPathLabel = customPathLabel, + showCustomPath = true, + showCloudSync = false, + showUninstall = false, + showUpdateCheck = installed, + isCheckingForUpdate = isCheckingForUpdate, + isUpdateAvailable = updateInfo?.hasUpdate == true, + updateDownloadSize = updateInfo?.downloadSize ?: 0L, + updateStatusText = updateStatusText, + isUpdateActionEnabled = updateActionEnabled, + showVerifyFiles = installed, + areSteamActionsEnabled = !hasBlockingEpicDownload, + dlcs = dlcItems, + selectedDlcIds = selectedDlcIds.toSet(), + onBack = onDismissRequest, + onInstall = { + if (hasBlockingEpicDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeEpicDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + val installPath = + if (customPath != null) { + val sanitizedTitle = app.title.replace(Regex("[^a-zA-Z0-9 \\-_]"), "").trim() + java.io.File(customPath!!, sanitizedTitle).absolutePath + } else { + EpicConstants.getGameInstallPath(context, app.title) + } + context.runIfOnlineOrToast { + EpicService.downloadGame(context, app.id, selectedDlcIds.toList(), installPath, "en-US") + onDismissRequest() + } + }, + onCloudSync = { + scope.launch(Dispatchers.IO) { + EpicCloudSavesManager.syncCloudSaves(context, app.id, "auto") + } + onDismissRequest() + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString(R.string.google_cloud_sync_started), + android.widget.Toast.LENGTH_SHORT, + ) + }, + onVerifyFiles = { + if (hasBlockingEpicDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeEpicDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch { + val started = + withContext(Dispatchers.IO) { + EpicService.verifyGameFiles(context, app.id) + } + if (started != null) { + showTaskProgressPopup( + started, + app.title, + getString(R.string.store_game_verify_complete), + getString(R.string.store_game_verify_failed_notice), + completeAsToast = true, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeEpicDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onCheckForUpdate = { + if (hasBlockingEpicDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeEpicDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch { + isCheckingForUpdate = true + updateStatusText = null + val latest = + withContext(Dispatchers.IO) { + EpicService.checkForGameUpdate(context, app.id) + } + updateInfo = latest + updateStatusText = + when { + latest.hasUpdate -> updateAvailableText + latest.message != null -> updateFailedText + else -> null + } + isCheckingForUpdate = false + if (!latest.hasUpdate && latest.message == null) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + noUpdateAvailableText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onDownloadUpdate = { + if (!updateActionEnabled || updateInfo?.hasUpdate != true) return@StoreGameDetailScreen + context.runIfOnlineOrToast { + scope.launch { + val latest = + withContext(Dispatchers.IO) { + EpicService.checkForGameUpdate(context, app.id) + } + updateInfo = latest + updateStatusText = + when { + latest.hasUpdate -> updateAvailableText + latest.message != null -> updateFailedText + else -> null + } + if (!latest.hasUpdate) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + noUpdateAvailableText, + android.widget.Toast.LENGTH_SHORT, + ) + return@launch + } + val started = + withContext(Dispatchers.IO) { + EpicService.updateGameFiles(context, app.id) + } + if (started != null) { + onDismissRequest() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeEpicDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onUninstall = { + scope.launch(Dispatchers.IO) { + val result = EpicService.deleteGame(context, app.id) + withContext(Dispatchers.Main) { + if (!result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message + ?: getString(R.string.common_ui_unknown_error), + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + }, + onCustomPath = { + if (customPath == null && defaultPathSet) { + showCustomPathWarning = true + } else { + DirectoryPickerDialog.show( + activity = this@EpicGameManagerDialog, + initialPath = customPath ?: EpicConstants.getGameInstallPath(context, app.appName), + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + } + }, + onToggleDlc = { id -> + if (selectedDlcIds.contains(id)) { + selectedDlcIds.remove(id) + } else { + selectedDlcIds.add(id) + } + }, + onToggleSelectAllDlcs = { + val all = dlcItems.isNotEmpty() && dlcItems.all { it.id in selectedDlcIds } + if (all) { + selectedDlcIds.clear() + } else { + dlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } + } + }, + ) + } + } +} + +@Composable +internal fun UnifiedActivity.GOGStoreTab( + isLoggedIn: Boolean, + gogApps: List, + searchQuery: String = "", + layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, + onLoginClick: () -> Unit, +) { + if (!isLoggedIn) { + LoginRequiredScreen("GOG", onLoginClick) + return + } + + val selectedGameId = remember { mutableStateOf(null) } + val gridState = rememberLazyGridState() + val activity = LocalContext.current as? UnifiedActivity + + val displayedApps = + remember(gogApps, searchQuery) { + if (searchQuery.isBlank()) { + gogApps + } else { + gogApps.filter { it.title.contains(searchQuery, ignoreCase = true) } + } + } + val installStateById = rememberGogInstallStateMap(displayedApps) + + // Sync store focus infrastructure + LaunchedEffect(displayedApps.size) { + activity?.storeItemCount = displayedApps.size + val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) + if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { + activity.storeFocusIndex.value = lastIndex + } + } + DisposableEffect(displayedApps) { + val clickCallback: (Int) -> Unit = { idx -> + displayedApps.getOrNull(idx)?.let { selectedGameId.value = it.id } + } + activity?.storeItemClickCallback = clickCallback + activity?.storeGridState = gridState + onDispose { + if (activity?.storeItemClickCallback === clickCallback) { + activity?.storeItemClickCallback = null + activity?.storeGridState = null + } + } + } + + val isControllerActive = ControllerHelper.isControllerConnected() + val gogBorderColor = if (isControllerActive) CardBorder else Color.Transparent + + if (layoutMode == LibraryLayoutMode.LIST) { + val listViewState = rememberLazyListState() + ListView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + listState = listViewState, + contentPadding = TabListContentPadding, + keyOf = { it.id }, + ) { app, _, _ -> + val isInstalled = installStateById[app.id] == true + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border(1.dp, gogBorderColor, RoundedCornerShape(14.dp)) + .background(CardDark, RoundedCornerShape(14.dp)) + .clickable { selectedGameId.value = app.id } + .padding(horizontal = 14.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .height(52.dp) + .aspectRatio(462f / 174f) + .clip(RoundedCornerShape(8.dp)), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(app.imageUrl.ifEmpty { app.iconUrl }) + .crossfade(300) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + compact = true, + ) + } + } + Spacer(Modifier.width(14.dp)) + Text( + text = app.title, + modifier = Modifier.weight(1f), + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else { + val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() + val focusRequesters = + remember(displayedApps.size) { + List(displayedApps.size) { FocusRequester() } + } + LaunchedEffect(focusIndex, focusRequesters.size) { + if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { + gridState.animateScrollToItem(focusIndex) + try { + focusRequesters[focusIndex].requestFocus() + } catch (_: Exception) { + } + } + } + FourByTwoGridView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), + gridState = gridState, + keyOf = { it.id }, + ) { app, index, rowHeight -> + val isInstalled = installStateById[app.id] == true + val isItemFocused = isControllerActive && index == focusIndex + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxWidth() + .height(rowHeight) + .then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ).border(1.dp, gogBorderColor, RoundedCornerShape(16.dp)) + .chasingBorder(isFocused = isItemFocused, paused = chasingBordersPaused.value, cornerRadius = 16.dp) + .background(CardDark, RoundedCornerShape(16.dp)) + .clickable { selectedGameId.value = app.id }, + ) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), + ) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(app.imageUrl.ifEmpty { app.iconUrl }) + .crossfade(300) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd), + attachedCorner = true, + ) + } + } + + Text( + text = app.title, + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } + } + + selectedGameId.value?.let { gameId -> + val app = gogApps.firstOrNull { it.id == gameId } + if (app != null) { + GOGGameManagerDialog(app = app) { selectedGameId.value = null } + } + } +} + +@Composable +internal fun UnifiedActivity.GOGGameManagerDialog( + app: GOGGame, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val installed = GOGService.isGameInstalled(app.id) + val scope = rememberCoroutineScope() + var isLoading by remember(app.id) { mutableStateOf(true) } + var selectedManifestSizes by remember(app.id) { mutableStateOf(GOGManifestSizes()) } + var dlcSizes by remember(app.id) { mutableStateOf>(emptyMap()) } + var customPath by remember { mutableStateOf(null) } + var showCustomPathWarning by remember { mutableStateOf(false) } + var dlcApps by remember(app.id) { mutableStateOf>(emptyList()) } + val selectedDlcIds = remember(app.id) { mutableStateListOf() } + var isCheckingForGogUpdate by remember(app.id) { mutableStateOf(false) } + var gogUpdateInfo by remember(app.id) { mutableStateOf(null) } + var gogUpdateStatusText by remember(app.id) { mutableStateOf(null) } + val gogDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( + initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), + ) + val hasBlockingGogDownload = + gogDownloadRecords.any { + it.store == com.winlator.cmod.app.db.download.DownloadRecord.STORE_GOG && + it.storeGameId == app.id && + it.status in setOf( + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_QUEUED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_DOWNLOADING, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_PAUSED, + com.winlator.cmod.app.db.download.DownloadRecord.STATUS_FAILED, + ) + } + val gogUpdateActionEnabled = !hasBlockingGogDownload + val activeGogDownloadText = stringResource(R.string.store_game_download_already_active) + val gogNoUpdateAvailableText = stringResource(R.string.store_game_no_update_available) + val gogUpdateAvailableText = stringResource(R.string.store_game_update_available) + val gogUpdateFailedText = stringResource(R.string.store_game_update_check_failed) + + if (showCustomPathWarning) { + CustomPathWarningDialog( + onDismiss = { showCustomPathWarning = false }, + onProceed = { + showCustomPathWarning = false + DirectoryPickerDialog.show( + activity = this@GOGGameManagerDialog, + initialPath = customPath ?: GOGConstants.defaultGOGGamesPath, + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + }, + ) + } + + data class GogInstallLoadData( + val dlcs: List, + val dlcSizes: Map, + val baseManifestSizes: GOGManifestSizes, + ) + + LaunchedEffect(app.id, PrefManager.containerLanguage) { + isLoading = true + val loadData = + withContext(Dispatchers.IO) { + val dlcs = GOGService.getDLCForGameSuspend(app.id, PrefManager.containerLanguage) + val perDlcSizes = + dlcs.mapNotNull { dlc -> + val id = dlc.id.toIntOrNull() ?: return@mapNotNull null + id to + GOGManifestSizes( + installSize = dlc.installSize, + downloadSize = dlc.downloadSize, + ) + }.toMap() + GogInstallLoadData( + dlcs = dlcs, + dlcSizes = perDlcSizes, + baseManifestSizes = + GOGService.getInstallableSelectedManifestSizes( + app.id, + PrefManager.containerLanguage, + ), + ) + } + dlcApps = loadData.dlcs + dlcSizes = loadData.dlcSizes + selectedManifestSizes = loadData.baseManifestSizes + selectedDlcIds.clear() + loadData.dlcs + .filterNot { it.isInstalled } + .mapNotNull { it.id.toIntOrNull() } + .forEach { selectedDlcIds.add(it) } + isLoading = false + } + + LaunchedEffect(app.id, PrefManager.containerLanguage, selectedDlcIds.toList()) { + selectedManifestSizes = + withContext(Dispatchers.IO) { + GOGService.getInstallableSelectedManifestSizes( + app.id, + PrefManager.containerLanguage, + selectedDlcIds.toList(), + ) + } + } + + val defaultPathSet = + if (PrefManager.useSingleDownloadFolder) { + PrefManager.defaultDownloadFolder.isNotEmpty() + } else { + PrefManager.gogDownloadFolder + .isNotEmpty() + } + val installRootPath = customPath ?: GOGConstants.defaultGOGGamesPath + val installPathDisplay = + if (installed) { + app.installPath + } else if (customPath != null) { + java.io.File(customPath!!, GOGConstants.getSanitizedGameFolderName(app.title)).absolutePath + } else { + GOGConstants.getGameInstallPath(app.title) + } + val dlcItems = + remember(dlcApps, dlcSizes) { + dlcApps.mapNotNull { dlc -> + val id = dlc.id.toIntOrNull() ?: return@mapNotNull null + val manifestSize = dlcSizes[id] + val size = + manifestSize + ?.downloadSize + ?.takeIf { it > 0L } + ?: manifestSize?.installSize?.takeIf { it > 0L } + ?: dlc.downloadSize.takeIf { it > 0L } + ?: dlc.installSize + StoreDlcItem(id = id, name = dlc.title, downloadSize = size, isInstalled = dlc.isInstalled) + } + } + val selectedDlcDownloadSize = + remember(dlcItems, selectedDlcIds.toList()) { + dlcItems + .filter { !it.isInstalled && it.id in selectedDlcIds } + .sumOf { it.downloadSize.coerceAtLeast(0L) } + } + val selectedDlcInstallSize = + remember(dlcSizes, dlcItems, selectedDlcIds.toList()) { + dlcItems + .filter { !it.isInstalled && it.id in selectedDlcIds } + .sumOf { dlcSizes[it.id]?.installSize?.takeIf { size -> size > 0L } ?: it.downloadSize.coerceAtLeast(0L) } + } + val totalDownloadSize = + if (installed) { + selectedDlcDownloadSize + } else { + selectedManifestSizes.downloadSize.takeIf { it > 0L } + ?: app.downloadSize + selectedDlcDownloadSize + } + val totalInstallSize = + if (installed) { + selectedDlcInstallSize + } else { + selectedManifestSizes.installSize.takeIf { it > 0L } + ?: app.installSize.takeIf { it > 0L } + ?: totalDownloadSize + } + val requiredBytes = + if (installed) { + selectedDlcInstallSize.takeIf { it > 0L } ?: selectedDlcDownloadSize + } else { + totalInstallSize.takeIf { it > 0L } ?: totalDownloadSize + } + val availableBytes = + try { + StorageUtils.getAvailableSpace(installRootPath) + } catch (_: Exception) { + 0L + } + val isInstallEnabled = requiredBytes == 0L || availableBytes >= requiredBytes + val installActionEnabled = isInstallEnabled && !hasBlockingGogDownload + val customPathLabel = + when { + customPath != null -> stringResource(R.string.common_ui_custom) + defaultPathSet -> stringResource(R.string.common_ui_already_set) + else -> stringResource(R.string.common_ui_custom) + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RectangleShape, + color = Color.Black, + ) { + StoreGameDetailScreen( + title = app.title, + subtitle = + listOfNotNull( + app.developer.takeIf { it.isNotBlank() }, + app.publisher.takeIf { + it.isNotBlank() && !it.equals(app.developer, ignoreCase = true) + }, + ).joinToString(" • "), + sourceLabel = "GOG", + heroImageUrl = StoreArtworkCache.imageModel(context, StoreArtworkCache.gogHeroRef(app)), + isLoading = isLoading, + isInstalled = installed, + installPathDisplay = installPathDisplay, + downloadSize = totalDownloadSize, + installSize = totalInstallSize, + availableBytes = availableBytes, + isInstallEnabled = isInstallEnabled, + isDownloadActionEnabled = installActionEnabled, + customPathLabel = customPathLabel, + showCustomPath = true, + showCloudSync = false, + showUninstall = false, + showUpdateCheck = installed, + isCheckingForUpdate = isCheckingForGogUpdate, + isUpdateAvailable = gogUpdateInfo?.hasUpdate == true, + updateDownloadSize = gogUpdateInfo?.downloadSize ?: 0L, + updateStatusText = gogUpdateStatusText, + isUpdateActionEnabled = gogUpdateActionEnabled, + showVerifyFiles = installed, + areSteamActionsEnabled = !hasBlockingGogDownload, + dlcs = dlcItems, + selectedDlcIds = selectedDlcIds.toSet(), + isDlcSelectionEnabled = installActionEnabled, + onBack = onDismissRequest, + onInstall = { + if (hasBlockingGogDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_download_already_active), + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + GOGService.downloadGame( + context, + app.id, + installPathDisplay, + PrefManager.containerLanguage, + selectedDlcIds.toList(), + ) + onDismissRequest() + } + }, + onCloudSync = { + scope.launch(Dispatchers.IO) { + GOGService.syncCloudSaves(context, "GOG_${app.id}", "auto") + } + onDismissRequest() + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString(R.string.google_cloud_sync_started), + android.widget.Toast.LENGTH_SHORT, + ) + }, + onVerifyFiles = { + if (hasBlockingGogDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeGogDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch { + val started = + withContext(Dispatchers.IO) { + GOGService.verifyGameFiles(context, app.id) + } + if (started != null) { + showTaskProgressPopup( + started, + app.title, + getString(R.string.store_game_verify_complete), + getString(R.string.store_game_verify_failed_notice), + completeAsToast = true, + ) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeGogDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onCheckForUpdate = { + if (hasBlockingGogDownload) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeGogDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + return@StoreGameDetailScreen + } + context.runIfOnlineOrToast { + scope.launch { + isCheckingForGogUpdate = true + gogUpdateStatusText = null + val latest = + withContext(Dispatchers.IO) { + GOGService.checkForGameUpdate(context, app.id) + } + gogUpdateInfo = latest + gogUpdateStatusText = + when { + latest.hasUpdate -> gogUpdateAvailableText + latest.message != null -> gogUpdateFailedText + else -> null + } + isCheckingForGogUpdate = false + if (!latest.hasUpdate && latest.message == null) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + gogNoUpdateAvailableText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onDownloadUpdate = { + if (!gogUpdateActionEnabled || gogUpdateInfo?.hasUpdate != true) return@StoreGameDetailScreen + context.runIfOnlineOrToast { + scope.launch { + val started = + withContext(Dispatchers.IO) { + GOGService.updateGameFiles(context, app.id) + } + if (started != null) { + onDismissRequest() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + activeGogDownloadText, + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, + onUninstall = { + scope.launch(Dispatchers.IO) { + val result = + GOGService.deleteGame( + context, + LibraryItem( + "GOG_${app.id}", + app.title, + com.winlator.cmod.feature.stores.steam.enums.GameSource.GOG, + ), + ) + withContext(Dispatchers.Main) { + if (!result.isSuccess) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString( + R.string.library_games_failed_to_uninstall_reason, + result.exceptionOrNull()?.message + ?: getString(R.string.common_ui_unknown_error), + ), + android.widget.Toast.LENGTH_LONG, + ) + } + onDismissRequest() + } + } + }, + onCustomPath = { + if (customPath == null && defaultPathSet) { + showCustomPathWarning = true + } else { + DirectoryPickerDialog.show( + activity = this@GOGGameManagerDialog, + initialPath = customPath ?: GOGConstants.defaultGOGGamesPath, + title = getString(R.string.settings_content_install_directory), + extraRoots = driveRoots(includeInternal = true), + ) { path -> customPath = path } + } + }, + onToggleDlc = { id -> + if (dlcItems.any { it.id == id && it.isInstalled }) { + return@StoreGameDetailScreen + } + if (selectedDlcIds.contains(id)) { + selectedDlcIds.remove(id) + } else { + selectedDlcIds.add(id) + } + }, + onToggleSelectAllDlcs = { + val selectableDlcItems = dlcItems.filterNot { it.isInstalled } + val all = selectableDlcItems.isNotEmpty() && selectableDlcItems.all { it.id in selectedDlcIds } + if (all) { + selectedDlcIds.removeAll(selectableDlcItems.map { it.id }.toSet()) + } else { + selectableDlcItems.forEach { if (it.id !in selectedDlcIds) selectedDlcIds.add(it.id) } + } + }, + ) + } + } +} + +// Steam Store Tab +@Composable +internal fun UnifiedActivity.SteamStoreTab( + isLoggedIn: Boolean, + steamApps: List, + searchQuery: String = "", + layoutMode: LibraryLayoutMode = LibraryLayoutMode.GRID_4, +) { + if (!isLoggedIn && !SteamService.hasStoredCredentials(this)) { + LoginRequiredScreen("Steam") { + startActivity(Intent(this@SteamStoreTab, SteamLoginActivity::class.java)) + } + return + } + + var selectedAppForDialog by remember { mutableStateOf(null) } + val gridState = rememberLazyGridState() + val activity = LocalContext.current as? UnifiedActivity + + val displayedApps = + remember(steamApps, searchQuery) { + if (searchQuery.isBlank()) { + steamApps + } else { + steamApps.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + val installStateById = rememberSteamInstallStateMap(displayedApps) + + // Sync store focus infrastructure + LaunchedEffect(displayedApps.size) { + activity?.storeItemCount = displayedApps.size + val lastIndex = (displayedApps.size - 1).coerceAtLeast(0) + if (activity != null && displayedApps.isNotEmpty() && activity.storeFocusIndex.value > lastIndex) { + activity.storeFocusIndex.value = lastIndex + } + } + // Register A-button click callback and grid state for visible-area snapping + DisposableEffect(displayedApps) { + val clickCallback: (Int) -> Unit = { idx -> + displayedApps.getOrNull(idx)?.let { selectedAppForDialog = it } + } + activity?.storeItemClickCallback = clickCallback + activity?.storeGridState = gridState + onDispose { + if (activity?.storeItemClickCallback === clickCallback) { + activity?.storeItemClickCallback = null + activity?.storeGridState = null + } + } + } + + if (layoutMode == LibraryLayoutMode.LIST) { + val listViewState = rememberLazyListState() + JoystickListScroll(listViewState, activity?.rightStickScrollState, minSpeed = 2.5f, maxSpeed = 16f, quadratic = true) + ListView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(), + listState = listViewState, + contentPadding = TabListContentPadding, + keyOf = { it.id }, + ) { app, _, _ -> + SteamStoreCapsule( + app, + isInstalled = installStateById[app.id] == true, + listMode = true, + isControllerActive = ControllerHelper.isControllerConnected(), + onClick = { + selectedAppForDialog = + app + }, + ) + } + } else { + val focusIndex by (activity?.storeFocusIndex ?: kotlinx.coroutines.flow.MutableStateFlow(0)).collectAsState() + val focusRequesters = + remember(displayedApps.size) { + List(displayedApps.size) { FocusRequester() } + } + LaunchedEffect(focusIndex, focusRequesters.size) { + if (searchQuery.isEmpty() && focusRequesters.isNotEmpty() && focusIndex in focusRequesters.indices) { + gridState.animateScrollToItem(focusIndex) + try { + focusRequesters[focusIndex].requestFocus() + } catch (_: Exception) { + } + } + } + // Right joystick: 2x faster at full push with quadratic speed curve + JoystickGridScroll(gridState, activity?.rightStickScrollState, minSpeed = 2.5f, maxSpeed = 16f, quadratic = true) + // Left joystick: 75% slower scrolling (vertical only, for browsing store) + JoystickGridScroll(gridState, activity?.leftStickScrollState, deadZone = 0.15f, minSpeed = 0.3125f, maxSpeed = 2f) + FourByTwoGridView( + items = displayedApps, + modifier = Modifier.tabScreenPadding(top = TabGridTopPadding), + gridState = gridState, + keyOf = { it.id }, + ) { app, index, rowHeight -> + Box( + Modifier.height(rowHeight).then( + if (index in focusRequesters.indices) { + Modifier.focusRequester(focusRequesters[index]) + } else { + Modifier + }, + ), + ) { + SteamStoreCapsule( + app, + isInstalled = installStateById[app.id] == true, + isFocusedOverride = index == focusIndex, + isControllerActive = + ControllerHelper + .isControllerConnected(), + onClick = { + selectedAppForDialog = + app + }, + ) + } + } + } + + if (selectedAppForDialog != null) { + GameManagerDialog( + app = selectedAppForDialog!!, + onDismissRequest = { selectedAppForDialog = null }, + ) + } +} + +@Composable +internal fun UnifiedActivity.SteamStoreCapsule( + app: SteamApp, + isInstalled: Boolean, + listMode: Boolean = false, + isFocusedOverride: Boolean = false, + isControllerActive: Boolean = false, + onClick: () -> Unit, +) { + val context = LocalContext.current + var isFocused by remember { mutableStateOf(false) } + val clickInteraction = remember { MutableInteractionSource() } + val isPressed by clickInteraction.collectIsPressedAsState() + val glowAlpha by animateFloatAsState( + targetValue = if (isPressed) 0.7f else 0f, + animationSpec = if (isPressed) tween(100) else tween(400), + label = "steamCapsuleGlow", + ) + val effectiveFocus = isControllerActive && (isFocusedOverride || isFocused) + val borderColor = if (isControllerActive) CardBorder else Color.Transparent + + if (listMode) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border(1.dp, borderColor, RoundedCornerShape(14.dp)) + .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 14.dp) + .background(CardDark, RoundedCornerShape(14.dp)) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .then( + if (glowAlpha > 0f) { + Modifier.drawWithContent { + drawContent() + drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(14.dp.toPx())) + } + } else { + Modifier + }, + ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), + ) { + // Hero background + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(app.getHeroUrl()) + .crossfade(300) + .build(), + contentDescription = null, + modifier = + Modifier + .matchParentSize() + .graphicsLayer { alpha = 0.25f }, + contentScale = ContentScale.Crop, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 11.dp), + horizontalArrangement = Arrangement.Center, + ) { + Box( + Modifier + .height(52.dp) + .aspectRatio(462f / 174f) + .clip(RoundedCornerShape(8.dp)), + ) { + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(app.getSmallCapsuleUrl()) + .crossfade(300) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + compact = true, + ) + } + } + Spacer(Modifier.width(14.dp)) + Text( + text = app.name, + modifier = + Modifier + .weight(1f) + .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxSize() + .border(1.dp, borderColor, RoundedCornerShape(16.dp)) + .chasingBorder(isFocused = effectiveFocus, paused = chasingBordersPaused.value, cornerRadius = 16.dp) + .background(CardDark, RoundedCornerShape(16.dp)) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .then( + if (glowAlpha > 0f) { + Modifier.drawWithContent { + drawContent() + drawRoundRect(color = AccentGlow, alpha = glowAlpha * 0.25f, cornerRadius = CornerRadius(16.dp.toPx())) + } + } else { + Modifier + }, + ).clickable(interactionSource = clickInteraction, indication = null, onClick = onClick), + ) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), + ) { + val imageUrl = app.getCapsuleUrl() + + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(imageUrl) + .crossfade(300) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + + if (isInstalled) { + StoreInstalledBadge( + modifier = Modifier.align(Alignment.BottomEnd), + attachedCorner = true, + ) + } + } + + Text( + text = app.name, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + .then(if (effectiveFocus) Modifier.basicMarquee(iterations = Int.MAX_VALUE) else Modifier), + style = MaterialTheme.typography.bodySmall, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/app/shell/UnifiedActivityUpdateChecks.kt b/app/src/main/app/shell/UnifiedActivityUpdateChecks.kt new file mode 100644 index 000000000..e40519c0d --- /dev/null +++ b/app/src/main/app/shell/UnifiedActivityUpdateChecks.kt @@ -0,0 +1,431 @@ +package com.winlator.cmod.app.shell + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.util.Log +import androidx.activity.SystemBarStyle +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.winlator.cmod.BuildConfig +import com.winlator.cmod.R +import com.winlator.cmod.app.PluviaApp +import com.winlator.cmod.app.db.PluviaDatabase +import com.winlator.cmod.app.service.DownloadService +import com.winlator.cmod.app.service.download.DownloadCoordinator +import com.winlator.cmod.app.update.UpdateChecker +import com.winlator.cmod.feature.settings.InputControlsFragment +import com.winlator.cmod.feature.settings.SettingsFocusZone +import com.winlator.cmod.feature.settings.SettingsHost +import com.winlator.cmod.feature.settings.SettingsNavBridge +import com.winlator.cmod.feature.settings.SettingsNavItem +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.feature.shortcuts.LibraryShortcutUtils +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.feature.shortcuts.ShortcutBroadcastReceiver +import com.winlator.cmod.feature.shortcuts.ShortcutSettingsComposeDialog +import com.winlator.cmod.feature.shortcuts.ShortcutsFragment +import com.winlator.cmod.feature.stores.common.StoreArtworkCache +import com.winlator.cmod.feature.stores.epic.data.EpicCredentials +import com.winlator.cmod.feature.stores.epic.data.EpicGame +import com.winlator.cmod.feature.stores.epic.data.EpicGameToken +import com.winlator.cmod.feature.stores.epic.service.EpicAuthManager +import com.winlator.cmod.feature.stores.epic.service.EpicCloudSavesManager +import com.winlator.cmod.feature.stores.epic.service.EpicConstants +import com.winlator.cmod.feature.stores.epic.service.EpicDownloadManager +import com.winlator.cmod.feature.stores.epic.service.EpicGameLauncher +import com.winlator.cmod.feature.stores.epic.service.EpicManager +import com.winlator.cmod.feature.stores.epic.service.EpicService +import com.winlator.cmod.feature.stores.epic.service.EpicUpdateInfo +import com.winlator.cmod.feature.stores.epic.ui.auth.EpicOAuthActivity +import com.winlator.cmod.feature.stores.gog.data.GOGDlcInfo +import com.winlator.cmod.feature.stores.gog.data.GOGGame +import com.winlator.cmod.feature.stores.gog.data.LibraryItem +import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager +import com.winlator.cmod.feature.stores.gog.service.GOGConstants +import com.winlator.cmod.feature.stores.gog.service.GOGManifestSizes +import com.winlator.cmod.feature.stores.gog.service.GOGService +import com.winlator.cmod.feature.stores.gog.service.GOGUpdateInfo +import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity +import com.winlator.cmod.feature.stores.steam.SteamLoginActivity +import com.winlator.cmod.feature.stores.steam.data.DepotInfo +import com.winlator.cmod.feature.stores.steam.data.DownloadInfo +import com.winlator.cmod.feature.stores.steam.data.SteamApp +import com.winlator.cmod.feature.stores.steam.enums.DownloadPhase +import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.events.EventDispatcher +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.feature.stores.steam.utils.PrefManager +import com.winlator.cmod.feature.stores.steam.utils.getAvatarURL +import com.winlator.cmod.feature.sync.CloudSyncHelper +import com.winlator.cmod.feature.sync.google.CloudSyncManager +import com.winlator.cmod.feature.sync.google.GameSaveBackupManager +import com.winlator.cmod.feature.sync.ui.CloudSavesContent +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.XServerDisplayActivity +import com.winlator.cmod.runtime.display.environment.ImageFs +import com.winlator.cmod.runtime.input.ControllerHelper +import com.winlator.cmod.runtime.wine.PeIconExtractor +import com.winlator.cmod.shared.android.ActivityResultHost +import com.winlator.cmod.shared.android.AppTerminationHelper +import com.winlator.cmod.shared.android.DirectoryPickerDialog +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity +import com.winlator.cmod.shared.android.RefreshRateUtils +import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.dialog.PopupDialog +import com.winlator.cmod.shared.ui.dialog.PopupTextAction +import androidx.compose.foundation.focusGroup +import com.winlator.cmod.shared.ui.focus.controllerFocusGlow +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerTextFieldEscape +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PANE_DIR_DOWN +import com.winlator.cmod.shared.ui.nav.PANE_DIR_LEFT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_RIGHT +import com.winlator.cmod.shared.ui.nav.PANE_DIR_SECONDARY +import com.winlator.cmod.shared.ui.nav.PANE_DIR_UP +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.FourByTwoGridView +import com.winlator.cmod.shared.ui.JoystickGridScroll +import com.winlator.cmod.shared.ui.JoystickListScroll +import com.winlator.cmod.shared.ui.ListView +import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.theme.WinNativeTheme +import dagger.hilt.android.AndroidEntryPoint +import dagger.Lazy +import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.roundToInt + +// Task-progress popup + store update-check helpers, split out of UnifiedActivity.kt (behavior-identical). + +internal fun UnifiedActivity.showTaskProgressPopup( + info: DownloadInfo, + gameName: String, + completeMsg: String, + failedMsg: String, + completeAsToast: Boolean = false, +) { + taskCheckingShown = false + taskProgressInfo = info + taskProgressGameName = gameName + taskProgressCompleteMsg = completeMsg + taskProgressFailedMsg = failedMsg + taskProgressCompleteAsToast = completeAsToast + taskProgressShown = true + taskDoneMessage = null +} + +// Runs a Steam update check behind the checking pop-up. +internal fun UnifiedActivity.startUpdateCheck(appId: Int, gameName: String) { + if (updateCheckInProgress) return + if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this, + getString(R.string.downloads_no_internet), + android.widget.Toast.LENGTH_SHORT, + ) + return + } + updateCheckInProgress = true + taskCheckingGameName = gameName + taskCheckingShown = true + taskDoneMessage = null + lifecycleScope.launch { + val result = + runCatching { + withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } + }.getOrNull() + try { + when { + result == null || result.message != null -> { + taskCheckingShown = false + taskDoneFailed = true + taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) + } + result.hasUpdate -> { + val started = + withContext(Dispatchers.IO) { + SteamService.downloadAppForUpdate(appId, result.depotIds) + } + if (started != null) { + showTaskProgressPopup( + started, + gameName, + getString(R.string.store_game_update_complete), + getString(R.string.store_game_update_failed_notice), + ) + } else { + // A download is already running — downloadApp showed its toast. + taskCheckingShown = false + } + } + else -> { + taskCheckingShown = false + taskDoneFailed = false + taskDoneMessage = getString(R.string.store_game_no_updates_notice) + } + } + } finally { + updateCheckInProgress = false + } + } +} + +internal fun UnifiedActivity.startGogUpdateCheck(gameId: String, gameName: String) { + if (updateCheckInProgress) return + if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this, + getString(R.string.downloads_no_internet), + android.widget.Toast.LENGTH_SHORT, + ) + return + } + updateCheckInProgress = true + taskCheckingGameName = gameName + taskCheckingShown = true + taskDoneMessage = null + lifecycleScope.launch { + val result = + runCatching { + withContext(Dispatchers.IO) { GOGService.checkForGameUpdate(this@startGogUpdateCheck, gameId) } + }.getOrNull() + try { + when { + result == null || result.message != null -> { + taskCheckingShown = false + taskDoneFailed = true + taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) + } + result.hasUpdate -> { + val started = + withContext(Dispatchers.IO) { + GOGService.updateGameFiles(this@startGogUpdateCheck, gameId) + } + if (started != null) { + showTaskProgressPopup( + started, + gameName, + getString(R.string.store_game_update_complete), + getString(R.string.store_game_update_failed_notice), + ) + } else { + taskCheckingShown = false + } + } + else -> { + taskCheckingShown = false + taskDoneFailed = false + taskDoneMessage = getString(R.string.store_game_no_updates_notice) + } + } + } finally { + updateCheckInProgress = false + } + } +} + +internal fun UnifiedActivity.startEpicUpdateCheck(appId: Int, gameName: String) { + if (updateCheckInProgress) return + if (!com.winlator.cmod.app.service.NetworkMonitor.hasInternet.value) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this, + getString(R.string.downloads_no_internet), + android.widget.Toast.LENGTH_SHORT, + ) + return + } + updateCheckInProgress = true + taskCheckingGameName = gameName + taskCheckingShown = true + taskDoneMessage = null + lifecycleScope.launch { + val result = + runCatching { + withContext(Dispatchers.IO) { EpicService.checkForGameUpdate(this@startEpicUpdateCheck, appId) } + }.getOrNull() + try { + when { + result == null || result.message != null -> { + taskCheckingShown = false + taskDoneFailed = true + taskDoneMessage = getString(R.string.store_game_update_check_failed_notice) + } + result.hasUpdate -> { + val started = + withContext(Dispatchers.IO) { + EpicService.updateGameFiles(this@startEpicUpdateCheck, appId) + } + if (started != null) { + showTaskProgressPopup( + started, + gameName, + getString(R.string.store_game_update_complete), + getString(R.string.store_game_update_failed_notice), + ) + } else { + taskCheckingShown = false + } + } + else -> { + taskCheckingShown = false + taskDoneFailed = false + taskDoneMessage = getString(R.string.store_game_no_updates_notice) + } + } + } finally { + updateCheckInProgress = false + } + } +} From 814f073e18b34bb848aef5ece4888dfbbcca0465 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Tue, 14 Jul 2026 21:04:04 +0000 Subject: [PATCH 3/8] Extract pure static helpers and POJOs out of XServerDisplayActivity into sibling files (behavior-neutral) --- .../main/runtime/display/ExitUploadTypes.java | 28 +++ .../runtime/display/SteamExecutableInfo.java | 13 ++ .../display/XServerDisplayActivity.java | 206 +----------------- .../runtime/display/XServerDisplayUtils.java | 183 ++++++++++++++++ 4 files changed, 225 insertions(+), 205 deletions(-) create mode 100644 app/src/main/runtime/display/ExitUploadTypes.java create mode 100644 app/src/main/runtime/display/SteamExecutableInfo.java create mode 100644 app/src/main/runtime/display/XServerDisplayUtils.java diff --git a/app/src/main/runtime/display/ExitUploadTypes.java b/app/src/main/runtime/display/ExitUploadTypes.java new file mode 100644 index 000000000..43f3d8f4e --- /dev/null +++ b/app/src/main/runtime/display/ExitUploadTypes.java @@ -0,0 +1,28 @@ +package com.winlator.cmod.runtime.display; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +// Exit cloud-sync callback/result types split out of XServerDisplayActivity. +interface ExitUploadAction { + void start(ExitUploadCallback callback); +} + +interface ExitUploadCallback { + void onComplete(ExitUploadResult result); +} + +interface ExitUploadBlockingAction { + ExitUploadResult run() throws Exception; +} + +final class ExitUploadResult { + final boolean success; + @NonNull final String message; + final boolean retryable; + + ExitUploadResult(boolean success, @Nullable String message, boolean retryable) { + this.success = success; + this.message = message == null ? "" : message; + this.retryable = retryable; + } +} diff --git a/app/src/main/runtime/display/SteamExecutableInfo.java b/app/src/main/runtime/display/SteamExecutableInfo.java new file mode 100644 index 000000000..2db2eda75 --- /dev/null +++ b/app/src/main/runtime/display/SteamExecutableInfo.java @@ -0,0 +1,13 @@ +package com.winlator.cmod.runtime.display; +import java.io.File; + +// Steam executable descriptor split out of XServerDisplayActivity. +class SteamExecutableInfo { + final String relativePath; + final File file; + + SteamExecutableInfo(String relativePath, File file) { + this.relativePath = relativePath; + this.file = file; + } +} diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c8508b6f3..932022ebf 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -191,6 +191,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import cn.sherlock.com.sun.media.sound.SF2Soundbank; +import static com.winlator.cmod.runtime.display.XServerDisplayUtils.*; public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private static final long STEAM_TERMINATION_GRACE_MS = 10000L; @@ -3121,30 +3122,6 @@ private void syncStoreCloudOnExit(Runnable onComplete) { onComplete.run(); } - private interface ExitUploadAction { - void start(ExitUploadCallback callback); - } - - private interface ExitUploadCallback { - void onComplete(ExitUploadResult result); - } - - private interface ExitUploadBlockingAction { - ExitUploadResult run() throws Exception; - } - - private static final class ExitUploadResult { - final boolean success; - @NonNull final String message; - final boolean retryable; - - ExitUploadResult(boolean success, @Nullable String message, boolean retryable) { - this.success = success; - this.message = message == null ? "" : message; - this.retryable = retryable; - } - } - private void runExitUploadWithRetries( String uploadName, String retryStatusMessage, @@ -3564,32 +3541,6 @@ private void resetWnLauncherLog(File launcherLog) { } } - // Builds a WINEDEBUG value enabling only the chosen message classes on the - // chosen channels. "-all" first zeroes every class so unchosen ones (notably - // trace) stay off, then each "class+channel" turns one class on. - private static String buildWineDebug(String classesCsv, String channelsCsv) { - java.util.List classes = splitCsv(classesCsv); - java.util.List channels = splitCsv(channelsCsv); - if (classes.isEmpty() || channels.isEmpty()) return "-all"; - StringBuilder sb = new StringBuilder("-all"); - for (String channel : channels) { - for (String cls : classes) { - sb.append(',').append(cls).append('+').append(channel); - } - } - return sb.toString(); - } - - private static java.util.List splitCsv(String value) { - java.util.List out = new java.util.ArrayList<>(); - if (value == null) return out; - for (String part : value.split(",")) { - String token = part.trim(); - if (!token.isEmpty()) out.add(token); - } - return out; - } - private void scrubPlanWBridgeFilesForNextSession() { if (container == null) return; try { @@ -3737,25 +3688,6 @@ private void signalPlanWLauncherCleanShutdown(String trigger) { } catch (Exception ignored) {} } - private static boolean isSteamExeRunning() { - for (String detail : ProcessHelper.listRunningWineProcessDetails()) { - if (detail.toLowerCase().contains("steam.exe")) return true; - } - return false; - } - - private static boolean wnLauncherLogContains(File log, String marker) { - if (log == null || !log.isFile()) return false; - try (java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(new java.io.FileInputStream(log)))) { - String line; - while ((line = reader.readLine()) != null) { - if (line.contains(marker)) return true; - } - } catch (Exception ignored) {} - return false; - } - @Override protected void onDestroy() { activityDestroyed.set(true); @@ -5050,14 +4982,6 @@ private void pushTaskManagerSystemStats() { memDetail)); } - private static int clampSGSRUpscaleMode(int mode) { - return SGSRResolutionUtils.clampUpscaleMode(mode); - } - - private static int normalizeSGSRShortcutUpscaleMode(int mode) { - return SGSRResolutionUtils.normalizeShortcutUpscaleMode(mode); - } - private void saveSGSRShortcutSettings() { if (shortcut != null) { if (sgsrEnabled) { @@ -5303,10 +5227,6 @@ private void saveScreenEffectsSettings() { } } - private static float clampHudAlpha(float v) { - return Math.max(0.1f, Math.min(1.0f, v)); - } - private void loadHUDSettings() { if (container == null) return; String json = container.getExtra("hudSettings"); @@ -5542,16 +5462,6 @@ private java.util.List recordResolutionLabels(int nativeShort) { return out; } - private static String resTierLabel(int shortSide) { - switch (shortSide) { - case 2160: return "4K"; - case 1440: return "2K"; - case 1080: return "1080p"; - case 720: return "720p"; - default: return shortSide + "p"; - } - } - // Build the popup config with persisted selections mapped to current indices. private RecordUiConfig buildRecordConfig() { java.util.List fps = recordFpsOptions(); @@ -5722,28 +5632,6 @@ private void stopRecordUiCapture() { recordUiBuffer = null; } - private static int tierShortForLabel(String label) { - switch (label) { - case "4K": return 2160; - case "2K": return 1440; - case "1080p": return 1080; - case "720p": return 720; - default: return 0; - } - } - - // Quality preset → bits-per-pixel·frame, then bitrate, clamped to a sane window. - private static int recordBitrate(int w, int h, int fps, int quality) { - double bpp; - switch (quality) { - case 0: bpp = 0.035; break; // Performance - case 1: bpp = 0.075; break; // Balance - default: bpp = 0.15; break; // Quality - } - long bps = (long) (w * (long) h * fps * bpp); - return (int) Math.max(2_000_000L, Math.min(bps, 80_000_000L)); - } - private void stopScreenRecording() { if (screenRecorder == null) return; stopRecordUiCapture(); @@ -6436,16 +6324,6 @@ private String pickNewestInstalledContentVersion(ContentProfile.ContentType type return best != null ? contentVersionIdentifier(best) : ""; } - private static String contentVersionIdentifier(ContentProfile profile) { - String entryName = ContentsManager.getEntryName(profile); - int firstDash = entryName.indexOf('-'); - return firstDash >= 0 ? entryName.substring(firstDash + 1) : entryName; - } - - private static boolean safeEquals(String a, String b) { - return a != null && a.equals(b); - } - private void setupXEnvironment() throws PackageManager.NameNotFoundException { // Never reattach for a dependency install: the boot exe must run in a fresh // environment, and a stale activeEnvironment from a just-closed session would @@ -8309,26 +8187,6 @@ private ContentProfile findD7vkProfileForDdrawrapper(String ddrawrapper) { return null; } - private static String findDelimitedWrapper(String value, String prefix) { - if (value == null) return null; - for (String part : value.split(";")) { - if (part.startsWith(prefix)) return part; - } - return null; - } - - private static boolean hasSelectedVkd3dVersion(String version) { - return version != null && !version.isEmpty() && !version.equalsIgnoreCase("None"); - } - - private static boolean hasSelectedDxvkWrapper(String dxvkWrapper) { - if (dxvkWrapper == null) return false; - String version = dxvkWrapper.startsWith("dxvk-") - ? dxvkWrapper.substring("dxvk-".length()) - : dxvkWrapper; - return !version.trim().isEmpty() && !version.equalsIgnoreCase("None"); - } - private void extractD8VKIfNeeded(String dxvkWrapper, File windowsDir) { if (compareVersion(dxvkWrapper, "2.4") >= 0) return; @@ -8342,49 +8200,6 @@ private void extractD8VKIfNeeded(String dxvkWrapper, File windowsDir) { ); } - private static int compareVersion(String varA, String varB) { - int[] a = parseSemverLoose(varA); - int[] b = parseSemverLoose(varB); - - if (a[0] != b[0]) return a[0] - b[0]; - if (a[1] != b[1]) return a[1] - b[1]; - return a[2] - b[2]; - } - - private static final Pattern SEMVER_LOOSE = - Pattern.compile("(\\d+)\\.(\\d+)(?:\\.(\\d+))?"); - - private static int[] parseSemverLoose(String s) { - if (s == null) return new int[]{0, 0, 0}; - - Matcher m = SEMVER_LOOSE.matcher(s); - - String g1 = null, g2 = null, g3 = null; - while (m.find()) { - g1 = m.group(1); - g2 = m.group(2); - g3 = m.group(3); - } - - if (g1 == null || g2 == null) { - return new int[]{0, 0, 0}; - } - - int major = safeParseInt(g1); - int minor = safeParseInt(g2); - int patch = safeParseInt(g3); - return new int[]{major, minor, patch}; - } - - private static int safeParseInt(String s) { - if (s == null || s.isEmpty()) return 0; - try { - return Integer.parseInt(s); - } catch (NumberFormatException ignored) { - return 0; - } - } - private String getWineStartCommand(GuestProgramLauncherComponent launcherComponent) { EnvVars envVars = getOverrideEnvVars(); String args = ""; @@ -8987,15 +8802,6 @@ private boolean isUserOverriddenSteamExe(int appId, String resolvedRelativeExe) && !resolved.equalsIgnoreCase(configured); } - /** Base file name of a Windows/Unix exe path (handles both '\\' and '/' separators). */ - private static String exeBaseName(String path) { - if (path == null) return ""; - String normalized = path.replace('\\', '/'); - int slash = normalized.lastIndexOf('/'); - if (slash >= 0) normalized = normalized.substring(slash + 1); - return normalized.trim(); - } - private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -9638,16 +9444,6 @@ private String buildSteamUnpackSignature(SteamExecutableInfo executableInfo) { + executableInfo.file.lastModified() + "\n"; } - private static class SteamExecutableInfo { - final String relativePath; - final File file; - - SteamExecutableInfo(String relativePath, File file) { - this.relativePath = relativePath; - this.file = file; - } - } - private void ensureUnpackedExeActive() { if (shortcut == null || !"STEAM".equals(shortcut.getExtra("game_source"))) return; try { diff --git a/app/src/main/runtime/display/XServerDisplayUtils.java b/app/src/main/runtime/display/XServerDisplayUtils.java new file mode 100644 index 000000000..1edc335ed --- /dev/null +++ b/app/src/main/runtime/display/XServerDisplayUtils.java @@ -0,0 +1,183 @@ +package com.winlator.cmod.runtime.display; +import com.winlator.cmod.runtime.content.ContentProfile; +import com.winlator.cmod.runtime.content.ContentsManager; +import com.winlator.cmod.runtime.system.ProcessHelper; +import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// Pure stateless helpers split out of XServerDisplayActivity (behavior-identical). +final class XServerDisplayUtils { + private XServerDisplayUtils() {} + + // Builds a WINEDEBUG value enabling only the chosen message classes on the + // chosen channels. "-all" first zeroes every class so unchosen ones (notably + // trace) stay off, then each "class+channel" turns one class on. + static String buildWineDebug(String classesCsv, String channelsCsv) { + java.util.List classes = splitCsv(classesCsv); + java.util.List channels = splitCsv(channelsCsv); + if (classes.isEmpty() || channels.isEmpty()) return "-all"; + StringBuilder sb = new StringBuilder("-all"); + for (String channel : channels) { + for (String cls : classes) { + sb.append(',').append(cls).append('+').append(channel); + } + } + return sb.toString(); + } + + static java.util.List splitCsv(String value) { + java.util.List out = new java.util.ArrayList<>(); + if (value == null) return out; + for (String part : value.split(",")) { + String token = part.trim(); + if (!token.isEmpty()) out.add(token); + } + return out; + } + + static boolean isSteamExeRunning() { + for (String detail : ProcessHelper.listRunningWineProcessDetails()) { + if (detail.toLowerCase().contains("steam.exe")) return true; + } + return false; + } + + static boolean wnLauncherLogContains(File log, String marker) { + if (log == null || !log.isFile()) return false; + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(new java.io.FileInputStream(log)))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(marker)) return true; + } + } catch (Exception ignored) {} + return false; + } + + static int clampSGSRUpscaleMode(int mode) { + return SGSRResolutionUtils.clampUpscaleMode(mode); + } + + static int normalizeSGSRShortcutUpscaleMode(int mode) { + return SGSRResolutionUtils.normalizeShortcutUpscaleMode(mode); + } + + static float clampHudAlpha(float v) { + return Math.max(0.1f, Math.min(1.0f, v)); + } + + static String resTierLabel(int shortSide) { + switch (shortSide) { + case 2160: return "4K"; + case 1440: return "2K"; + case 1080: return "1080p"; + case 720: return "720p"; + default: return shortSide + "p"; + } + } + + static int tierShortForLabel(String label) { + switch (label) { + case "4K": return 2160; + case "2K": return 1440; + case "1080p": return 1080; + case "720p": return 720; + default: return 0; + } + } + + // Quality preset → bits-per-pixel·frame, then bitrate, clamped to a sane window. + static int recordBitrate(int w, int h, int fps, int quality) { + double bpp; + switch (quality) { + case 0: bpp = 0.035; break; // Performance + case 1: bpp = 0.075; break; // Balance + default: bpp = 0.15; break; // Quality + } + long bps = (long) (w * (long) h * fps * bpp); + return (int) Math.max(2_000_000L, Math.min(bps, 80_000_000L)); + } + + static String contentVersionIdentifier(ContentProfile profile) { + String entryName = ContentsManager.getEntryName(profile); + int firstDash = entryName.indexOf('-'); + return firstDash >= 0 ? entryName.substring(firstDash + 1) : entryName; + } + + static boolean safeEquals(String a, String b) { + return a != null && a.equals(b); + } + + static String findDelimitedWrapper(String value, String prefix) { + if (value == null) return null; + for (String part : value.split(";")) { + if (part.startsWith(prefix)) return part; + } + return null; + } + + static boolean hasSelectedVkd3dVersion(String version) { + return version != null && !version.isEmpty() && !version.equalsIgnoreCase("None"); + } + + static boolean hasSelectedDxvkWrapper(String dxvkWrapper) { + if (dxvkWrapper == null) return false; + String version = dxvkWrapper.startsWith("dxvk-") + ? dxvkWrapper.substring("dxvk-".length()) + : dxvkWrapper; + return !version.trim().isEmpty() && !version.equalsIgnoreCase("None"); + } + + static int compareVersion(String varA, String varB) { + int[] a = parseSemverLoose(varA); + int[] b = parseSemverLoose(varB); + + if (a[0] != b[0]) return a[0] - b[0]; + if (a[1] != b[1]) return a[1] - b[1]; + return a[2] - b[2]; + } + + static final Pattern SEMVER_LOOSE = + Pattern.compile("(\\d+)\\.(\\d+)(?:\\.(\\d+))?"); + + static int[] parseSemverLoose(String s) { + if (s == null) return new int[]{0, 0, 0}; + + Matcher m = SEMVER_LOOSE.matcher(s); + + String g1 = null, g2 = null, g3 = null; + while (m.find()) { + g1 = m.group(1); + g2 = m.group(2); + g3 = m.group(3); + } + + if (g1 == null || g2 == null) { + return new int[]{0, 0, 0}; + } + + int major = safeParseInt(g1); + int minor = safeParseInt(g2); + int patch = safeParseInt(g3); + return new int[]{major, minor, patch}; + } + + static int safeParseInt(String s) { + if (s == null || s.isEmpty()) return 0; + try { + return Integer.parseInt(s); + } catch (NumberFormatException ignored) { + return 0; + } + } + + /** Base file name of a Windows/Unix exe path (handles both '\\' and '/' separators). */ + static String exeBaseName(String path) { + if (path == null) return ""; + String normalized = path.replace('\\', '/'); + int slash = normalized.lastIndexOf('/'); + if (slash >= 0) normalized = normalized.substring(slash + 1); + return normalized.trim(); + } +} From 6648fd6fa699f40687fd0347cf918f4950b6f331 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Tue, 14 Jul 2026 22:58:24 +0000 Subject: [PATCH 4/8] Split XServerDrawerMenu.kt pane content into per-pane files (behavior-neutral, 6.2k->2.7k lines) --- .../display/XServerDrawerControlsPane.kt | 714 ++++ .../display/XServerDrawerEffectsPane.kt | 466 +++ .../runtime/display/XServerDrawerHudPane.kt | 531 +++ .../display/XServerDrawerInputPanes.kt | 522 +++ .../runtime/display/XServerDrawerLogsPane.kt | 387 ++ .../main/runtime/display/XServerDrawerMenu.kt | 3600 +---------------- .../display/XServerDrawerOutputPane.kt | 550 +++ .../display/XServerDrawerRecordDialogs.kt | 455 +++ .../display/XServerDrawerTaskManagerPane.kt | 1363 +++++++ 9 files changed, 5042 insertions(+), 3546 deletions(-) create mode 100644 app/src/main/runtime/display/XServerDrawerControlsPane.kt create mode 100644 app/src/main/runtime/display/XServerDrawerEffectsPane.kt create mode 100644 app/src/main/runtime/display/XServerDrawerHudPane.kt create mode 100644 app/src/main/runtime/display/XServerDrawerInputPanes.kt create mode 100644 app/src/main/runtime/display/XServerDrawerLogsPane.kt create mode 100644 app/src/main/runtime/display/XServerDrawerOutputPane.kt create mode 100644 app/src/main/runtime/display/XServerDrawerRecordDialogs.kt create mode 100644 app/src/main/runtime/display/XServerDrawerTaskManagerPane.kt diff --git a/app/src/main/runtime/display/XServerDrawerControlsPane.kt b/app/src/main/runtime/display/XServerDrawerControlsPane.kt new file mode 100644 index 000000000..5c2cc730d --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerControlsPane.kt @@ -0,0 +1,714 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Input-controls pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun InputControlsPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight, ControlsPaneScaleMin) + val scrollState = rememberScrollState() + val gcmEnabled = state.inputControlsGcmRumbleMode != "disabled" + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding( + start = (12f * paneScale).dp, + end = (12f * paneScale).dp, + top = (4f * paneScale).dp, + bottom = (12f * paneScale).dp, + ), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.input_controls_editor_select_profile)) + InputControlsProfileSelector( + profileNames = state.inputControlsProfileNames, + selectedIndex = state.inputControlsSelectedProfileIndex, + onProfileSelected = listener::onInputControlsProfileSelected, + onEditClick = listener::onInputControlsEditClick, + ) + } + + if (state.inputControlsStyleNames.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.input_controls_select_style)) + InputControlsSimpleDropdown( + options = state.inputControlsStyleNames, + selectedIndex = state.inputControlsSelectedStyleIndex, + onSelected = listener::onInputControlsStyleSelected, + ) + } + } + + if (state.inputControlsLabelThemeNames.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.input_controls_select_label_theme)) + InputControlsSimpleDropdown( + options = state.inputControlsLabelThemeNames, + selectedIndex = state.inputControlsSelectedLabelThemeIndex, + onSelected = listener::onInputControlsLabelThemeSelected, + ) + } + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_show_touchscreen_controls), + checked = state.inputControlsShowOverlay, + onCheckedChange = listener::onInputControlsShowOverlayChanged, + ) + + if (state.inputControlsShowOverlay) { + NavSliderRow( + label = stringResource(R.string.input_controls_editor_overlay_opacity), + valueText = "${(state.inputControlsOverlayOpacity * 100).toInt()}%", + value = state.inputControlsOverlayOpacity, + valueRange = 0.1f..1.0f, + steps = 8, + onValueChange = listener::onInputControlsOverlayOpacityChanged, + ) + Spacer(Modifier.height(4.dp)) + + NavBooleanRow( + title = stringResource(R.string.input_controls_tap_to_click), + checked = state.inputControlsTapToClick, + onCheckedChange = listener::onInputControlsTapToClickChanged, + ) + } + + NavBooleanRow( + title = stringResource(R.string.settings_general_touchscreen_haptics), + checked = state.inputControlsTouchscreenHaptics, + onCheckedChange = listener::onInputControlsTouchscreenHapticsChanged, + ) + + NavBooleanRow( + title = stringResource(R.string.session_gamepad_enable_vibration), + checked = state.inputControlsGamepadVibration, + onCheckedChange = listener::onInputControlsGamepadVibrationChanged, + ) + + NavSliderRow( + label = "Mouse sensitivity scale", + valueText = "${Math.round(state.cursorSpeed * 100)}%", + value = state.cursorSpeed * 100f, + valueRange = 10f..300f, + steps = 0, + onValueChange = { listener.onCursorSpeedChanged(it / 100f) }, + adjustStep = 5f, + ) + + val rsMapMode = state.screenTouchMode == 2 + val rsValue = if (rsMapMode) state.screenTouchRsSensitivity else state.rightStickSensitivity + NavSliderRow( + label = stringResource(R.string.session_drawer_right_stick_sensitivity), + valueText = "${Math.round(rsValue * 100)}%", + value = rsValue * 100f, + valueRange = (if (rsMapMode) 25f else 10f)..200f, + steps = 0, + onValueChange = { listener.onRightStickSensitivityChanged(it / 100f) }, + adjustStep = 5f, + ) + + LaunchedEffect(gcmEnabled) { + if (gcmEnabled) scrollState.animateScrollTo(Int.MAX_VALUE) + } + + NavBooleanRow( + title = "GameSir Controller Rumble", + subtitle = "For Android-mode GameSir controllers only", + checked = gcmEnabled, + onCheckedChange = { enabled -> + listener.onInputControlsGcmRumbleModeChanged(if (enabled) "known" else "disabled") + }, + ) + + if (gcmEnabled) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + HUDToggleChip( + label = "Known", + checked = state.inputControlsGcmRumbleMode == "known", + onClick = { listener.onInputControlsGcmRumbleModeChanged("known") }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onInputControlsGcmRumbleModeChanged("known") }, + ), + ) + HUDToggleChip( + label = "All (experimental)", + checked = state.inputControlsGcmRumbleMode == "all", + onClick = { listener.onInputControlsGcmRumbleModeChanged("all") }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onInputControlsGcmRumbleModeChanged("all") }, + ), + ) + } + Text( + text = if (state.inputControlsGcmRumbleMode == "all") + "All GameSir devices" + else + "G8+ MFi, X5s, X3 Pro", + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + ) + } + } + } + } + } +} + +/** Compact dropdown for the Controls Style/Label-Theme rows; like [InputControlsProfileSelector] but without the edit button (built-in, non-editable choices). */ +@Composable +internal fun InputControlsSimpleDropdown( + options: List, + selectedIndex: Int, + onSelected: (Int) -> Unit, +) { + val paneScale = LocalPaneScale.current + var expanded by remember { mutableStateOf(false) } + val selectedText = options.getOrElse(selectedIndex) { options.firstOrNull() ?: "" } + val parentNav = LocalPaneNav.current + val optionRegistry = remember { PaneNavRegistry() } + LaunchedEffect(expanded) { + if (expanded) { + optionRegistry.reset() + optionRegistry.controllerActive = true + parentNav?.overlay = optionRegistry + parentNav?.overlayClose = { expanded = false } + } else if (parentNav?.overlay === optionRegistry) { + parentNav.overlay = null + parentNav.overlayClose = null + } + } + + val cornerRadius = (14f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(140), + label = "inputControlsSimpleBg", + ) + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem( + cornerRadius = cornerRadius, + onActivate = { expanded = true }, + onAdjust = { dir -> + if (options.isNotEmpty()) { + onSelected(((selectedIndex + dir) % options.size + options.size) % options.size) + } + }, + ) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { expanded = true } + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedText, + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = DrawerTextSecondary, + modifier = Modifier.size((22f * paneScale).dp), + ) + } + + InputControlsOptionsPopup( + expanded = expanded, + options = options, + selectedIndex = selectedIndex, + onSelected = onSelected, + onDismiss = { expanded = false }, + optionRegistry = optionRegistry, + ) + } +} + +@Composable +internal fun InputControlsProfileSelector( + profileNames: List, + selectedIndex: Int, + onProfileSelected: (Int) -> Unit, + onEditClick: () -> Unit, +) { + val paneScale = LocalPaneScale.current + var expanded by remember { mutableStateOf(false) } + val disabledPlaceholder = stringResource(R.string.common_ui_disabled_placeholder) + val selectedText = profileNames.getOrElse(selectedIndex) { disabledPlaceholder } + val parentNav = LocalPaneNav.current + val optionRegistry = remember { PaneNavRegistry() } + LaunchedEffect(expanded) { + if (expanded) { + optionRegistry.reset() + optionRegistry.controllerActive = true + parentNav?.overlay = optionRegistry + parentNav?.overlayClose = { expanded = false } + } else if (parentNav?.overlay === optionRegistry) { + parentNav.overlay = null + parentNav.overlayClose = null + } + } + + val cornerRadius = (14f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(140), + label = "inputControlsProfileBg", + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = cornerRadius, onActivate = { expanded = true }) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { expanded = true } + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedText, + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = DrawerTextSecondary, + modifier = Modifier.size((22f * paneScale).dp), + ) + } + + InputControlsOptionsPopup( + expanded = expanded, + options = profileNames, + selectedIndex = selectedIndex, + onSelected = onProfileSelected, + onDismiss = { expanded = false }, + optionRegistry = optionRegistry, + ) + } + + Box( + modifier = + Modifier + .size((44f * paneScale).dp) + .clip(shape) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = cornerRadius, onActivate = onEditClick) + .clickable(onClick = onEditClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.common_ui_settings), + tint = DrawerTextPrimary, + modifier = Modifier.size((20f * paneScale).dp), + ) + } + } +} + +// Drawer-styled dropdown for the Controls selectors. +@Composable +internal fun InputControlsOptionsPopup( + expanded: Boolean, + options: List, + selectedIndex: Int, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, + optionRegistry: PaneNavRegistry, +) { + if (!expanded) return + val paneScale = LocalPaneScale.current + val density = LocalDensity.current + val gapPx = with(density) { (4f * paneScale).dp.roundToPx() } + val shape = RoundedCornerShape((12f * paneScale).dp) + val scrollState = rememberScrollState() + Popup( + popupPositionProvider = remember(gapPx) { TaskManagerPopupPositionProvider(gapPx) }, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = false), + ) { + CompositionLocalProvider(LocalPaneNav provides optionRegistry) { + Column( + modifier = + Modifier + .widthIn(min = (160f * paneScale).dp, max = (280f * paneScale).dp) + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .heightIn(max = (260f * paneScale).dp) + .verticalScroll(scrollState) + .padding((5f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + ) { + options.forEachIndexed { index, name -> + InputControlsOptionItem( + label = name, + selected = index == selectedIndex, + onClick = { + onSelected(index) + onDismiss() + }, + ) + } + } + } + } +} + +@Composable +internal fun InputControlsOptionItem( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) DrawerAccent.copy(alpha = 0.16f) else PaneInnerResting, + animationSpec = tween(120), + label = "inputControlsOptionItem", + ) + val shape = RoundedCornerShape((8f * paneScale).dp) + Row( + modifier = + modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, if (selected) ActiveCardBorder else RestingCardBorder, shape) + .paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onClick) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + Text( + text = label, + color = if (selected) DrawerAccent else DrawerTextPrimary, + fontSize = (13f * paneScale).sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (selected) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size((16f * paneScale).dp), + ) + } + } +} + +@Composable +internal fun ExpandableSection( + title: String, + expanded: Boolean, + onToggle: () -> Unit, + content: @Composable () -> Unit, +) { + val paneScale = LocalPaneScale.current + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "expandableRotation", + ) + val headerInteractionSource = remember { MutableInteractionSource() } + val headerPressed = headerInteractionSource.collectIsPressedAsState().value + val headerBg by animateColorAsState( + targetValue = + when { + headerPressed -> PaneInnerPressed + else -> PaneInnerResting + }, + animationSpec = tween(140), + label = "expandableHeaderBg", + ) + val headerBorder by animateColorAsState( + targetValue = if (expanded) DrawerAccent else RestingCardBorder, + animationSpec = tween(140), + label = "expandableHeaderBorder", + ) + val headerShape = RoundedCornerShape((12f * paneScale).dp) + Column(verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(headerShape) + .background(headerBg) + .border(1.dp, headerBorder, headerShape) + .paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = onToggle) + .clickable( + interactionSource = headerInteractionSource, + indication = null, + onClick = onToggle, + ) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + color = if (expanded) DrawerAccent else DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.3.sp, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + tint = if (expanded) DrawerAccent else DrawerTextSecondary, + modifier = + Modifier + .size((18f * paneScale).dp) + .graphicsLayer { rotationZ = rotation }, + ) + } + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy((12f * paneScale).dp)) { + content() + } + } + } +} diff --git a/app/src/main/runtime/display/XServerDrawerEffectsPane.kt b/app/src/main/runtime/display/XServerDrawerEffectsPane.kt new file mode 100644 index 000000000..fda9ebd6a --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerEffectsPane.kt @@ -0,0 +1,466 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Screen-effects pane composable, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun ScreenEffectsPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.shortcuts_graphics_sgsr_full_title)) + NavBooleanRow( + title = stringResource(R.string.session_drawer_upscaler_fsr), + checked = state.sgsrEnabled, + onCheckedChange = listener::onSGSREnabledChanged, + ) + + AnimatedVisibility( + visible = state.sgsrEnabled, + enter = + expandVertically( + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(durationMillis = 160, easing = FastOutSlowInEasing)), + exit = + shrinkVertically( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)), + ) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + NavSliderRow( + label = stringResource(R.string.session_drawer_sgsr_edge_sharpness), + valueText = "${state.sgsrSharpness}%", + value = state.sgsrSharpness.toFloat(), + valueRange = 0f..100f, + steps = 99, + onValueChange = { listener.onSGSRSharpnessChanged(it.roundToInt().coerceIn(0, 100)) }, + ) + } + } + } + + ThinDivider() + + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_color_profile)) + + val profiles = + listOf( + 0 to stringResource(R.string.session_drawer_color_profile_disabled), + 1 to stringResource(R.string.session_drawer_color_profile_hdr), + 2 to stringResource(R.string.session_drawer_color_profile_natural), + 4 to stringResource(R.string.session_drawer_color_profile_toon), + 3 to stringResource(R.string.session_drawer_color_profile_crt), + 5 to stringResource(R.string.session_drawer_color_profile_ntsc), + 6 to stringResource(R.string.session_drawer_color_profile_ntsc2), + ) + + ChipFlow { + profiles.forEach { (id, label) -> + HUDToggleChip( + label = label, + checked = state.colorProfile == id, + onClick = { listener.onColorProfileSelected(id) }, + modifier = Modifier.paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onColorProfileSelected(id) }, + ), + ) + } + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_vivid), + checked = state.vividEnabled, + onCheckedChange = listener::onVividEnabledChanged, + ) + + if (state.vividEnabled) { + NavSliderRow( + label = stringResource(R.string.session_drawer_vivid_strength), + valueText = "${state.vividStrength}%", + value = state.vividStrength.toFloat(), + valueRange = 0f..100f, + steps = 99, + onValueChange = { listener.onVividStrengthChanged(it.roundToInt()) }, + ) + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_sharpen), + checked = state.sharpenEnabled, + onCheckedChange = listener::onSharpenEnabledChanged, + ) + + if (state.sharpenEnabled) { + NavSliderRow( + label = stringResource(R.string.session_drawer_strength), + valueText = "${state.sharpenStrength}%", + value = state.sharpenStrength.toFloat(), + valueRange = 0f..100f, + steps = 99, + onValueChange = { listener.onSharpenStrengthChanged(it.roundToInt().coerceIn(0, 100)) }, + ) + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_scanlines), + checked = state.scanlinesEnabled, + onCheckedChange = listener::onScanlinesEnabledChanged, + ) + + if (state.scanlinesEnabled) { + NavSliderRow( + label = stringResource(R.string.session_drawer_intensity), + valueText = "${state.scanlinesIntensity}%", + value = state.scanlinesIntensity.toFloat(), + valueRange = 0f..100f, + steps = 99, + onValueChange = { listener.onScanlinesIntensityChanged(it.roundToInt().coerceIn(0, 100)) }, + ) + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_pixelate), + checked = state.pixelateEnabled, + onCheckedChange = listener::onPixelateEnabledChanged, + ) + + if (state.pixelateEnabled) { + NavSliderRow( + label = stringResource(R.string.session_drawer_block_size), + valueText = "${state.pixelateBlock}px", + value = state.pixelateBlock.toFloat(), + valueRange = 2f..14f, + steps = 11, + onValueChange = { listener.onPixelateBlockChanged(it.roundToInt().coerceIn(2, 14)) }, + ) + } + + NavSliderRow( + label = stringResource(R.string.session_drawer_brightness), + valueText = "${state.brightness}", + value = state.brightness.toFloat(), + valueRange = -100f..100f, + steps = 39, + onValueChange = { listener.onBrightnessChanged(it.roundToInt().coerceIn(-100, 100)) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_drawer_contrast), + valueText = "${state.contrast}", + value = state.contrast.toFloat(), + valueRange = -100f..100f, + steps = 39, + onValueChange = { listener.onContrastChanged(it.roundToInt().coerceIn(-100, 100)) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_drawer_gamma), + valueText = String.format("%.2fx", state.gammaPercent / 100f), + value = state.gammaPercent.toFloat(), + valueRange = 50f..250f, + steps = 19, + onValueChange = { listener.onGammaChanged(it.roundToInt().coerceIn(50, 250)) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_drawer_saturation), + valueText = "${state.saturation}%", + value = state.saturation.toFloat(), + valueRange = 0f..200f, + steps = 39, + onValueChange = { listener.onSaturationChanged(it.roundToInt().coerceIn(0, 200)) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_drawer_temperature), + valueText = "${state.temperature}", + value = state.temperature.toFloat(), + valueRange = -100f..100f, + steps = 39, + onValueChange = { listener.onTemperatureChanged(it.roundToInt().coerceIn(-100, 100)) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_drawer_tint), + valueText = "${state.tint}", + value = state.tint.toFloat(), + valueRange = -100f..100f, + steps = 39, + onValueChange = { listener.onTintChanged(it.roundToInt().coerceIn(-100, 100)) }, + ) + + Box( + Modifier.fillMaxWidth().paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onResetEffects() }, + ), + ) { + DrawerResetRow( + label = stringResource(R.string.session_drawer_reset_effects), + onClick = listener::onResetEffects, + ) + } + } + + ThinDivider() + + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_color_blind)) + + Row(horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + HUDToggleChip( + label = stringResource(R.string.session_drawer_color_blind_protan), + checked = state.colorBlind == 1, + onClick = { listener.onColorBlindSelected(if (state.colorBlind == 1) 0 else 1) }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 1) 0 else 1) }, + ), + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_color_blind_deutan), + checked = state.colorBlind == 2, + onClick = { listener.onColorBlindSelected(if (state.colorBlind == 2) 0 else 2) }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 2) 0 else 2) }, + ), + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_color_blind_tritan), + checked = state.colorBlind == 3, + onClick = { listener.onColorBlindSelected(if (state.colorBlind == 3) 0 else 3) }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 3) 0 else 3) }, + ), + ) + } + } + + ThinDivider() + + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_scale)) + + NavBooleanRow( + title = stringResource(R.string.session_drawer_scale_nearest), + checked = state.scaleFilter == 1, + onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 1 else 0) }, + ) + + NavBooleanRow( + title = stringResource(R.string.session_drawer_scale_linear), + checked = state.scaleFilter == 2, + onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 2 else 0) }, + ) + + NavBooleanRow( + title = stringResource(R.string.session_drawer_scale_bicubic), + checked = state.scaleFilter == 3, + onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 3 else 0) }, + ) + } + } + } + } +} diff --git a/app/src/main/runtime/display/XServerDrawerHudPane.kt b/app/src/main/runtime/display/XServerDrawerHudPane.kt new file mode 100644 index 000000000..3e7266667 --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerHudPane.kt @@ -0,0 +1,531 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// HUD pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun HUDPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + var activeEditor by remember { mutableStateOf(null) } + var fpsLimitMemory by remember { + mutableStateOf(if (state.fpsLimit > 0) state.fpsLimit else FPS_LIMITER_DEFAULT) + } + LaunchedEffect(state.fpsLimit) { + if (state.fpsLimit > 0) fpsLimitMemory = state.fpsLimit + } + val elementNames = + listOf( + stringResource(R.string.session_drawer_hud_element_fps), + stringResource(R.string.session_drawer_hud_element_api), + stringResource(R.string.session_drawer_hud_element_gpu), + stringResource(R.string.session_drawer_hud_element_cpu), + stringResource(R.string.session_drawer_hud_element_ram), + stringResource(R.string.session_drawer_hud_element_battery), + stringResource(R.string.session_drawer_hud_element_temp), + stringResource(R.string.session_drawer_hud_element_graph), + stringResource(R.string.session_drawer_hud_element_cpu_temp), + ) + val elementOrder = listOf(1, 2, 3, 8, 4, 5, 6, 0, 7) + val active = + state.items.firstOrNull { it.itemId == R.id.main_menu_fps_monitor }?.active ?: false + + activeEditor?.let { editor -> + HUDMetricInputDialog( + editor = editor, + initialPercent = + when (editor) { + HUDMetricEditor.ALPHA -> (state.hudTransparency * 100).roundToInt() + HUDMetricEditor.BACKGROUND_ALPHA -> (state.hudBackgroundTransparency * 100).roundToInt() + HUDMetricEditor.SCALE -> (state.hudScale * 100).roundToInt() + }, + onDismiss = { activeEditor = null }, + onConfirm = { enteredPercent -> + activeEditor = null + when (editor) { + HUDMetricEditor.ALPHA -> { + listener.onHUDTransparencyChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) + } + HUDMetricEditor.BACKGROUND_ALPHA -> { + listener.onHUDBackgroundTransparencyChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) + } + HUDMetricEditor.SCALE -> { + listener.onHUDScaleChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) + } + } + }, + ) + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + NavEnableRow( + title = stringResource(R.string.session_drawer_fps_monitor), + checked = active, + onCheckedChange = { listener.onActionSelected(R.id.main_menu_fps_monitor) }, + ) + + if (active) { + NavSliderRow( + label = stringResource(R.string.session_drawer_hud_alpha), + valueText = "${(state.hudTransparency * 100).toInt()}%", + value = state.hudTransparency, + valueRange = 0.1f..1f, + steps = 17, + onValueClick = { activeEditor = HUDMetricEditor.ALPHA }, + onValueChange = { listener.onHUDTransparencyChanged(it.snapToStep(0.05f, 0.1f, 1f)) }, + ) + + if (state.hudBackgroundAlphaEnabled) { + NavSliderRow( + label = stringResource(R.string.session_drawer_hud_background), + valueText = "${(state.hudBackgroundTransparency * 100).toInt()}%", + value = state.hudBackgroundTransparency, + valueRange = 0.1f..1f, + steps = 17, + onValueClick = { activeEditor = HUDMetricEditor.BACKGROUND_ALPHA }, + onValueChange = { listener.onHUDBackgroundTransparencyChanged(it.snapToStep(0.05f, 0.1f, 1f)) }, + ) + } + + NavSliderRow( + label = stringResource(R.string.session_drawer_hud_scale), + valueText = "${Math.round(state.hudScale * 100)}%", + value = state.hudScale, + valueRange = 0.3f..2.0f, + steps = 33, + onValueClick = { activeEditor = HUDMetricEditor.SCALE }, + onValueChange = { listener.onHUDScaleChanged(it.snapToStep(0.05f, 0.3f, 2.0f)) }, + adjustStep = 0.05f, + ) + + NavBooleanRow( + title = stringResource(R.string.session_drawer_hud_background_alpha), + checked = state.hudBackgroundAlphaEnabled, + onCheckedChange = listener::onHUDBackgroundAlphaDecoupledChanged, + ) + + NavBooleanRow( + title = stringResource(R.string.session_drawer_hud_frametime_numeric), + checked = state.frametimeNumericEnabled, + onCheckedChange = listener::onFrametimeNumericChanged, + ) + + Box( + Modifier.fillMaxWidth().paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onFPSLimitChanged(if (state.fpsLimit > 0) 0 else fpsLimitMemory.coerceIn(FPS_LIMITER_MIN, state.maxRefreshRate)) }, + onAdjust = { dir -> + val base = if (state.fpsLimit > 0) state.fpsLimit else fpsLimitMemory + val q = base / 5.0 + val units = if (dir > 0) Math.floor(q + 1e-4) + 1 else Math.ceil(q - 1e-4) - 1 + listener.onFPSLimitChanged((units * 5).toInt().coerceIn(FPS_LIMITER_MIN, state.maxRefreshRate)) + }, + ), + ) { + FPSLimiterCard( + currentLimit = state.fpsLimit, + maxRefreshRate = state.maxRefreshRate, + onLimitChanged = listener::onFPSLimitChanged, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_hud_elements)) + ChipFlow { + elementOrder.forEach { index -> + HUDToggleChip( + label = elementNames[index], + checked = state.hudElements[index], + onClick = { listener.onHUDElementToggled(index, !state.hudElements[index]) }, + modifier = Modifier.paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onHUDElementToggled(index, !state.hudElements[index]) }, + ), + ) + } + } + } + + NavBooleanRow( + title = stringResource(R.string.session_drawer_dual_series_battery), + checked = state.dualSeriesBatteryEnabled, + onCheckedChange = listener::onDualSeriesBatteryChanged, + ) + } + } + } + } +} + +@Composable +internal fun HUDMetricInputDialog( + editor: HUDMetricEditor, + initialPercent: Int, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, +) { + var value by remember { mutableStateOf(initialPercent.toString()) } + val keyboardController = LocalSoftwareKeyboardController.current + + fun submit() { + val parsed = value.toIntOrNull() ?: initialPercent + onConfirm(parsed.coerceIn(editor.minPercent, editor.maxPercent)) + } + + val focusRequester = remember { FocusRequester() } + WinNativeDialogShell( + onDismiss = onDismiss, + title = + when (editor) { + HUDMetricEditor.ALPHA -> stringResource(R.string.session_drawer_hud_alpha_input_title) + HUDMetricEditor.BACKGROUND_ALPHA -> stringResource(R.string.session_drawer_hud_background_alpha_input_title) + HUDMetricEditor.SCALE -> stringResource(R.string.session_drawer_hud_scale_input_title) + }, + maxWidth = 380.dp, + ) { + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + Column( + modifier = + Modifier + .controllerMenuInput( + onDismiss = onDismiss, + onStart = { + keyboardController?.hide() + submit() + }, + ), + ) { + Text( + text = stringResource(R.string.session_drawer_hud_input_hint, editor.minPercent, editor.maxPercent), + color = DrawerTextSecondary, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + Spacer(Modifier.height(14.dp)) + OutlinedTextField( + value = value, + onValueChange = { incoming -> value = incoming.filter(Char::isDigit) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + suffix = { + Text( + text = "%", + color = DrawerTextSecondary, + fontSize = 13.sp, + ) + }, + textStyle = androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy(color = DrawerTextPrimary), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = DrawerAccent, + unfocusedBorderColor = DrawerOutline, + focusedTextColor = DrawerTextPrimary, + unfocusedTextColor = DrawerTextPrimary, + focusedContainerColor = DrawerBackground, + unfocusedContainerColor = DrawerBackground, + focusedLabelColor = DrawerTextSecondary, + unfocusedLabelColor = DrawerTextSecondary, + cursorColor = DrawerAccent, + ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + keyboardController?.hide() + submit() + }, + ), + ) + Spacer(Modifier.height(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(DrawerOutline), + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + DialogFocusButton( + label = stringResource(R.string.common_ui_cancel), + textColor = DrawerTextPrimary, + backgroundColor = PaneInnerResting, + borderColor = RestingCardBorder, + onClick = onDismiss, + ) + DialogFocusButton( + label = stringResource(R.string.common_ui_apply), + textColor = DrawerAccent, + backgroundColor = DrawerAccent.copy(alpha = 0.12f), + borderColor = DrawerAccent.copy(alpha = 0.3f), + focusRequester = focusRequester, + onClick = { + keyboardController?.hide() + submit() + }, + ) + } + } + } +} + +@Composable +internal fun HUDToggleChip( + label: String, + checked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = + when { + pressed -> PaneInnerPressed + else -> PaneInnerResting + }, + animationSpec = tween(140), + label = "hudChipBg", + ) + val borderColor by animateColorAsState( + targetValue = if (checked) DrawerAccent else RestingCardBorder, + animationSpec = tween(140), + label = "hudChipBorder", + ) + val cornerRadius = (12f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) + val indicatorSize = (10f * paneScale).dp + + Row( + modifier = + modifier + .clip(shape) + .background(bgColor) + .border(1.dp, borderColor, shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ).padding(horizontal = (10f * paneScale).dp, vertical = (9f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(indicatorSize) + .clip(CircleShape) + .background(if (checked) DrawerAccent else Color(0x14FFFFFF)), + ) + Spacer(Modifier.width((8f * paneScale).dp)) + Text( + text = label, + color = DrawerTextPrimary, + fontSize = (13f * paneScale).sp, + fontWeight = if (checked) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/runtime/display/XServerDrawerInputPanes.kt b/app/src/main/runtime/display/XServerDrawerInputPanes.kt new file mode 100644 index 000000000..8e991fc3e --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerInputPanes.kt @@ -0,0 +1,522 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Touch + gyroscope pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun TouchPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + onClose: () -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + Box( + modifier = + Modifier + .size((30f * paneScale).dp) + .clip(RoundedCornerShape((8f * paneScale).dp)) + .paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onClose) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClose, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.common_ui_back), + tint = DrawerTextSecondary, + modifier = Modifier.size((20f * paneScale).dp), + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onActionSelected(R.id.main_menu_disable_mouse) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_mouse_input), + checked = state.mouseEnabled, + onCheckedChange = { listener.onActionSelected(R.id.main_menu_disable_mouse) }, + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onActionSelected(R.id.main_menu_relative_mouse_movement) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_relative_mouse_movement), + checked = state.relativeMouseEnabled, + onCheckedChange = { listener.onActionSelected(R.id.main_menu_relative_mouse_movement) }, + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(0) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_touch_trackpad), + checked = state.screenTouchMode == 0 && !state.rtsGesturesEnabled, + onCheckedChange = { if (it) listener.onScreenTouchModeChanged(0) }, + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(1) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_touch_touchscreen), + checked = state.screenTouchMode == 1 && !state.rtsGesturesEnabled, + onCheckedChange = { listener.onScreenTouchModeChanged(if (it) 1 else 0) }, + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(2) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_touch_map_right_stick), + checked = state.screenTouchMode == 2 && !state.rtsGesturesEnabled, + onCheckedChange = { listener.onScreenTouchModeChanged(if (it) 2 else 0) }, + ) + } + Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onRtsGesturesToggled(!state.rtsGesturesEnabled) })) { + DrawerBooleanRow( + title = stringResource(R.string.session_drawer_rts_gestures), + checked = state.rtsGesturesEnabled, + onCheckedChange = { listener.onRtsGesturesToggled(it) }, + ) + } + if (state.rtsGesturesEnabled) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_gesture_profile_section)) + InputControlsProfileSelector( + profileNames = state.gestureProfileNames, + selectedIndex = state.gestureSelectedProfileIndex, + onProfileSelected = listener::onGestureProfileSelected, + onEditClick = listener::onRtsGesturesEditClick, + ) + } + } + } + } + } +} + +@Composable +internal fun GyroscopePaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + var calibrateExpanded by remember { mutableStateOf(false) } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + NavEnableRow( + title = stringResource(R.string.session_gyroscope_title), + checked = state.gyroscopeEnabled, + onCheckedChange = listener::onGyroscopeEnabledChanged, + ) + + if (state.gyroscopeEnabled) { + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_gyroscope_mode)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + listOf( + stringResource(R.string.session_gyroscope_hold), + stringResource(R.string.session_gyroscope_toggle), + ).forEachIndexed { index, label -> + HUDToggleChip( + label = label, + checked = state.gyroscopeModeIndex == index, + onClick = { listener.onGyroscopeModeSelected(index) }, + modifier = Modifier.weight(1f).paneNavItem( + cornerRadius = (16f * paneScale).dp, + onActivate = { listener.onGyroscopeModeSelected(index) }, + ), + ) + } + } + } + + NavBooleanRow( + title = stringResource(R.string.session_gyroscope_orientation_mode), + checked = state.gyroOrientationEnabled, + onCheckedChange = listener::onGyroOrientationModeChanged, + ) + + Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_gyroscope_activator_button)) + GyroscopeActivatorDropdown( + currentLabel = state.gyroscopeActivatorLabel, + onSelected = listener::onGyroscopeActivatorSelected, + ) + } + + NavBooleanRow( + title = stringResource(R.string.session_gyroscope_enable_right_stick), + checked = state.rightStickGyroEnabled, + onCheckedChange = listener::onRightStickGyroChanged, + ) + + NavBooleanRow( + title = stringResource(R.string.session_gyroscope_experimental_mouse_movement), + checked = state.gyroMouseEnabled, + onCheckedChange = listener::onGyroMouseEnabledChanged, + ) + + if (state.gyroMouseEnabled) { + NavSliderRow( + label = stringResource(R.string.session_gyroscope_mouse_scale), + valueText = "${state.gyroMouseScale.toInt()}%", + value = state.gyroMouseScale, + valueRange = 0f..200f, + steps = 199, + onValueChange = { listener.onGyroMouseScaleChanged(it.roundToInt().toFloat()) }, + ) + } + + ExpandableSection( + title = stringResource(R.string.session_drawer_calibrate_advanced), + expanded = calibrateExpanded, + onToggle = { calibrateExpanded = !calibrateExpanded }, + ) { + NavSliderRow( + label = stringResource(R.string.session_gyroscope_x_sensitivity), + valueText = "${(state.gyroXSensitivity * 100).roundToInt()}%", + value = state.gyroXSensitivity, + valueRange = 0.01f..3f, + steps = 0, + onValueChange = { listener.onGyroXSensitivityChanged(it) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_gyroscope_y_sensitivity), + valueText = "${(state.gyroYSensitivity * 100).roundToInt()}%", + value = state.gyroYSensitivity, + valueRange = 0.01f..3f, + steps = 0, + onValueChange = { listener.onGyroYSensitivityChanged(it) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_gyroscope_smoothing), + valueText = "${(state.gyroSmoothing * 100).toInt()}%", + value = state.gyroSmoothing, + valueRange = 0f..1f, + steps = 99, + onValueChange = { listener.onGyroSmoothingChanged(it) }, + ) + + NavSliderRow( + label = stringResource(R.string.session_gyroscope_deadzone), + valueText = "${(state.gyroDeadzone * 100).toInt()}%", + value = state.gyroDeadzone, + valueRange = 0f..1f, + steps = 99, + onValueChange = { listener.onGyroDeadzoneChanged(it) }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + HUDToggleChip( + label = stringResource(R.string.session_gyroscope_invert_x), + checked = state.invertGyroX, + onClick = { listener.onInvertGyroXChanged(!state.invertGyroX) }, + modifier = Modifier.weight(1f).paneNavItem(cornerRadius = (16f * paneScale).dp, onActivate = { listener.onInvertGyroXChanged(!state.invertGyroX) }), + ) + HUDToggleChip( + label = stringResource(R.string.session_gyroscope_invert_y), + checked = state.invertGyroY, + onClick = { listener.onInvertGyroYChanged(!state.invertGyroY) }, + modifier = Modifier.weight(1f).paneNavItem(cornerRadius = (16f * paneScale).dp, onActivate = { listener.onInvertGyroYChanged(!state.invertGyroY) }), + ) + } + + Box( + Modifier.paneNavItem( + cornerRadius = 10.dp, + onActivate = { listener.onActionSelected(R.id.main_menu_gyroscope_reset) }, + ), + ) { + WinNativeDialogButton( + label = stringResource(R.string.session_gyroscope_reset_stick), + textColor = DrawerAccent, + backgroundColor = DrawerAccent.copy(alpha = 0.12f), + borderColor = DrawerAccent.copy(alpha = 0.3f), + onClick = { listener.onActionSelected(R.id.main_menu_gyroscope_reset) }, + ) + } + } + } + } + } + } +} + +@Composable +internal fun GyroscopeActivatorDropdown( + currentLabel: String, + onSelected: (Int) -> Unit, +) { + val paneScale = LocalPaneScale.current + val names = stringArrayResource(R.array.button_options) + val keycodes = integerArrayResource(R.array.button_keycodes) + var expanded by remember { mutableStateOf(false) } + val parentNav = LocalPaneNav.current + val optionRegistry = remember { PaneNavRegistry() } + LaunchedEffect(expanded) { + if (expanded) { + optionRegistry.reset() + optionRegistry.controllerActive = true + parentNav?.overlay = optionRegistry + parentNav?.overlayClose = { expanded = false } + } else if (parentNav?.overlay === optionRegistry) { + parentNav.overlay = null + parentNav.overlayClose = null + } + } + + val cornerRadius = (14f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(140), + label = "gyroActivatorDropdownBg", + ) + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = cornerRadius, onActivate = { expanded = true }) + .clickable( + interactionSource = interactionSource, + indication = null, + ) { expanded = true } + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentLabel, + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = DrawerTextSecondary, + modifier = Modifier.size((22f * paneScale).dp), + ) + } + + InputControlsOptionsPopup( + expanded = expanded, + options = names.toList(), + selectedIndex = names.indexOfFirst { it == currentLabel }.coerceAtLeast(0), + onSelected = { index -> onSelected(keycodes[index]) }, + onDismiss = { expanded = false }, + optionRegistry = optionRegistry, + ) + } +} diff --git a/app/src/main/runtime/display/XServerDrawerLogsPane.kt b/app/src/main/runtime/display/XServerDrawerLogsPane.kt new file mode 100644 index 000000000..2776657c3 --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerLogsPane.kt @@ -0,0 +1,387 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Logs pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun LogsPaneContent( + logsState: LogsPaneState, + listener: XServerDrawerActionListener, + onClose: () -> Unit, +) { + DisposableEffect(Unit) { + listener.onLogsPaneVisibilityChanged(true) + onDispose { listener.onLogsPaneVisibilityChanged(false) } + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + LogsPaneHeader( + paused = logsState.paused, + lineCount = logsState.lines.size, + onClear = { listener.onLogsClear() }, + onTogglePause = { listener.onLogsPauseChanged(!logsState.paused) }, + onShare = { listener.onLogsShare() }, + onClose = onClose, + ) + + LogsPaneList( + lines = logsState.lines, + paused = logsState.paused, + modifier = Modifier.weight(1f, fill = true).fillMaxWidth(), + ) + } + } + } +} + +@Composable +internal fun LogsPaneHeader( + paused: Boolean, + lineCount: Int, + onClear: () -> Unit, + onTogglePause: () -> Unit, + onShare: () -> Unit, + onClose: () -> Unit, +) { + val paneScale = LocalPaneScale.current + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.session_drawer_logs), + color = DrawerTextPrimary, + fontSize = (16f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = + if (paused) { + stringResource(R.string.session_drawer_logs_paused_indicator) + + " · " + + stringResource(R.string.session_drawer_logs_line_count, lineCount) + } else { + stringResource(R.string.session_drawer_logs_line_count, lineCount) + }, + color = if (paused) DrawerAccent else DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) + } + + LogsPaneActionTile( + icon = if (paused) Icons.Outlined.PlayArrow else Icons.Outlined.Pause, + contentDescription = + if (paused) { + stringResource(R.string.session_drawer_logs_resume) + } else { + stringResource(R.string.session_drawer_logs_pause) + }, + onClick = onTogglePause, + ) + LogsPaneActionTile( + icon = Icons.Outlined.DeleteSweep, + contentDescription = stringResource(R.string.session_drawer_logs_clear), + onClick = onClear, + ) + LogsPaneActionTile( + icon = Icons.Outlined.Share, + contentDescription = stringResource(R.string.session_drawer_logs_share), + onClick = onShare, + ) + + Spacer(Modifier.width((16f * paneScale).dp)) + + TaskManagerCloseButton(onClick = onClose) + } +} + +@Composable +internal fun LogsPaneActionTile( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val tint by animateColorAsState( + targetValue = if (pressed) DrawerAccent else DrawerTextPrimary, + animationSpec = tween(120), + label = "logsActionTileTint", + ) + Box( + modifier = + Modifier + .size((38f * paneScale).dp) + .clip(CircleShape) + .paneNavItem(cornerRadius = (19f * paneScale).dp, onActivate = onClick) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size((24f * paneScale).dp), + ) + } +} + +@Composable +internal fun LogsPaneList( + lines: List, + paused: Boolean, + modifier: Modifier = Modifier, +) { + val paneScale = LocalPaneScale.current + val shape = RoundedCornerShape((10f * paneScale).dp) + val listState = rememberLazyListState() + + LaunchedEffect(lines.size, paused) { + if (!paused && lines.isNotEmpty()) { + listState.scrollToItem((lines.size - 1).coerceAtLeast(0)) + } + } + + Box( + modifier = + modifier + .clip(shape) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, shape), + ) { + if (lines.isEmpty()) { + Text( + text = stringResource(R.string.common_ui_no_items_to_display), + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + modifier = + Modifier + .fillMaxWidth() + .padding(top = (24f * paneScale).dp), + textAlign = TextAlign.Center, + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + horizontal = (10f * paneScale).dp, + vertical = (8f * paneScale).dp, + ), + verticalArrangement = Arrangement.spacedBy((1f * paneScale).dp), + ) { + items(lines) { line -> + Text( + text = line, + color = DrawerTextPrimary, + fontSize = (11f * paneScale).sp, + fontFamily = FontFamily.Monospace, + lineHeight = (14f * paneScale).sp, + letterSpacing = 0.sp, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/app/src/main/runtime/display/XServerDrawerMenu.kt b/app/src/main/runtime/display/XServerDrawerMenu.kt index 1cee605dd..7b8fe2d4e 100644 --- a/app/src/main/runtime/display/XServerDrawerMenu.kt +++ b/app/src/main/runtime/display/XServerDrawerMenu.kt @@ -190,13 +190,13 @@ private const val DrawerSurfaceAlpha = 0.72f private const val DrawerPressedAlpha = 0.88f private const val DrawerGradientLift = 0.014f -private val DrawerAccent = Color(0xFF2196F3) +internal val DrawerAccent = Color(0xFF2196F3) private val DrawerActiveAccent = Color(0xFF29B6F6) private val DrawerFocusFill = Color(0xFF0E2438) -private val DrawerTextPrimary = WinNativeTextPrimary.copy(alpha = 0.88f) -private val DrawerTextSecondary = WinNativeTextSecondary.copy(alpha = 0.82f) -private val DrawerOutline = WinNativeOutline -private val DrawerBackground = WinNativeBackground.copy(alpha = DrawerSheetAlpha) +internal val DrawerTextPrimary = WinNativeTextPrimary.copy(alpha = 0.88f) +internal val DrawerTextSecondary = WinNativeTextSecondary.copy(alpha = 0.82f) +internal val DrawerOutline = WinNativeOutline +internal val DrawerBackground = WinNativeBackground.copy(alpha = DrawerSheetAlpha) internal val PaneSurfaceColor = WinNativeBackground.copy(alpha = DrawerSheetAlpha) private val PaneSurfacePressed = Color(0xFF232B3A).copy(alpha = DrawerPressedAlpha) @@ -204,19 +204,19 @@ private val PaneSurfacePressed = Color(0xFF232B3A).copy(alpha = DrawerPressedAlp private val TopRailSurfaceColor = WinNativeSurface.copy(alpha = DrawerSheetAlpha) private val TileResting = Color(0xFF20283A).copy(alpha = DrawerSurfaceAlpha) -private val TileExitResting = Color(0xFF3A2125).copy(alpha = DrawerSurfaceAlpha) -private val TileExitPressed = Color(0xFF4A2A30).copy(alpha = DrawerPressedAlpha) -private val PaneInnerResting = WinNativePanel.copy(alpha = DrawerSurfaceAlpha) -private val PaneInnerPressed = Color(0xFF242B3A).copy(alpha = DrawerPressedAlpha) -private val RestingCardBorder = WinNativeOutline.copy(alpha = 0.72f) +internal val TileExitResting = Color(0xFF3A2125).copy(alpha = DrawerSurfaceAlpha) +internal val TileExitPressed = Color(0xFF4A2A30).copy(alpha = DrawerPressedAlpha) +internal val PaneInnerResting = WinNativePanel.copy(alpha = DrawerSurfaceAlpha) +internal val PaneInnerPressed = Color(0xFF242B3A).copy(alpha = DrawerPressedAlpha) +internal val RestingCardBorder = WinNativeOutline.copy(alpha = 0.72f) private val DisabledCardBorder = Color(0xFF202033).copy(alpha = 0.58f) -private val ActiveCardBorder = DrawerActiveAccent +internal val ActiveCardBorder = DrawerActiveAccent private val BottomDividerColor = WinNativeOutline -private val GlassExitTint = Color(0xFFE07B6B) -private val RecordRed = Color(0xFFE53935) +internal val GlassExitTint = Color(0xFFE07B6B) +internal val RecordRed = Color(0xFFE53935) // Pane content scales down on short displays. -private val LocalPaneScale = staticCompositionLocalOf { 1f } +internal val LocalPaneScale = staticCompositionLocalOf { 1f } private const val PANE_DIR_LEFT = 1 private const val PANE_DIR_RIGHT = 2 @@ -235,7 +235,7 @@ private class PaneNavEntry( ) @Stable -private class PaneNavRegistry { +internal class PaneNavRegistry { private val items = mutableStateMapOf() private var slotCounter = 0 private var lastSignal = -1 @@ -334,9 +334,9 @@ private class PaneNavRegistry { } } -private val LocalPaneNav = staticCompositionLocalOf { null } +internal val LocalPaneNav = staticCompositionLocalOf { null } -private fun Modifier.paneHighlight(highlighted: Boolean, cornerRadius: Dp): Modifier = +internal fun Modifier.paneHighlight(highlighted: Boolean, cornerRadius: Dp): Modifier = drawWithContent { val cr = cornerRadius.toPx() if (highlighted) { @@ -354,7 +354,7 @@ private fun Modifier.paneHighlight(highlighted: Boolean, cornerRadius: Dp): Modi @OptIn(ExperimentalFoundationApi::class) @Composable -private fun Modifier.paneNavItem( +internal fun Modifier.paneNavItem( cornerRadius: Dp = 10.dp, onActivate: () -> Unit = {}, onAdjust: (Int) -> Unit = {}, @@ -379,7 +379,7 @@ private fun Modifier.paneNavItem( } @Composable -private fun NavBooleanRow( +internal fun NavBooleanRow( title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, @@ -397,7 +397,7 @@ private fun NavBooleanRow( } @Composable -private fun NavEnableRow( +internal fun NavEnableRow( title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, @@ -414,7 +414,7 @@ private fun NavEnableRow( } @Composable -private fun NavSliderRow( +internal fun NavSliderRow( label: String, valueText: String, value: Float, @@ -459,14 +459,14 @@ private fun NavSliderRow( } private const val PaneScaleMin = 0.78f -private const val ControlsPaneScaleMin = 0.62f +internal const val ControlsPaneScaleMin = 0.62f private const val PaneScaleReferenceHeightDp = 520f -private const val PendingTaskAffinityTimeoutMs = 2500L +internal const val PendingTaskAffinityTimeoutMs = 2500L -private fun computePaneScale(availableHeight: Dp, minScale: Float = PaneScaleMin): Float = +internal fun computePaneScale(availableHeight: Dp, minScale: Float = PaneScaleMin): Float = (availableHeight.value / PaneScaleReferenceHeightDp).coerceIn(minScale, 1f) -private enum class HUDMetricEditor( +internal enum class HUDMetricEditor( val minPercent: Int, val maxPercent: Int, ) { @@ -502,7 +502,7 @@ data class TaskManagerPaneState( val memoryDetail: String = "", ) -private data class PendingTaskAffinity( +internal data class PendingTaskAffinity( val affinityMask: Int, val requestedAtMillis: Long, ) @@ -2295,7 +2295,7 @@ private fun BottomActionButton( } @Composable -private fun ThinDivider() { +internal fun ThinDivider() { Box( modifier = Modifier @@ -2306,7 +2306,7 @@ private fun ThinDivider() { } @Composable -private fun DrawerResetRow(label: String, onClick: () -> Unit) { +internal fun DrawerResetRow(label: String, onClick: () -> Unit) { val paneScale = LocalPaneScale.current val shape = RoundedCornerShape((14f * paneScale).dp) Row( @@ -2358,3000 +2358,30 @@ private fun PaneEnableRow( ) } -@Composable -private fun HUDPaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, -) { - var activeEditor by remember { mutableStateOf(null) } - var fpsLimitMemory by remember { - mutableStateOf(if (state.fpsLimit > 0) state.fpsLimit else FPS_LIMITER_DEFAULT) - } - LaunchedEffect(state.fpsLimit) { - if (state.fpsLimit > 0) fpsLimitMemory = state.fpsLimit - } - val elementNames = - listOf( - stringResource(R.string.session_drawer_hud_element_fps), - stringResource(R.string.session_drawer_hud_element_api), - stringResource(R.string.session_drawer_hud_element_gpu), - stringResource(R.string.session_drawer_hud_element_cpu), - stringResource(R.string.session_drawer_hud_element_ram), - stringResource(R.string.session_drawer_hud_element_battery), - stringResource(R.string.session_drawer_hud_element_temp), - stringResource(R.string.session_drawer_hud_element_graph), - stringResource(R.string.session_drawer_hud_element_cpu_temp), - ) - val elementOrder = listOf(1, 2, 3, 8, 4, 5, 6, 0, 7) - val active = - state.items.firstOrNull { it.itemId == R.id.main_menu_fps_monitor }?.active ?: false - - activeEditor?.let { editor -> - HUDMetricInputDialog( - editor = editor, - initialPercent = - when (editor) { - HUDMetricEditor.ALPHA -> (state.hudTransparency * 100).roundToInt() - HUDMetricEditor.BACKGROUND_ALPHA -> (state.hudBackgroundTransparency * 100).roundToInt() - HUDMetricEditor.SCALE -> (state.hudScale * 100).roundToInt() - }, - onDismiss = { activeEditor = null }, - onConfirm = { enteredPercent -> - activeEditor = null - when (editor) { - HUDMetricEditor.ALPHA -> { - listener.onHUDTransparencyChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) - } - HUDMetricEditor.BACKGROUND_ALPHA -> { - listener.onHUDBackgroundTransparencyChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) - } - HUDMetricEditor.SCALE -> { - listener.onHUDScaleChanged(enteredPercent.coerceIn(editor.minPercent, editor.maxPercent) / 100f) - } - } - }, - ) - } - - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - NavEnableRow( - title = stringResource(R.string.session_drawer_fps_monitor), - checked = active, - onCheckedChange = { listener.onActionSelected(R.id.main_menu_fps_monitor) }, - ) - - if (active) { - NavSliderRow( - label = stringResource(R.string.session_drawer_hud_alpha), - valueText = "${(state.hudTransparency * 100).toInt()}%", - value = state.hudTransparency, - valueRange = 0.1f..1f, - steps = 17, - onValueClick = { activeEditor = HUDMetricEditor.ALPHA }, - onValueChange = { listener.onHUDTransparencyChanged(it.snapToStep(0.05f, 0.1f, 1f)) }, - ) - - if (state.hudBackgroundAlphaEnabled) { - NavSliderRow( - label = stringResource(R.string.session_drawer_hud_background), - valueText = "${(state.hudBackgroundTransparency * 100).toInt()}%", - value = state.hudBackgroundTransparency, - valueRange = 0.1f..1f, - steps = 17, - onValueClick = { activeEditor = HUDMetricEditor.BACKGROUND_ALPHA }, - onValueChange = { listener.onHUDBackgroundTransparencyChanged(it.snapToStep(0.05f, 0.1f, 1f)) }, - ) - } - - NavSliderRow( - label = stringResource(R.string.session_drawer_hud_scale), - valueText = "${Math.round(state.hudScale * 100)}%", - value = state.hudScale, - valueRange = 0.3f..2.0f, - steps = 33, - onValueClick = { activeEditor = HUDMetricEditor.SCALE }, - onValueChange = { listener.onHUDScaleChanged(it.snapToStep(0.05f, 0.3f, 2.0f)) }, - adjustStep = 0.05f, - ) - - NavBooleanRow( - title = stringResource(R.string.session_drawer_hud_background_alpha), - checked = state.hudBackgroundAlphaEnabled, - onCheckedChange = listener::onHUDBackgroundAlphaDecoupledChanged, - ) - - NavBooleanRow( - title = stringResource(R.string.session_drawer_hud_frametime_numeric), - checked = state.frametimeNumericEnabled, - onCheckedChange = listener::onFrametimeNumericChanged, - ) - - Box( - Modifier.fillMaxWidth().paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onFPSLimitChanged(if (state.fpsLimit > 0) 0 else fpsLimitMemory.coerceIn(FPS_LIMITER_MIN, state.maxRefreshRate)) }, - onAdjust = { dir -> - val base = if (state.fpsLimit > 0) state.fpsLimit else fpsLimitMemory - val q = base / 5.0 - val units = if (dir > 0) Math.floor(q + 1e-4) + 1 else Math.ceil(q - 1e-4) - 1 - listener.onFPSLimitChanged((units * 5).toInt().coerceIn(FPS_LIMITER_MIN, state.maxRefreshRate)) - }, - ), - ) { - FPSLimiterCard( - currentLimit = state.fpsLimit, - maxRefreshRate = state.maxRefreshRate, - onLimitChanged = listener::onFPSLimitChanged, - ) - } - - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_drawer_hud_elements)) - ChipFlow { - elementOrder.forEach { index -> - HUDToggleChip( - label = elementNames[index], - checked = state.hudElements[index], - onClick = { listener.onHUDElementToggled(index, !state.hudElements[index]) }, - modifier = Modifier.paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onHUDElementToggled(index, !state.hudElements[index]) }, - ), - ) - } - } - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_dual_series_battery), - checked = state.dualSeriesBatteryEnabled, - onCheckedChange = listener::onDualSeriesBatteryChanged, - ) - } - } - } - } -} - -@Composable -private fun TouchPaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, - onClose: () -> Unit, -) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - Box( - modifier = - Modifier - .size((30f * paneScale).dp) - .clip(RoundedCornerShape((8f * paneScale).dp)) - .paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onClose) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClose, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.common_ui_back), - tint = DrawerTextSecondary, - modifier = Modifier.size((20f * paneScale).dp), - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onActionSelected(R.id.main_menu_disable_mouse) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_mouse_input), - checked = state.mouseEnabled, - onCheckedChange = { listener.onActionSelected(R.id.main_menu_disable_mouse) }, - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onActionSelected(R.id.main_menu_relative_mouse_movement) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_relative_mouse_movement), - checked = state.relativeMouseEnabled, - onCheckedChange = { listener.onActionSelected(R.id.main_menu_relative_mouse_movement) }, - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(0) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_touch_trackpad), - checked = state.screenTouchMode == 0 && !state.rtsGesturesEnabled, - onCheckedChange = { if (it) listener.onScreenTouchModeChanged(0) }, - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(1) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_touch_touchscreen), - checked = state.screenTouchMode == 1 && !state.rtsGesturesEnabled, - onCheckedChange = { listener.onScreenTouchModeChanged(if (it) 1 else 0) }, - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onScreenTouchModeChanged(2) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_touch_map_right_stick), - checked = state.screenTouchMode == 2 && !state.rtsGesturesEnabled, - onCheckedChange = { listener.onScreenTouchModeChanged(if (it) 2 else 0) }, - ) - } - Box(Modifier.fillMaxWidth().paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = { listener.onRtsGesturesToggled(!state.rtsGesturesEnabled) })) { - DrawerBooleanRow( - title = stringResource(R.string.session_drawer_rts_gestures), - checked = state.rtsGesturesEnabled, - onCheckedChange = { listener.onRtsGesturesToggled(it) }, - ) - } - if (state.rtsGesturesEnabled) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_gesture_profile_section)) - InputControlsProfileSelector( - profileNames = state.gestureProfileNames, - selectedIndex = state.gestureSelectedProfileIndex, - onProfileSelected = listener::onGestureProfileSelected, - onEditClick = listener::onRtsGesturesEditClick, - ) - } - } - } - } - } -} - -@Composable -private fun GyroscopePaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, -) { - var calibrateExpanded by remember { mutableStateOf(false) } - - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - NavEnableRow( - title = stringResource(R.string.session_gyroscope_title), - checked = state.gyroscopeEnabled, - onCheckedChange = listener::onGyroscopeEnabledChanged, - ) - - if (state.gyroscopeEnabled) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_gyroscope_mode)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - listOf( - stringResource(R.string.session_gyroscope_hold), - stringResource(R.string.session_gyroscope_toggle), - ).forEachIndexed { index, label -> - HUDToggleChip( - label = label, - checked = state.gyroscopeModeIndex == index, - onClick = { listener.onGyroscopeModeSelected(index) }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onGyroscopeModeSelected(index) }, - ), - ) - } - } - } - - NavBooleanRow( - title = stringResource(R.string.session_gyroscope_orientation_mode), - checked = state.gyroOrientationEnabled, - onCheckedChange = listener::onGyroOrientationModeChanged, - ) - - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_gyroscope_activator_button)) - GyroscopeActivatorDropdown( - currentLabel = state.gyroscopeActivatorLabel, - onSelected = listener::onGyroscopeActivatorSelected, - ) - } - - NavBooleanRow( - title = stringResource(R.string.session_gyroscope_enable_right_stick), - checked = state.rightStickGyroEnabled, - onCheckedChange = listener::onRightStickGyroChanged, - ) - - NavBooleanRow( - title = stringResource(R.string.session_gyroscope_experimental_mouse_movement), - checked = state.gyroMouseEnabled, - onCheckedChange = listener::onGyroMouseEnabledChanged, - ) - - if (state.gyroMouseEnabled) { - NavSliderRow( - label = stringResource(R.string.session_gyroscope_mouse_scale), - valueText = "${state.gyroMouseScale.toInt()}%", - value = state.gyroMouseScale, - valueRange = 0f..200f, - steps = 199, - onValueChange = { listener.onGyroMouseScaleChanged(it.roundToInt().toFloat()) }, - ) - } - - ExpandableSection( - title = stringResource(R.string.session_drawer_calibrate_advanced), - expanded = calibrateExpanded, - onToggle = { calibrateExpanded = !calibrateExpanded }, - ) { - NavSliderRow( - label = stringResource(R.string.session_gyroscope_x_sensitivity), - valueText = "${(state.gyroXSensitivity * 100).roundToInt()}%", - value = state.gyroXSensitivity, - valueRange = 0.01f..3f, - steps = 0, - onValueChange = { listener.onGyroXSensitivityChanged(it) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_gyroscope_y_sensitivity), - valueText = "${(state.gyroYSensitivity * 100).roundToInt()}%", - value = state.gyroYSensitivity, - valueRange = 0.01f..3f, - steps = 0, - onValueChange = { listener.onGyroYSensitivityChanged(it) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_gyroscope_smoothing), - valueText = "${(state.gyroSmoothing * 100).toInt()}%", - value = state.gyroSmoothing, - valueRange = 0f..1f, - steps = 99, - onValueChange = { listener.onGyroSmoothingChanged(it) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_gyroscope_deadzone), - valueText = "${(state.gyroDeadzone * 100).toInt()}%", - value = state.gyroDeadzone, - valueRange = 0f..1f, - steps = 99, - onValueChange = { listener.onGyroDeadzoneChanged(it) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - HUDToggleChip( - label = stringResource(R.string.session_gyroscope_invert_x), - checked = state.invertGyroX, - onClick = { listener.onInvertGyroXChanged(!state.invertGyroX) }, - modifier = Modifier.weight(1f).paneNavItem(cornerRadius = (16f * paneScale).dp, onActivate = { listener.onInvertGyroXChanged(!state.invertGyroX) }), - ) - HUDToggleChip( - label = stringResource(R.string.session_gyroscope_invert_y), - checked = state.invertGyroY, - onClick = { listener.onInvertGyroYChanged(!state.invertGyroY) }, - modifier = Modifier.weight(1f).paneNavItem(cornerRadius = (16f * paneScale).dp, onActivate = { listener.onInvertGyroYChanged(!state.invertGyroY) }), - ) - } - - Box( - Modifier.paneNavItem( - cornerRadius = 10.dp, - onActivate = { listener.onActionSelected(R.id.main_menu_gyroscope_reset) }, - ), - ) { - WinNativeDialogButton( - label = stringResource(R.string.session_gyroscope_reset_stick), - textColor = DrawerAccent, - backgroundColor = DrawerAccent.copy(alpha = 0.12f), - borderColor = DrawerAccent.copy(alpha = 0.3f), - onClick = { listener.onActionSelected(R.id.main_menu_gyroscope_reset) }, - ) - } - } - } - } - } - } -} - -@Composable -private fun InputControlsPaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, -) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight, ControlsPaneScaleMin) - val scrollState = rememberScrollState() - val gcmEnabled = state.inputControlsGcmRumbleMode != "disabled" - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding( - start = (12f * paneScale).dp, - end = (12f * paneScale).dp, - top = (4f * paneScale).dp, - bottom = (12f * paneScale).dp, - ), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.input_controls_editor_select_profile)) - InputControlsProfileSelector( - profileNames = state.inputControlsProfileNames, - selectedIndex = state.inputControlsSelectedProfileIndex, - onProfileSelected = listener::onInputControlsProfileSelected, - onEditClick = listener::onInputControlsEditClick, - ) - } - - if (state.inputControlsStyleNames.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.input_controls_select_style)) - InputControlsSimpleDropdown( - options = state.inputControlsStyleNames, - selectedIndex = state.inputControlsSelectedStyleIndex, - onSelected = listener::onInputControlsStyleSelected, - ) - } - } - - if (state.inputControlsLabelThemeNames.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.input_controls_select_label_theme)) - InputControlsSimpleDropdown( - options = state.inputControlsLabelThemeNames, - selectedIndex = state.inputControlsSelectedLabelThemeIndex, - onSelected = listener::onInputControlsLabelThemeSelected, - ) - } - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_show_touchscreen_controls), - checked = state.inputControlsShowOverlay, - onCheckedChange = listener::onInputControlsShowOverlayChanged, - ) - - if (state.inputControlsShowOverlay) { - NavSliderRow( - label = stringResource(R.string.input_controls_editor_overlay_opacity), - valueText = "${(state.inputControlsOverlayOpacity * 100).toInt()}%", - value = state.inputControlsOverlayOpacity, - valueRange = 0.1f..1.0f, - steps = 8, - onValueChange = listener::onInputControlsOverlayOpacityChanged, - ) - Spacer(Modifier.height(4.dp)) - - NavBooleanRow( - title = stringResource(R.string.input_controls_tap_to_click), - checked = state.inputControlsTapToClick, - onCheckedChange = listener::onInputControlsTapToClickChanged, - ) - } - - NavBooleanRow( - title = stringResource(R.string.settings_general_touchscreen_haptics), - checked = state.inputControlsTouchscreenHaptics, - onCheckedChange = listener::onInputControlsTouchscreenHapticsChanged, - ) - - NavBooleanRow( - title = stringResource(R.string.session_gamepad_enable_vibration), - checked = state.inputControlsGamepadVibration, - onCheckedChange = listener::onInputControlsGamepadVibrationChanged, - ) - - NavSliderRow( - label = "Mouse sensitivity scale", - valueText = "${Math.round(state.cursorSpeed * 100)}%", - value = state.cursorSpeed * 100f, - valueRange = 10f..300f, - steps = 0, - onValueChange = { listener.onCursorSpeedChanged(it / 100f) }, - adjustStep = 5f, - ) - - val rsMapMode = state.screenTouchMode == 2 - val rsValue = if (rsMapMode) state.screenTouchRsSensitivity else state.rightStickSensitivity - NavSliderRow( - label = stringResource(R.string.session_drawer_right_stick_sensitivity), - valueText = "${Math.round(rsValue * 100)}%", - value = rsValue * 100f, - valueRange = (if (rsMapMode) 25f else 10f)..200f, - steps = 0, - onValueChange = { listener.onRightStickSensitivityChanged(it / 100f) }, - adjustStep = 5f, - ) - - LaunchedEffect(gcmEnabled) { - if (gcmEnabled) scrollState.animateScrollTo(Int.MAX_VALUE) - } - - NavBooleanRow( - title = "GameSir Controller Rumble", - subtitle = "For Android-mode GameSir controllers only", - checked = gcmEnabled, - onCheckedChange = { enabled -> - listener.onInputControlsGcmRumbleModeChanged(if (enabled) "known" else "disabled") - }, - ) - - if (gcmEnabled) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - HUDToggleChip( - label = "Known", - checked = state.inputControlsGcmRumbleMode == "known", - onClick = { listener.onInputControlsGcmRumbleModeChanged("known") }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onInputControlsGcmRumbleModeChanged("known") }, - ), - ) - HUDToggleChip( - label = "All (experimental)", - checked = state.inputControlsGcmRumbleMode == "all", - onClick = { listener.onInputControlsGcmRumbleModeChanged("all") }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onInputControlsGcmRumbleModeChanged("all") }, - ), - ) - } - Text( - text = if (state.inputControlsGcmRumbleMode == "all") - "All GameSir devices" - else - "G8+ MFi, X5s, X3 Pro", - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - ) - } - } - } - } - } -} - -/** Compact dropdown for the Controls Style/Label-Theme rows; like [InputControlsProfileSelector] but without the edit button (built-in, non-editable choices). */ -@Composable -private fun InputControlsSimpleDropdown( - options: List, - selectedIndex: Int, - onSelected: (Int) -> Unit, -) { - val paneScale = LocalPaneScale.current - var expanded by remember { mutableStateOf(false) } - val selectedText = options.getOrElse(selectedIndex) { options.firstOrNull() ?: "" } - val parentNav = LocalPaneNav.current - val optionRegistry = remember { PaneNavRegistry() } - LaunchedEffect(expanded) { - if (expanded) { - optionRegistry.reset() - optionRegistry.controllerActive = true - parentNav?.overlay = optionRegistry - parentNav?.overlayClose = { expanded = false } - } else if (parentNav?.overlay === optionRegistry) { - parentNav.overlay = null - parentNav.overlayClose = null - } - } - - val cornerRadius = (14f * paneScale).dp - val shape = RoundedCornerShape(cornerRadius) - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(140), - label = "inputControlsSimpleBg", - ) - - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem( - cornerRadius = cornerRadius, - onActivate = { expanded = true }, - onAdjust = { dir -> - if (options.isNotEmpty()) { - onSelected(((selectedIndex + dir) % options.size + options.size) % options.size) - } - }, - ) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { expanded = true } - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedText, - color = DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = DrawerTextSecondary, - modifier = Modifier.size((22f * paneScale).dp), - ) - } - - InputControlsOptionsPopup( - expanded = expanded, - options = options, - selectedIndex = selectedIndex, - onSelected = onSelected, - onDismiss = { expanded = false }, - optionRegistry = optionRegistry, - ) - } -} - -@Composable -private fun InputControlsProfileSelector( - profileNames: List, - selectedIndex: Int, - onProfileSelected: (Int) -> Unit, - onEditClick: () -> Unit, -) { - val paneScale = LocalPaneScale.current - var expanded by remember { mutableStateOf(false) } - val disabledPlaceholder = stringResource(R.string.common_ui_disabled_placeholder) - val selectedText = profileNames.getOrElse(selectedIndex) { disabledPlaceholder } - val parentNav = LocalPaneNav.current - val optionRegistry = remember { PaneNavRegistry() } - LaunchedEffect(expanded) { - if (expanded) { - optionRegistry.reset() - optionRegistry.controllerActive = true - parentNav?.overlay = optionRegistry - parentNav?.overlayClose = { expanded = false } - } else if (parentNav?.overlay === optionRegistry) { - parentNav.overlay = null - parentNav.overlayClose = null - } - } - - val cornerRadius = (14f * paneScale).dp - val shape = RoundedCornerShape(cornerRadius) - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(140), - label = "inputControlsProfileBg", - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.weight(1f)) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem(cornerRadius = cornerRadius, onActivate = { expanded = true }) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { expanded = true } - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedText, - color = DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = DrawerTextSecondary, - modifier = Modifier.size((22f * paneScale).dp), - ) - } - - InputControlsOptionsPopup( - expanded = expanded, - options = profileNames, - selectedIndex = selectedIndex, - onSelected = onProfileSelected, - onDismiss = { expanded = false }, - optionRegistry = optionRegistry, - ) - } - - Box( - modifier = - Modifier - .size((44f * paneScale).dp) - .clip(shape) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem(cornerRadius = cornerRadius, onActivate = onEditClick) - .clickable(onClick = onEditClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.common_ui_settings), - tint = DrawerTextPrimary, - modifier = Modifier.size((20f * paneScale).dp), - ) - } - } -} - -// Drawer-styled dropdown for the Controls selectors. -@Composable -private fun InputControlsOptionsPopup( - expanded: Boolean, - options: List, - selectedIndex: Int, - onSelected: (Int) -> Unit, - onDismiss: () -> Unit, - optionRegistry: PaneNavRegistry, -) { - if (!expanded) return - val paneScale = LocalPaneScale.current - val density = LocalDensity.current - val gapPx = with(density) { (4f * paneScale).dp.roundToPx() } - val shape = RoundedCornerShape((12f * paneScale).dp) - val scrollState = rememberScrollState() - Popup( - popupPositionProvider = remember(gapPx) { TaskManagerPopupPositionProvider(gapPx) }, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = false), - ) { - CompositionLocalProvider(LocalPaneNav provides optionRegistry) { - Column( - modifier = - Modifier - .widthIn(min = (160f * paneScale).dp, max = (280f * paneScale).dp) - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, RestingCardBorder, shape) - .heightIn(max = (260f * paneScale).dp) - .verticalScroll(scrollState) - .padding((5f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - ) { - options.forEachIndexed { index, name -> - InputControlsOptionItem( - label = name, - selected = index == selectedIndex, - onClick = { - onSelected(index) - onDismiss() - }, - ) - } - } - } - } -} - -@Composable -private fun InputControlsOptionItem( - label: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) DrawerAccent.copy(alpha = 0.16f) else PaneInnerResting, - animationSpec = tween(120), - label = "inputControlsOptionItem", - ) - val shape = RoundedCornerShape((8f * paneScale).dp) - Row( - modifier = - modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, if (selected) ActiveCardBorder else RestingCardBorder, shape) - .paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onClick) - .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - Text( - text = label, - color = if (selected) DrawerAccent else DrawerTextPrimary, - fontSize = (13f * paneScale).sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - if (selected) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null, - tint = DrawerAccent, - modifier = Modifier.size((16f * paneScale).dp), - ) - } - } -} - -@Composable -private fun ExpandableSection( - title: String, - expanded: Boolean, - onToggle: () -> Unit, - content: @Composable () -> Unit, -) { - val paneScale = LocalPaneScale.current - val rotation by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - animationSpec = tween(180, easing = FastOutSlowInEasing), - label = "expandableRotation", - ) - val headerInteractionSource = remember { MutableInteractionSource() } - val headerPressed = headerInteractionSource.collectIsPressedAsState().value - val headerBg by animateColorAsState( - targetValue = - when { - headerPressed -> PaneInnerPressed - else -> PaneInnerResting - }, - animationSpec = tween(140), - label = "expandableHeaderBg", - ) - val headerBorder by animateColorAsState( - targetValue = if (expanded) DrawerAccent else RestingCardBorder, - animationSpec = tween(140), - label = "expandableHeaderBorder", - ) - val headerShape = RoundedCornerShape((12f * paneScale).dp) - Column(verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp)) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(headerShape) - .background(headerBg) - .border(1.dp, headerBorder, headerShape) - .paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = onToggle) - .clickable( - interactionSource = headerInteractionSource, - indication = null, - onClick = onToggle, - ) - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - color = if (expanded) DrawerAccent else DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.3.sp, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Outlined.ExpandMore, - contentDescription = null, - tint = if (expanded) DrawerAccent else DrawerTextSecondary, - modifier = - Modifier - .size((18f * paneScale).dp) - .graphicsLayer { rotationZ = rotation }, - ) - } - AnimatedVisibility(visible = expanded) { - Column(verticalArrangement = Arrangement.spacedBy((12f * paneScale).dp)) { - content() - } - } - } -} - -@Composable -private fun ScreenEffectsPaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, -) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.shortcuts_graphics_sgsr_full_title)) - NavBooleanRow( - title = stringResource(R.string.session_drawer_upscaler_fsr), - checked = state.sgsrEnabled, - onCheckedChange = listener::onSGSREnabledChanged, - ) - - AnimatedVisibility( - visible = state.sgsrEnabled, - enter = - expandVertically( - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(durationMillis = 160, easing = FastOutSlowInEasing)), - exit = - shrinkVertically( - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)), - ) { - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - NavSliderRow( - label = stringResource(R.string.session_drawer_sgsr_edge_sharpness), - valueText = "${state.sgsrSharpness}%", - value = state.sgsrSharpness.toFloat(), - valueRange = 0f..100f, - steps = 99, - onValueChange = { listener.onSGSRSharpnessChanged(it.roundToInt().coerceIn(0, 100)) }, - ) - } - } - } - - ThinDivider() - - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_drawer_color_profile)) - - val profiles = - listOf( - 0 to stringResource(R.string.session_drawer_color_profile_disabled), - 1 to stringResource(R.string.session_drawer_color_profile_hdr), - 2 to stringResource(R.string.session_drawer_color_profile_natural), - 4 to stringResource(R.string.session_drawer_color_profile_toon), - 3 to stringResource(R.string.session_drawer_color_profile_crt), - 5 to stringResource(R.string.session_drawer_color_profile_ntsc), - 6 to stringResource(R.string.session_drawer_color_profile_ntsc2), - ) - - ChipFlow { - profiles.forEach { (id, label) -> - HUDToggleChip( - label = label, - checked = state.colorProfile == id, - onClick = { listener.onColorProfileSelected(id) }, - modifier = Modifier.paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onColorProfileSelected(id) }, - ), - ) - } - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_vivid), - checked = state.vividEnabled, - onCheckedChange = listener::onVividEnabledChanged, - ) - - if (state.vividEnabled) { - NavSliderRow( - label = stringResource(R.string.session_drawer_vivid_strength), - valueText = "${state.vividStrength}%", - value = state.vividStrength.toFloat(), - valueRange = 0f..100f, - steps = 99, - onValueChange = { listener.onVividStrengthChanged(it.roundToInt()) }, - ) - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_sharpen), - checked = state.sharpenEnabled, - onCheckedChange = listener::onSharpenEnabledChanged, - ) - - if (state.sharpenEnabled) { - NavSliderRow( - label = stringResource(R.string.session_drawer_strength), - valueText = "${state.sharpenStrength}%", - value = state.sharpenStrength.toFloat(), - valueRange = 0f..100f, - steps = 99, - onValueChange = { listener.onSharpenStrengthChanged(it.roundToInt().coerceIn(0, 100)) }, - ) - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_scanlines), - checked = state.scanlinesEnabled, - onCheckedChange = listener::onScanlinesEnabledChanged, - ) - - if (state.scanlinesEnabled) { - NavSliderRow( - label = stringResource(R.string.session_drawer_intensity), - valueText = "${state.scanlinesIntensity}%", - value = state.scanlinesIntensity.toFloat(), - valueRange = 0f..100f, - steps = 99, - onValueChange = { listener.onScanlinesIntensityChanged(it.roundToInt().coerceIn(0, 100)) }, - ) - } - - NavBooleanRow( - title = stringResource(R.string.session_drawer_pixelate), - checked = state.pixelateEnabled, - onCheckedChange = listener::onPixelateEnabledChanged, - ) - - if (state.pixelateEnabled) { - NavSliderRow( - label = stringResource(R.string.session_drawer_block_size), - valueText = "${state.pixelateBlock}px", - value = state.pixelateBlock.toFloat(), - valueRange = 2f..14f, - steps = 11, - onValueChange = { listener.onPixelateBlockChanged(it.roundToInt().coerceIn(2, 14)) }, - ) - } - - NavSliderRow( - label = stringResource(R.string.session_drawer_brightness), - valueText = "${state.brightness}", - value = state.brightness.toFloat(), - valueRange = -100f..100f, - steps = 39, - onValueChange = { listener.onBrightnessChanged(it.roundToInt().coerceIn(-100, 100)) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_drawer_contrast), - valueText = "${state.contrast}", - value = state.contrast.toFloat(), - valueRange = -100f..100f, - steps = 39, - onValueChange = { listener.onContrastChanged(it.roundToInt().coerceIn(-100, 100)) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_drawer_gamma), - valueText = String.format("%.2fx", state.gammaPercent / 100f), - value = state.gammaPercent.toFloat(), - valueRange = 50f..250f, - steps = 19, - onValueChange = { listener.onGammaChanged(it.roundToInt().coerceIn(50, 250)) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_drawer_saturation), - valueText = "${state.saturation}%", - value = state.saturation.toFloat(), - valueRange = 0f..200f, - steps = 39, - onValueChange = { listener.onSaturationChanged(it.roundToInt().coerceIn(0, 200)) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_drawer_temperature), - valueText = "${state.temperature}", - value = state.temperature.toFloat(), - valueRange = -100f..100f, - steps = 39, - onValueChange = { listener.onTemperatureChanged(it.roundToInt().coerceIn(-100, 100)) }, - ) - - NavSliderRow( - label = stringResource(R.string.session_drawer_tint), - valueText = "${state.tint}", - value = state.tint.toFloat(), - valueRange = -100f..100f, - steps = 39, - onValueChange = { listener.onTintChanged(it.roundToInt().coerceIn(-100, 100)) }, - ) - - Box( - Modifier.fillMaxWidth().paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onResetEffects() }, - ), - ) { - DrawerResetRow( - label = stringResource(R.string.session_drawer_reset_effects), - onClick = listener::onResetEffects, - ) - } - } - - ThinDivider() - - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_drawer_color_blind)) - - Row(horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - HUDToggleChip( - label = stringResource(R.string.session_drawer_color_blind_protan), - checked = state.colorBlind == 1, - onClick = { listener.onColorBlindSelected(if (state.colorBlind == 1) 0 else 1) }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 1) 0 else 1) }, - ), - ) - HUDToggleChip( - label = stringResource(R.string.session_drawer_color_blind_deutan), - checked = state.colorBlind == 2, - onClick = { listener.onColorBlindSelected(if (state.colorBlind == 2) 0 else 2) }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 2) 0 else 2) }, - ), - ) - HUDToggleChip( - label = stringResource(R.string.session_drawer_color_blind_tritan), - checked = state.colorBlind == 3, - onClick = { listener.onColorBlindSelected(if (state.colorBlind == 3) 0 else 3) }, - modifier = Modifier.weight(1f).paneNavItem( - cornerRadius = (16f * paneScale).dp, - onActivate = { listener.onColorBlindSelected(if (state.colorBlind == 3) 0 else 3) }, - ), - ) - } - } - - ThinDivider() - - Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_drawer_scale)) - - NavBooleanRow( - title = stringResource(R.string.session_drawer_scale_nearest), - checked = state.scaleFilter == 1, - onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 1 else 0) }, - ) - - NavBooleanRow( - title = stringResource(R.string.session_drawer_scale_linear), - checked = state.scaleFilter == 2, - onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 2 else 0) }, - ) - - NavBooleanRow( - title = stringResource(R.string.session_drawer_scale_bicubic), - checked = state.scaleFilter == 3, - onCheckedChange = { on -> listener.onScaleFilterSelected(if (on) 3 else 0) }, - ) - } - } - } - } -} - - -@Composable -private fun OutputPaneContent( - state: XServerDrawerState, - listener: XServerDrawerActionListener, -) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - if (state.outputSwapActive) { - OutputActiveControls(state = state, listener = listener, paneScale = paneScale) - } else if (state.outputDisplayAvailable) { - OutputSendToDisplay(state = state, listener = listener, paneScale = paneScale) - } else { - OutputCastEntry(listener = listener, paneScale = paneScale) - } - } - } - } -} - -@Composable -private fun OutputActiveControls( - state: XServerDrawerState, - listener: XServerDrawerActionListener, - paneScale: Float, -) { - OutputDeviceHeader(state = state, paneScale = paneScale) - - OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_display)) { - if (state.outputResolutionLabels.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - OutputFieldLabel(stringResource(R.string.session_drawer_output_resolution), paneScale) - InputControlsSimpleDropdown( - options = state.outputResolutionLabels, - selectedIndex = state.outputSelectedResolutionIndex, - onSelected = listener::onOutputResolutionSelected, - ) - Text( - text = if (state.outputPanelScaling) { - stringResource(R.string.session_drawer_output_scaling_note, state.outputPanelNative) - } else { - stringResource(R.string.session_drawer_output_render_note) - }, - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - lineHeight = (15f * paneScale).sp, - ) - } - } - if (!state.outputPanelScaling && state.outputRefreshLabels.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - OutputFieldLabel(stringResource(R.string.session_drawer_output_refresh_rate), paneScale) - InputControlsSimpleDropdown( - options = state.outputRefreshLabels, - selectedIndex = state.outputSelectedRefreshIndex, - onSelected = listener::onOutputRefreshRateSelected, - ) - } - } - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - OutputFieldLabel(stringResource(R.string.session_drawer_output_aspect_ratio), paneScale) - val aspectLabels = - listOf( - stringResource(R.string.session_drawer_output_aspect_fit), - stringResource(R.string.session_drawer_output_aspect_stretch), - stringResource(R.string.session_drawer_output_aspect_zoom), - ) - ChipFlow { - aspectLabels.forEachIndexed { index, label -> - HUDToggleChip( - label = label, - checked = state.outputAspectMode == index, - onClick = { listener.onOutputAspectModeSelected(index) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputAspectModeSelected(index) }, - ), - ) - } - } - } - } - - if (state.outputGameModeSupported) { - OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_game_mode)) { - ChipFlow { - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_on), - checked = state.outputGameModeEnabled, - onClick = { listener.onOutputGameModeToggled(true) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputGameModeToggled(true) }, - ), - ) - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_off), - checked = !state.outputGameModeEnabled, - onClick = { listener.onOutputGameModeToggled(false) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputGameModeToggled(false) }, - ), - ) - } - Text( - text = stringResource(R.string.session_drawer_output_game_mode_note), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - lineHeight = (15f * paneScale).sp, - ) - } - } - - if (state.outputVitureConnected) { - OutputGlassesCard(state = state, listener = listener, paneScale = paneScale) - } - - OutputPaneButton( - label = stringResource(R.string.session_drawer_output_return_to_phone), - paneScale = paneScale, - onClick = listener::onOutputReturnToPhone, - ) -} - -@Composable -private fun OutputGlassesCard( - state: XServerDrawerState, - listener: XServerDrawerActionListener, - paneScale: Float, -) { - OutputCard( - paneScale = paneScale, - title = state.outputVitureName.ifEmpty { stringResource(R.string.session_drawer_output_glasses) }, - ) { - if (state.outputVitureSupportsBrightness) { - DrawerSliderRow( - label = stringResource(R.string.session_drawer_output_brightness), - valueText = "${state.outputVitureBrightness}/${state.outputVitureBrightnessMax}", - value = state.outputVitureBrightness.toFloat(), - valueRange = 0f..state.outputVitureBrightnessMax.toFloat(), - steps = (state.outputVitureBrightnessMax - 1).coerceAtLeast(0), - onValueChange = { listener.onOutputVitureBrightness(it.roundToInt()) }, - ) - } - if (state.outputVitureSupportsFilm) { - if (state.outputVitureFilmStepped) { - DrawerSliderRow( - label = stringResource(R.string.session_drawer_output_shade), - valueText = "${state.outputVitureFilm}/8", - value = state.outputVitureFilm.toFloat(), - valueRange = 0f..8f, - steps = 7, - onValueChange = { listener.onOutputVitureFilm(it.roundToInt()) }, - ) - } else { - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - OutputFieldLabel(stringResource(R.string.session_drawer_output_shade), paneScale) - ChipFlow { - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_on), - checked = state.outputVitureFilm > 0, - onClick = { listener.onOutputVitureFilm(1) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputVitureFilm(1) }, - ), - ) - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_off), - checked = state.outputVitureFilm == 0, - onClick = { listener.onOutputVitureFilm(0) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputVitureFilm(0) }, - ), - ) - } - } - } - } - if (state.outputVitureSupportsVolume) { - DrawerSliderRow( - label = stringResource(R.string.session_drawer_output_volume), - valueText = "${state.outputVitureVolume}/${state.outputVitureVolumeMax}", - value = state.outputVitureVolume.toFloat(), - valueRange = 0f..state.outputVitureVolumeMax.toFloat(), - steps = (state.outputVitureVolumeMax - 1).coerceAtLeast(0), - onValueChange = { listener.onOutputVitureVolume(it.roundToInt()) }, - ) - } - if (state.outputVitureSupports3D) { - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - OutputFieldLabel(stringResource(R.string.session_drawer_output_3d), paneScale) - ChipFlow { - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_on), - checked = state.outputViture3D, - onClick = { listener.onOutputViture3D(true) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputViture3D(true) }, - ), - ) - HUDToggleChip( - label = stringResource(R.string.session_drawer_output_game_mode_off), - checked = !state.outputViture3D, - onClick = { listener.onOutputViture3D(false) }, - modifier = Modifier.paneNavItem( - cornerRadius = (12f * paneScale).dp, - onActivate = { listener.onOutputViture3D(false) }, - ), - ) - } - } - } - Text( - text = stringResource(R.string.session_drawer_output_glasses_note), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - lineHeight = (15f * paneScale).sp, - ) - } -} - -@Composable -private fun OutputCard( - paneScale: Float, - title: String, - content: @Composable () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape((14f * paneScale).dp)) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - PaneSectionLabel(title) - content() - } -} - -@Composable -private fun OutputFieldLabel(text: String, paneScale: Float) { - Text( - text = text, - color = DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - fontWeight = FontWeight.Medium, - ) -} - -@Composable -private fun OutputDeviceHeader(state: XServerDrawerState, paneScale: Float) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - modifier = Modifier.padding(horizontal = (2f * paneScale).dp), - ) { - Icon( - imageVector = Icons.Outlined.Monitor, - contentDescription = null, - tint = DrawerAccent, - modifier = Modifier.size((22f * paneScale).dp), - ) - Text( - text = state.outputDisplayName.ifEmpty { stringResource(R.string.session_drawer_output_title) }, - color = DrawerTextPrimary, - fontSize = (15f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun OutputSendToDisplay( - state: XServerDrawerState, - listener: XServerDrawerActionListener, - paneScale: Float, -) { - OutputDeviceHeader(state = state, paneScale = paneScale) - OutputPaneButton( - label = stringResource(R.string.session_drawer_output_send_to_display), - paneScale = paneScale, - onClick = listener::onOutputSwapToDisplay, - ) - Text( - text = stringResource(R.string.session_drawer_output_send_note), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - lineHeight = (15f * paneScale).sp, - ) -} - -@Composable -private fun OutputCastEntry( - listener: XServerDrawerActionListener, - paneScale: Float, -) { - Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { - PaneSectionLabel(stringResource(R.string.session_drawer_output_cast_title)) - Text( - text = stringResource(R.string.session_drawer_output_cast_body), - color = DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - lineHeight = (16f * paneScale).sp, - ) - } - OutputPaneButton( - label = stringResource(R.string.session_drawer_output_cast_button), - paneScale = paneScale, - onClick = listener::onOutputCastClick, - ) - Text( - text = stringResource(R.string.session_drawer_output_cast_note), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - lineHeight = (15f * paneScale).sp, - ) -} - -@Composable -private fun OutputPaneButton( - label: String, - paneScale: Float, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape((14f * paneScale).dp)) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) - .paneNavItem(cornerRadius = (14f * paneScale).dp, onActivate = onClick) - .clickable { onClick() } - .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = label, - color = DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - ) - } -} - -@Composable -private fun TaskManagerPaneContent( - taskManagerState: TaskManagerPaneState, - listener: XServerDrawerActionListener, - onClose: () -> Unit, -) { - var showNewTaskDialog by remember { mutableStateOf(false) } - var processPendingEnd by remember { mutableStateOf(null) } - var expandedAffinityPid by remember { mutableStateOf(null) } - val pendingAffinities = remember { mutableStateMapOf() } - - DisposableEffect(Unit) { - listener.onTaskManagerVisibilityChanged(true) - onDispose { listener.onTaskManagerVisibilityChanged(false) } - } - - LaunchedEffect(taskManagerState.processes) { - val visibleProcessPids = taskManagerState.processes.map { it.pid }.toSet() - val now = System.currentTimeMillis() - pendingAffinities.keys.toList().forEach { pid -> - if (pid !in visibleProcessPids) pendingAffinities.remove(pid) - } - taskManagerState.processes.forEach { process -> - val pending = pendingAffinities[process.pid] - if ( - pending != null && - (pending.affinityMask == process.affinityMask || - now - pending.requestedAtMillis > PendingTaskAffinityTimeoutMs) - ) { - pendingAffinities.remove(process.pid) - } - } - if (expandedAffinityPid != null && expandedAffinityPid !in visibleProcessPids) { - expandedAffinityPid = null - } - } - - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - val affinityCoreCount = - if (taskManagerState.cpuCoreCount > 0) { - taskManagerState.cpuCoreCount - } else { - Runtime.getRuntime().availableProcessors() - } - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - TaskManagerHeader( - cpuPercent = taskManagerState.cpuPercent, - cpuCoreCount = taskManagerState.cpuCoreCount, - cpuCorePercents = taskManagerState.cpuCorePercents, - memoryPercent = taskManagerState.memoryPercent, - memoryDetail = taskManagerState.memoryDetail, - onNewTask = { showNewTaskDialog = true }, - onClose = onClose, - onCpuExpandedChanged = listener::onTaskManagerCpuExpandedChanged, - ) - - TaskManagerProcessHeader() - - Box(modifier = Modifier.weight(1f, fill = true).fillMaxWidth()) { - if (taskManagerState.processes.isEmpty()) { - Text( - text = stringResource(R.string.common_ui_no_items_to_display), - color = DrawerTextSecondary, - fontSize = (13f * paneScale).sp, - modifier = Modifier.fillMaxWidth().padding(top = (24f * paneScale).dp), - textAlign = TextAlign.Center, - ) - } else { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy((12f * paneScale).dp), - ) { - taskManagerState.processes.forEach { process -> - key(process.pid) { - val selectedAffinityMask = - pendingAffinities[process.pid]?.affinityMask ?: process.affinityMask - TaskManagerProcessCard( - process = process, - expanded = expandedAffinityPid == process.pid, - affinityMask = selectedAffinityMask, - coreCount = affinityCoreCount, - onToggleAffinity = { - expandedAffinityPid = - if (expandedAffinityPid == process.pid) null else process.pid - }, - onAffinityMaskChanged = { affinityMask -> - pendingAffinities[process.pid] = - PendingTaskAffinity(affinityMask, System.currentTimeMillis()) - listener.onTaskManagerSetAffinity(process.pid, affinityMask) - }, - onEndProcess = { processPendingEnd = process }, - onBringToFront = { listener.onTaskManagerBringToFront(process.name) }, - ) - } - } - } - } - } - } - } - } - - if (showNewTaskDialog) { - TaskManagerNewTaskDialog( - onDismiss = { showNewTaskDialog = false }, - onConfirm = { command -> - showNewTaskDialog = false - listener.onTaskManagerNewTask(command) - }, - ) - } - - processPendingEnd?.let { process -> - TaskManagerEndProcessDialog( - process = process, - onDismiss = { processPendingEnd = null }, - onConfirm = { - processPendingEnd = null - listener.onTaskManagerEndProcess(process.name) - }, - ) - } -} - -@Composable -private fun LogsPaneContent( - logsState: LogsPaneState, - listener: XServerDrawerActionListener, - onClose: () -> Unit, -) { - DisposableEffect(Unit) { - listener.onLogsPaneVisibilityChanged(true) - onDispose { listener.onLogsPaneVisibilityChanged(false) } - } - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val paneScale = computePaneScale(maxHeight) - CompositionLocalProvider(LocalPaneScale provides paneScale) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), - ) { - LogsPaneHeader( - paused = logsState.paused, - lineCount = logsState.lines.size, - onClear = { listener.onLogsClear() }, - onTogglePause = { listener.onLogsPauseChanged(!logsState.paused) }, - onShare = { listener.onLogsShare() }, - onClose = onClose, - ) - - LogsPaneList( - lines = logsState.lines, - paused = logsState.paused, - modifier = Modifier.weight(1f, fill = true).fillMaxWidth(), - ) - } - } - } -} - -@Composable -private fun LogsPaneHeader( - paused: Boolean, - lineCount: Int, - onClear: () -> Unit, - onTogglePause: () -> Unit, - onShare: () -> Unit, - onClose: () -> Unit, -) { - val paneScale = LocalPaneScale.current - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.session_drawer_logs), - color = DrawerTextPrimary, - fontSize = (16f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = - if (paused) { - stringResource(R.string.session_drawer_logs_paused_indicator) + - " · " + - stringResource(R.string.session_drawer_logs_line_count, lineCount) - } else { - stringResource(R.string.session_drawer_logs_line_count, lineCount) - }, - color = if (paused) DrawerAccent else DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.Medium, - ) - } - - LogsPaneActionTile( - icon = if (paused) Icons.Outlined.PlayArrow else Icons.Outlined.Pause, - contentDescription = - if (paused) { - stringResource(R.string.session_drawer_logs_resume) - } else { - stringResource(R.string.session_drawer_logs_pause) - }, - onClick = onTogglePause, - ) - LogsPaneActionTile( - icon = Icons.Outlined.DeleteSweep, - contentDescription = stringResource(R.string.session_drawer_logs_clear), - onClick = onClear, - ) - LogsPaneActionTile( - icon = Icons.Outlined.Share, - contentDescription = stringResource(R.string.session_drawer_logs_share), - onClick = onShare, - ) - - Spacer(Modifier.width((16f * paneScale).dp)) - - TaskManagerCloseButton(onClick = onClose) - } -} - -@Composable -private fun LogsPaneActionTile( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, -) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val tint by animateColorAsState( - targetValue = if (pressed) DrawerAccent else DrawerTextPrimary, - animationSpec = tween(120), - label = "logsActionTileTint", - ) - Box( - modifier = - Modifier - .size((38f * paneScale).dp) - .clip(CircleShape) - .paneNavItem(cornerRadius = (19f * paneScale).dp, onActivate = onClick) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = tint, - modifier = Modifier.size((24f * paneScale).dp), - ) - } -} - -@Composable -private fun LogsPaneList( - lines: List, - paused: Boolean, - modifier: Modifier = Modifier, -) { - val paneScale = LocalPaneScale.current - val shape = RoundedCornerShape((10f * paneScale).dp) - val listState = rememberLazyListState() - - LaunchedEffect(lines.size, paused) { - if (!paused && lines.isNotEmpty()) { - listState.scrollToItem((lines.size - 1).coerceAtLeast(0)) - } - } - - Box( - modifier = - modifier - .clip(shape) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, shape), - ) { - if (lines.isEmpty()) { - Text( - text = stringResource(R.string.common_ui_no_items_to_display), - color = DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - modifier = - Modifier - .fillMaxWidth() - .padding(top = (24f * paneScale).dp), - textAlign = TextAlign.Center, - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = - PaddingValues( - horizontal = (10f * paneScale).dp, - vertical = (8f * paneScale).dp, - ), - verticalArrangement = Arrangement.spacedBy((1f * paneScale).dp), - ) { - items(lines) { line -> - Text( - text = line, - color = DrawerTextPrimary, - fontSize = (11f * paneScale).sp, - fontFamily = FontFamily.Monospace, - lineHeight = (14f * paneScale).sp, - letterSpacing = 0.sp, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } -} - -@Composable -private fun TaskManagerHeader( - cpuPercent: Int, - cpuCoreCount: Int, - cpuCorePercents: List, - memoryPercent: Int, - memoryDetail: String, - onNewTask: () -> Unit, - onClose: () -> Unit, - onCpuExpandedChanged: (Boolean) -> Unit, -) { - val paneScale = LocalPaneScale.current - var cpuExpanded by remember { mutableStateOf(false) } - DisposableEffect(cpuExpanded) { - onCpuExpandedChanged(cpuExpanded) - onDispose { if (cpuExpanded) onCpuExpandedChanged(false) } - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.session_task_title), - color = DrawerTextPrimary, - fontSize = (16f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - - TaskManagerCloseButton(onClick = onClose) - } - - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TaskManagerStatTile( - title = stringResource(R.string.session_task_cpu_usage_format, cpuPercent), - detail = - if (cpuCoreCount > 0) { - pluralStringResource(R.plurals.session_task_core_count, cpuCoreCount, cpuCoreCount) - } else { - "" - }, - modifier = Modifier.weight(1f).fillMaxHeight(), - selected = cpuExpanded, - onClick = { cpuExpanded = !cpuExpanded }, - ) - TaskManagerStatTile( - title = stringResource(R.string.session_task_memory) + " ($memoryPercent%)", - detail = memoryDetail, - modifier = Modifier.weight(1f).fillMaxHeight(), - ) - } - - AnimatedVisibility( - visible = cpuExpanded && cpuCorePercents.isNotEmpty(), - enter = - fadeIn(animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing)) + - expandVertically( - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top, - ), - exit = - fadeOut(animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing)) + - shrinkVertically( - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top, - ), - ) { - TaskManagerCpuCoreGrid(cpuCorePercents = cpuCorePercents) - } - - TaskManagerNewTaskButton(onClick = onNewTask) -} - -@Composable -private fun TaskManagerCloseButton(onClick: () -> Unit) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(120), - label = "taskManagerCloseBg", - ) - val size = (38f * paneScale).dp - val shape = RoundedCornerShape((10f * paneScale).dp) - Box( - modifier = - Modifier - .size(size) - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem(cornerRadius = (10f * paneScale).dp, onActivate = onClick) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.common_ui_close), - tint = DrawerTextPrimary, - modifier = Modifier.size((22f * paneScale).dp), - ) - } -} - -@Composable -private fun TaskManagerStatTile( - title: String, - detail: String, - modifier: Modifier = Modifier, - selected: Boolean = false, - onClick: (() -> Unit)? = null, -) { - val paneScale = LocalPaneScale.current - val shape = RoundedCornerShape((10f * paneScale).dp) - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = - when { - pressed -> PaneInnerPressed - else -> PaneInnerResting - }, - animationSpec = tween(120), - label = "taskManagerStatTileBg", - ) - val borderColor by animateColorAsState( - targetValue = if (selected) DrawerAccent else RestingCardBorder, - animationSpec = tween(120), - label = "taskManagerStatTileBorder", - ) - val clickModifier = - if (onClick != null) { - Modifier.clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - } else { - Modifier - } - - Column( - modifier = - modifier - .clip(shape) - .background(bgColor) - .border(1.dp, borderColor, shape) - .then(clickModifier) - .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((1f * paneScale).dp), - ) { - Text( - text = title, - color = DrawerAccent, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = detail, - color = DrawerTextSecondary, - fontSize = (10f * paneScale).sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun TaskManagerCpuCoreGrid(cpuCorePercents: List) { - val paneScale = LocalPaneScale.current - val shape = RoundedCornerShape((10f * paneScale).dp) - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, shape) - .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - ) { - Text( - text = stringResource(R.string.session_task_per_core_usage), - color = DrawerTextPrimary, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - ) { - cpuCorePercents.forEachIndexed { index, percent -> - TaskManagerCpuCoreChip(coreIndex = index, percent = percent) - } - } - } -} - -@Composable -private fun TaskManagerCpuCoreChip(coreIndex: Int, percent: Int) { - val paneScale = LocalPaneScale.current - val shape = RoundedCornerShape((6f * paneScale).dp) - Row( - modifier = - Modifier - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, RestingCardBorder, shape) - .padding(horizontal = (6f * paneScale).dp, vertical = (3f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - ) { - Text( - text = stringResource(R.string.session_task_core_label, coreIndex), - color = DrawerTextSecondary, - fontSize = (10f * paneScale).sp, - fontWeight = FontWeight.Medium, - ) - Text( - text = "$percent%", - color = DrawerAccent, - fontSize = (10f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - ) - } -} - -@Composable -private fun TaskManagerNewTaskButton(onClick: () -> Unit) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(120), - label = "taskManagerNewTaskBg", - ) - val tint = if (pressed) DrawerAccent.copy(alpha = 0.76f) else DrawerAccent - val shape = RoundedCornerShape((12f * paneScale).dp) - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = onClick) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - tint = tint, - modifier = Modifier.size((18f * paneScale).dp), - ) - Spacer(Modifier.width((6f * paneScale).dp)) - Text( - text = stringResource(R.string.session_task_new_task), - color = tint, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.Medium, - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun TaskManagerAffinityOptions( - affinityMask: Int, - coreCount: Int, - onAffinityMaskChanged: (Int) -> Unit, -) { - val paneScale = LocalPaneScale.current - val effectiveCoreCount = coreCount.coerceAtLeast(1).coerceAtMost(32) - val selectedMask = sanitizeTaskAffinityMask(affinityMask, effectiveCoreCount) - val fullMask = taskAffinityFullMask(effectiveCoreCount) - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = (8f * paneScale).dp, - end = (8f * paneScale).dp, - bottom = (8f * paneScale).dp, - ), - verticalArrangement = Arrangement.spacedBy((7f * paneScale).dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((6f * paneScale).dp), - ) { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = null, - tint = DrawerAccent, - modifier = Modifier.size((15f * paneScale).dp), - ) - Text( - text = stringResource(R.string.session_task_affinity_title), - color = DrawerTextPrimary, - fontSize = (12f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - } - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((5f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((5f * paneScale).dp), - ) { - TaskManagerAffinityChip( - label = stringResource(R.string.session_task_affinity_all_cores), - selected = selectedMask == fullMask, - onClick = { onAffinityMaskChanged(fullMask) }, - ) - for (coreIndex in 0 until effectiveCoreCount) { - val bit = 1 shl coreIndex - TaskManagerAffinityChip( - label = stringResource(R.string.session_task_core_label, coreIndex), - selected = (selectedMask and bit) != 0, - onClick = { - val nextMask = - if ((selectedMask and bit) != 0) { - selectedMask and bit.inv() - } else { - selectedMask or bit - } - if ((nextMask and fullMask) != 0) { - onAffinityMaskChanged(nextMask and fullMask) - } - }, - ) - } - } - } -} - -@Composable -private fun TaskManagerAffinityChip( - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val bgColor = - if (selected) { - DrawerAccent.copy(alpha = 0.16f) - } else { - PaneInnerResting - } - val borderColor = if (selected) DrawerAccent.copy(alpha = 0.56f) else RestingCardBorder - val textColor = if (selected) DrawerAccent else DrawerTextPrimary - Row( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(bgColor) - .border(1.dp, borderColor, RoundedCornerShape(8.dp)) - .paneNavItem(cornerRadius = 8.dp, onActivate = onClick) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 8.dp, vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null, - tint = DrawerAccent.copy(alpha = if (selected) 1f else 0f), - modifier = Modifier.size(13.dp), - ) - Text( - text = label, - color = textColor, - fontSize = 11.sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - ) - } -} - -private fun taskAffinityFullMask(coreCount: Int): Int { - var mask = 0 - for (index in 0 until coreCount.coerceAtLeast(1).coerceAtMost(32)) { - mask = mask or (1 shl index) - } - return mask -} - -private fun sanitizeTaskAffinityMask(affinityMask: Int, coreCount: Int): Int { - val fullMask = taskAffinityFullMask(coreCount) - val sanitizedMask = affinityMask and fullMask - return if (sanitizedMask != 0) sanitizedMask else fullMask -} - -private class TaskManagerPopupPositionProvider(private val gapPx: Int) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) - val below = anchorBounds.bottom + gapPx - val y = - if (below + popupContentSize.height <= windowSize.height) { - below - } else { - (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0) - } - return IntOffset(x, y) - } -} - -@Composable -private fun TaskManagerActionPopup( - expanded: Boolean, - onDismiss: () -> Unit, - content: @Composable ColumnScope.() -> Unit, -) { - if (!expanded) return - val paneScale = LocalPaneScale.current - val density = LocalDensity.current - val gapPx = with(density) { (4f * paneScale).dp.roundToPx() } - val shape = RoundedCornerShape((12f * paneScale).dp) - Popup( - popupPositionProvider = remember(gapPx) { TaskManagerPopupPositionProvider(gapPx) }, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true), - ) { - Column( - modifier = - Modifier - .width(IntrinsicSize.Max) - .widthIn(min = (150f * paneScale).dp, max = (240f * paneScale).dp) - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, RestingCardBorder, shape) - .padding((5f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), - content = content, - ) - } -} - -@Composable -private fun TaskManagerActionPopupItem( - label: String, - onClick: () -> Unit, - icon: ImageVector? = null, -) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) DrawerAccent.copy(alpha = 0.16f) else PaneInnerResting, - animationSpec = tween(120), - label = "taskManagerPopupItem", - ) - val shape = RoundedCornerShape((8f * paneScale).dp) - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = DrawerAccent, - modifier = Modifier.size((16f * paneScale).dp), - ) - } - Text( - text = label, - color = DrawerTextPrimary, - fontSize = (13f * paneScale).sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TaskManagerEndProcessDialog( - process: TaskManagerProcess, - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - val displayName = if (process.isWow64) "${process.name} *32" else process.name - val shape = RoundedCornerShape(12.dp) - - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .safeDrawingPadding() - .padding(horizontal = 14.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = - Modifier - .widthIn(max = 292.dp) - .fillMaxWidth() - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, GlassExitTint.copy(alpha = 0.32f), shape) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = null, - tint = GlassExitTint, - modifier = Modifier.size(17.dp), - ) - Text( - text = stringResource(R.string.session_task_end_process), - color = DrawerTextPrimary, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - - Text( - text = displayName, - color = DrawerTextPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = stringResource(R.string.session_task_confirm_end_process), - color = DrawerTextPrimary, - fontSize = 11.sp, - lineHeight = 14.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - TaskManagerDialogButton( - label = stringResource(R.string.common_ui_cancel), - textColor = DrawerTextPrimary, - modifier = Modifier.height(34.dp), - verticalPadding = 0.dp, - onClick = onDismiss, - ) - TaskManagerDialogButton( - label = stringResource(R.string.session_task_end_process), - textColor = GlassExitTint, - modifier = Modifier.height(34.dp), - verticalPadding = 0.dp, - fontWeight = FontWeight.Medium, - backgroundColor = GlassExitTint.copy(alpha = 0.12f), - borderColor = GlassExitTint.copy(alpha = 0.34f), - onClick = onConfirm, - ) - } - } - } - } -} - -private val NEW_TASK_PRESETS = listOf("Wfm.exe", "Winecfg.exe", "Regedit.exe", "Taskmgr.exe", "Services.exe") - -@Composable -private fun TaskManagerNewTaskDialog( - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - val customLabel = stringResource(R.string.session_task_custom_value) - var selectedLabel by remember { mutableStateOf(NEW_TASK_PRESETS.first()) } - var customMode by remember { mutableStateOf(false) } - var customText by remember { mutableStateOf("") } - var dropdownExpanded by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - val shape = RoundedCornerShape(14.dp) - val fieldShape = RoundedCornerShape(10.dp) - - fun submit() { - val command = if (customMode) customText.trim() else selectedLabel.trim().lowercase() - if (command.isNotEmpty()) onConfirm(command) - } - - LaunchedEffect(customMode) { - if (customMode) { - focusRequester.requestFocus() - keyboardController?.show() - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize() - .safeDrawingPadding() - .imePadding() - .padding(horizontal = 14.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = - Modifier - .widthIn(max = 310.dp) - .fillMaxWidth() - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, RestingCardBorder, shape) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - tint = DrawerAccent, - modifier = Modifier.size(18.dp), - ) - Text( - text = stringResource(R.string.session_task_new_task), - color = DrawerTextPrimary, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - } - - Box(modifier = Modifier.fillMaxWidth()) { - if (customMode) { - OutlinedTextField( - value = customText, - onValueChange = { customText = it }, - modifier = - Modifier - .fillMaxWidth() - .height(48.dp) - .focusRequester(focusRequester), - singleLine = true, - textStyle = - androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy( - color = DrawerTextPrimary, - fontSize = 13.sp, - ), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = DrawerAccent, - unfocusedBorderColor = RestingCardBorder, - focusedTextColor = DrawerTextPrimary, - unfocusedTextColor = DrawerTextPrimary, - focusedContainerColor = PaneInnerResting, - unfocusedContainerColor = PaneInnerResting, - cursorColor = DrawerAccent, - ), - trailingIcon = { - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = DrawerTextSecondary, - modifier = - Modifier - .size(22.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { dropdownExpanded = true }, - ) - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions( - onDone = { - keyboardController?.hide() - submit() - }, - ), - ) - } else { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(fieldShape) - .background(PaneInnerResting) - .border(1.dp, RestingCardBorder, fieldShape) - .clickable { dropdownExpanded = true } - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = selectedLabel, - color = DrawerTextPrimary, - fontSize = 13.sp, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = DrawerTextSecondary, - modifier = Modifier.size(22.dp), - ) - } - } - TaskManagerActionPopup( - expanded = dropdownExpanded, - onDismiss = { dropdownExpanded = false }, - ) { - NEW_TASK_PRESETS.forEach { item -> - TaskManagerActionPopupItem( - label = item, - onClick = { - selectedLabel = item - customMode = false - dropdownExpanded = false - }, - ) - } - TaskManagerActionPopupItem( - label = customLabel, - onClick = { - customMode = true - dropdownExpanded = false - }, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - verticalAlignment = Alignment.CenterVertically, - ) { - TaskManagerDialogButton( - label = stringResource(R.string.common_ui_cancel), - textColor = DrawerTextPrimary, - modifier = Modifier.height(34.dp), - verticalPadding = 0.dp, - onClick = onDismiss, - ) - TaskManagerDialogButton( - label = stringResource(R.string.common_ui_ok), - textColor = DrawerAccent, - modifier = Modifier.height(34.dp), - verticalPadding = 0.dp, - backgroundColor = DrawerAccent.copy(alpha = 0.12f), - borderColor = DrawerAccent.copy(alpha = 0.34f), - onClick = { - keyboardController?.hide() - submit() - }, - ) - } - } - } - } -} - -@Composable -private fun TaskManagerDialogButton( - label: String, - textColor: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, - backgroundColor: Color = PaneInnerResting, - borderColor: Color = RestingCardBorder, - fontWeight: FontWeight = FontWeight.SemiBold, - verticalPadding: Dp = 8.dp, -) { - Box( - modifier = - modifier - .widthIn(min = 72.dp) - .clip(RoundedCornerShape(9.dp)) - .background(backgroundColor) - .border(1.dp, borderColor, RoundedCornerShape(9.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ) - .padding(horizontal = 14.dp, vertical = verticalPadding), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - color = textColor, - fontSize = 12.sp, - fontWeight = fontWeight, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun TaskManagerProcessHeader() { - val paneScale = LocalPaneScale.current - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = (4f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.session_task_process_name), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - Text( - text = stringResource(R.string.session_task_pid), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, - modifier = Modifier.width((54f * paneScale).dp), - ) - Text( - text = stringResource(R.string.session_task_memory), - color = DrawerTextSecondary, - fontSize = (11f * paneScale).sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, - modifier = Modifier.width((78f * paneScale).dp), - ) - Spacer(modifier = Modifier.width((46f * paneScale).dp)) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun TaskManagerProcessCard( - process: TaskManagerProcess, - expanded: Boolean, - affinityMask: Int, - coreCount: Int, - onToggleAffinity: () -> Unit, - onAffinityMaskChanged: (Int) -> Unit, - onEndProcess: () -> Unit, - onBringToFront: () -> Unit, -) { - val paneScale = LocalPaneScale.current - val shape = RoundedCornerShape((8f * paneScale).dp) - val displayName = if (process.isWow64) "${process.name} *32" else process.name - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - var menuExpanded by remember { mutableStateOf(false) } - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(120), - label = "taskManagerProcessRowBg", - ) - val borderColor by animateColorAsState( - targetValue = if (expanded) DrawerAccent.copy(alpha = 0.62f) else RestingCardBorder, - animationSpec = tween(160), - label = "taskManagerProcessCardBorder", - ) - - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, borderColor, shape), - ) { - Box { - Row( - modifier = - Modifier - .fillMaxWidth() - .paneNavItem( - cornerRadius = (8f * paneScale).dp, - onActivate = onToggleAffinity, - onSecondary = onBringToFront, - ) - .combinedClickable( - interactionSource = interactionSource, - indication = null, - onClick = onToggleAffinity, - onLongClick = { menuExpanded = true }, - ) - .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = displayName, - color = DrawerTextPrimary, - fontSize = (12f * paneScale).sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = process.pid.toString(), - color = DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - textAlign = TextAlign.End, - modifier = Modifier.width((54f * paneScale).dp), - ) - Text( - text = process.memoryFormatted, - color = DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - textAlign = TextAlign.End, - modifier = Modifier.width((78f * paneScale).dp), - ) - Spacer(modifier = Modifier.width((10f * paneScale).dp)) - Box( - Modifier.paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onEndProcess), - ) { - TaskManagerEndButton(onClick = onEndProcess) - } - } - TaskManagerActionPopup( - expanded = menuExpanded, - onDismiss = { menuExpanded = false }, - ) { - TaskManagerActionPopupItem( - label = stringResource(R.string.session_task_bring_to_front), - icon = Icons.Outlined.Monitor, - onClick = { - menuExpanded = false - onBringToFront() - }, - ) +internal class TaskManagerPopupPositionProvider(private val gapPx: Int) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val below = anchorBounds.bottom + gapPx + val y = + if (below + popupContentSize.height <= windowSize.height) { + below + } else { + (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0) } - } - - AnimatedVisibility( - visible = expanded, - enter = - fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)) + - expandVertically( - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top, - ), - exit = - fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)) + - shrinkVertically( - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top, - ), - ) { - TaskManagerAffinityOptions( - affinityMask = affinityMask, - coreCount = coreCount, - onAffinityMaskChanged = onAffinityMaskChanged, - ) - } + return IntOffset(x, y) } } -@Composable -private fun TaskManagerEndButton(onClick: () -> Unit) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) TileExitPressed else TileExitResting, - animationSpec = tween(120), - label = "taskManagerEndBtn", - ) - val size = (32f * paneScale).dp - val shape = RoundedCornerShape((8f * paneScale).dp) - Box( - modifier = - Modifier - .size(size) - .clip(shape) - .background(bgColor) - .border(1.dp, GlassExitTint.copy(alpha = 0.34f), shape) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.session_task_end_process), - tint = GlassExitTint, - modifier = Modifier.size((16f * paneScale).dp), - ) - } -} +internal val NEW_TASK_PRESETS = listOf("Wfm.exe", "Winecfg.exe", "Regedit.exe", "Taskmgr.exe", "Services.exe") @Composable -private fun PaneSectionLabel(text: String) { +internal fun PaneSectionLabel(text: String) { val paneScale = LocalPaneScale.current Text( text = text, @@ -5362,81 +2392,6 @@ private fun PaneSectionLabel(text: String) { ) } -@Composable -private fun GyroscopeActivatorDropdown( - currentLabel: String, - onSelected: (Int) -> Unit, -) { - val paneScale = LocalPaneScale.current - val names = stringArrayResource(R.array.button_options) - val keycodes = integerArrayResource(R.array.button_keycodes) - var expanded by remember { mutableStateOf(false) } - val parentNav = LocalPaneNav.current - val optionRegistry = remember { PaneNavRegistry() } - LaunchedEffect(expanded) { - if (expanded) { - optionRegistry.reset() - optionRegistry.controllerActive = true - parentNav?.overlay = optionRegistry - parentNav?.overlayClose = { expanded = false } - } else if (parentNav?.overlay === optionRegistry) { - parentNav.overlay = null - parentNav.overlayClose = null - } - } - - val cornerRadius = (14f * paneScale).dp - val shape = RoundedCornerShape(cornerRadius) - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, - animationSpec = tween(140), - label = "gyroActivatorDropdownBg", - ) - - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(bgColor) - .border(1.dp, RestingCardBorder, shape) - .paneNavItem(cornerRadius = cornerRadius, onActivate = { expanded = true }) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { expanded = true } - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = currentLabel, - color = DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - tint = DrawerTextSecondary, - modifier = Modifier.size((22f * paneScale).dp), - ) - } - - InputControlsOptionsPopup( - expanded = expanded, - options = names.toList(), - selectedIndex = names.indexOfFirst { it == currentLabel }.coerceAtLeast(0), - onSelected = { index -> onSelected(keycodes[index]) }, - onDismiss = { expanded = false }, - optionRegistry = optionRegistry, - ) - } -} - @Composable private fun DrawerMetricChip( label: String, @@ -5526,7 +2481,7 @@ private fun DrawerReadOnlyValueRow( } @Composable -private fun DrawerSliderRow( +internal fun DrawerSliderRow( label: String, valueText: String, value: Float, @@ -5583,7 +2538,7 @@ private fun DrawerSliderRow( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CompactSlider( +internal fun CompactSlider( value: Float, onValueChange: (Float) -> Unit, valueRange: ClosedFloatingPointRange, @@ -5650,14 +2605,14 @@ private fun CompactSlider( } } -private fun Float.snapToStep( +internal fun Float.snapToStep( step: Float, min: Float, max: Float, ): Float = (min + (((this - min) / step).roundToInt() * step)).coerceIn(min, max) @Composable -private fun DialogFocusButton( +internal fun DialogFocusButton( label: String, textColor: Color, backgroundColor: Color, @@ -5688,186 +2643,7 @@ private fun DialogFocusButton( } @Composable -private fun HUDMetricInputDialog( - editor: HUDMetricEditor, - initialPercent: Int, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit, -) { - var value by remember { mutableStateOf(initialPercent.toString()) } - val keyboardController = LocalSoftwareKeyboardController.current - - fun submit() { - val parsed = value.toIntOrNull() ?: initialPercent - onConfirm(parsed.coerceIn(editor.minPercent, editor.maxPercent)) - } - - val focusRequester = remember { FocusRequester() } - WinNativeDialogShell( - onDismiss = onDismiss, - title = - when (editor) { - HUDMetricEditor.ALPHA -> stringResource(R.string.session_drawer_hud_alpha_input_title) - HUDMetricEditor.BACKGROUND_ALPHA -> stringResource(R.string.session_drawer_hud_background_alpha_input_title) - HUDMetricEditor.SCALE -> stringResource(R.string.session_drawer_hud_scale_input_title) - }, - maxWidth = 380.dp, - ) { - LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } - Column( - modifier = - Modifier - .controllerMenuInput( - onDismiss = onDismiss, - onStart = { - keyboardController?.hide() - submit() - }, - ), - ) { - Text( - text = stringResource(R.string.session_drawer_hud_input_hint, editor.minPercent, editor.maxPercent), - color = DrawerTextSecondary, - fontSize = 13.sp, - lineHeight = 18.sp, - ) - Spacer(Modifier.height(14.dp)) - OutlinedTextField( - value = value, - onValueChange = { incoming -> value = incoming.filter(Char::isDigit) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - suffix = { - Text( - text = "%", - color = DrawerTextSecondary, - fontSize = 13.sp, - ) - }, - textStyle = androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy(color = DrawerTextPrimary), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = DrawerAccent, - unfocusedBorderColor = DrawerOutline, - focusedTextColor = DrawerTextPrimary, - unfocusedTextColor = DrawerTextPrimary, - focusedContainerColor = DrawerBackground, - unfocusedContainerColor = DrawerBackground, - focusedLabelColor = DrawerTextSecondary, - unfocusedLabelColor = DrawerTextSecondary, - cursorColor = DrawerAccent, - ), - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = - KeyboardActions( - onDone = { - keyboardController?.hide() - submit() - }, - ), - ) - Spacer(Modifier.height(16.dp)) - Box( - modifier = - Modifier - .fillMaxWidth() - .height(1.dp) - .background(DrawerOutline), - ) - Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), - ) { - DialogFocusButton( - label = stringResource(R.string.common_ui_cancel), - textColor = DrawerTextPrimary, - backgroundColor = PaneInnerResting, - borderColor = RestingCardBorder, - onClick = onDismiss, - ) - DialogFocusButton( - label = stringResource(R.string.common_ui_apply), - textColor = DrawerAccent, - backgroundColor = DrawerAccent.copy(alpha = 0.12f), - borderColor = DrawerAccent.copy(alpha = 0.3f), - focusRequester = focusRequester, - onClick = { - keyboardController?.hide() - submit() - }, - ) - } - } - } -} - -@Composable -private fun HUDToggleChip( - label: String, - checked: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val paneScale = LocalPaneScale.current - val interactionSource = remember { MutableInteractionSource() } - val pressed = interactionSource.collectIsPressedAsState().value - val bgColor by animateColorAsState( - targetValue = - when { - pressed -> PaneInnerPressed - else -> PaneInnerResting - }, - animationSpec = tween(140), - label = "hudChipBg", - ) - val borderColor by animateColorAsState( - targetValue = if (checked) DrawerAccent else RestingCardBorder, - animationSpec = tween(140), - label = "hudChipBorder", - ) - val cornerRadius = (12f * paneScale).dp - val shape = RoundedCornerShape(cornerRadius) - val indicatorSize = (10f * paneScale).dp - - Row( - modifier = - modifier - .clip(shape) - .background(bgColor) - .border(1.dp, borderColor, shape) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ).padding(horizontal = (10f * paneScale).dp, vertical = (9f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(indicatorSize) - .clip(CircleShape) - .background(if (checked) DrawerAccent else Color(0x14FFFFFF)), - ) - Spacer(Modifier.width((8f * paneScale).dp)) - Text( - text = label, - color = DrawerTextPrimary, - fontSize = (13f * paneScale).sp, - fontWeight = if (checked) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -@Composable -private fun DrawerBooleanRow( +internal fun DrawerBooleanRow( title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, @@ -5941,282 +2717,14 @@ private fun DrawerBooleanRow( } } -private val RECORD_QUALITY_LABELS = listOf("Performance", "Balance", "Quality") - -/** Centered popup for choosing recording fps / resolution / quality (+ Record UI), then Record Now. */ -@Composable -private fun RecordSettingsDialog( - config: RecordUiConfig, - onDismiss: () -> Unit, - onRecordNow: (fpsIndex: Int, resolutionIndex: Int, quality: Int, recordUI: Boolean) -> Unit, -) { - val fpsOptions = config.fpsOptions.ifEmpty { listOf(60) } - val resOptions = config.resolutionLabels.ifEmpty { listOf("Native") } - - var fpsIndex by remember { mutableStateOf(config.fpsIndex.coerceIn(0, fpsOptions.lastIndex)) } - var resIndex by remember { mutableStateOf(config.resolutionIndex.coerceIn(0, resOptions.lastIndex)) } - var quality by remember { mutableStateOf(config.quality.coerceIn(0, RECORD_QUALITY_LABELS.lastIndex)) } - var recordUI by remember { mutableStateOf(config.recordUI) } - - val recordNav = remember { SharedPaneNavRegistry() } - val doRecord = { onRecordNow(fpsIndex, resIndex, quality, recordUI) } - - val shape = RoundedCornerShape(16.dp) - // Cap card height (landscape is short); settings scroll, the Record Now button stays pinned. - val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), - ) { - CompositionLocalProvider(SharedLocalPaneNav provides recordNav) { - DialogPaneNav(recordNav, onDismiss = onDismiss, onStart = doRecord) - Box( - modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(horizontal = 14.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = - Modifier - .widthIn(max = 360.dp) - .fillMaxWidth() - .heightIn(max = maxCardHeight) - .clip(shape) - .background(PaneSurfaceColor) - .border(1.dp, RestingCardBorder, shape) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Outlined.FiberManualRecord, - contentDescription = null, - tint = RecordRed, - modifier = Modifier.size(18.dp), - ) - Text( - text = stringResource(R.string.session_record_settings_title), - color = DrawerTextPrimary, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) - } - - Column( - modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - modifier = Modifier.fillMaxWidth().sharedPaneNavItem( - onAdjust = { dir -> if (fpsOptions.size > 1) fpsIndex = (fpsIndex + dir).coerceIn(0, fpsOptions.lastIndex) }, - ), - ) { - DrawerSliderRow( - label = stringResource(R.string.session_record_fps), - valueText = "${fpsOptions[fpsIndex]} fps", - value = fpsIndex.toFloat(), - valueRange = 0f..(fpsOptions.lastIndex.coerceAtLeast(1)).toFloat(), - steps = (fpsOptions.size - 2).coerceAtLeast(0), - onValueChange = { if (fpsOptions.size > 1) fpsIndex = it.roundToInt().coerceIn(0, fpsOptions.lastIndex) }, - ) - } - - Box( - modifier = Modifier.fillMaxWidth().sharedPaneNavItem( - onAdjust = { dir -> if (resOptions.size > 1) resIndex = (resIndex + dir).coerceIn(0, resOptions.lastIndex) }, - ), - ) { - DrawerSliderRow( - label = stringResource(R.string.session_record_resolution), - valueText = resOptions[resIndex], - value = resIndex.toFloat(), - valueRange = 0f..(resOptions.lastIndex.coerceAtLeast(1)).toFloat(), - steps = (resOptions.size - 2).coerceAtLeast(0), - onValueChange = { if (resOptions.size > 1) resIndex = it.roundToInt().coerceIn(0, resOptions.lastIndex) }, - ) - } - - Box( - modifier = Modifier.fillMaxWidth().sharedPaneNavItem( - onAdjust = { dir -> quality = (quality + dir).coerceIn(0, RECORD_QUALITY_LABELS.lastIndex) }, - ), - ) { - DrawerSliderRow( - label = stringResource(R.string.session_record_quality), - valueText = RECORD_QUALITY_LABELS[quality], - value = quality.toFloat(), - valueRange = 0f..(RECORD_QUALITY_LABELS.lastIndex).toFloat(), - steps = (RECORD_QUALITY_LABELS.size - 2).coerceAtLeast(0), - onValueChange = { quality = it.roundToInt().coerceIn(0, RECORD_QUALITY_LABELS.lastIndex) }, - ) - } - - Box( - modifier = Modifier.fillMaxWidth().sharedPaneNavItem( - onActivate = { recordUI = !recordUI }, - ), - ) { - DrawerBooleanRow( - title = stringResource(R.string.session_record_include_ui), - checked = recordUI, - onCheckedChange = { recordUI = it }, - subtitle = stringResource(R.string.session_record_include_ui_subtitle), - ) - } - } - - Button( - onClick = doRecord, - modifier = Modifier.fillMaxWidth().height(48.dp).sharedPaneNavItem( - cornerRadius = 12.dp, - onActivate = doRecord, - isEntry = true, - ), - shape = RoundedCornerShape(12.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = RecordRed, - contentColor = Color.White, - ), - ) { - Icon( - imageVector = Icons.Outlined.FiberManualRecord, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(R.string.session_record_now), - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - ) - } - } - } - } - } -} - -private const val FPS_LIMITER_MIN = 30 -private const val FPS_LIMITER_DEFAULT = 60 - -@Composable -private fun FPSLimiterCard( - currentLimit: Int, - maxRefreshRate: Int, - onLimitChanged: (Int) -> Unit, -) { - val paneScale = LocalPaneScale.current - val enabled = currentLimit > 0 - val maxFps = maxRefreshRate.coerceAtLeast(FPS_LIMITER_MIN) - val steps = (maxFps - FPS_LIMITER_MIN - 1).coerceAtLeast(0) - - // Slider position tracked locally (readout follows the drag, value survives an off/on toggle); the commit is deferred to release and re-seeds when maxFps changes (e.g. a mid-game refresh-rate change that clamps the limit). - var sliderValue by remember(maxFps) { - mutableStateOf( - (if (currentLimit > 0) currentLimit else FPS_LIMITER_DEFAULT) - .coerceIn(FPS_LIMITER_MIN, maxFps) - .toFloat(), - ) - } - - LaunchedEffect(currentLimit) { - if (currentLimit > 0) { - val target = currentLimit.coerceIn(FPS_LIMITER_MIN, maxFps).toFloat() - if (target != sliderValue) sliderValue = target - } - } - - val borderColor by animateColorAsState( - targetValue = if (enabled) ActiveCardBorder else RestingCardBorder, - animationSpec = tween(140), - label = "fpsLimiterCardBorder", - ) - val shape = RoundedCornerShape((14f * paneScale).dp) - - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(shape) - .background(PaneInnerResting) - .border(1.dp, borderColor, shape) - .padding(horizontal = (12f * paneScale).dp, vertical = (8f * paneScale).dp), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { onLimitChanged(if (enabled) 0 else sliderValue.roundToInt()) }, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.session_drawer_fps_limiter), - color = DrawerTextPrimary, - fontSize = (14f * paneScale).sp, - fontWeight = FontWeight.Medium, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = - if (enabled) { - "${sliderValue.roundToInt()} FPS" - } else { - stringResource(R.string.session_drawer_fps_limiter_off) - }, - color = if (enabled) DrawerAccent else DrawerTextSecondary, - fontSize = (12f * paneScale).sp, - fontWeight = if (enabled) FontWeight.SemiBold else FontWeight.Normal, - ) - } - CompositionLocalProvider(LocalRippleConfiguration provides null) { - Switch( - checked = enabled, - onCheckedChange = { on -> onLimitChanged(if (on) sliderValue.roundToInt() else 0) }, - colors = outlinedSwitchColors(DrawerAccent, DrawerTextSecondary), - ) - } - } +internal val RECORD_QUALITY_LABELS = listOf("Performance", "Balance", "Quality") - AnimatedVisibility( - visible = enabled, - enter = - expandVertically( - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - expandFrom = Alignment.Top, - ) + fadeIn(animationSpec = tween(durationMillis = 160, easing = FastOutSlowInEasing)), - exit = - shrinkVertically( - animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), - shrinkTowards = Alignment.Top, - ) + fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)), - ) { - Column { - Spacer(Modifier.height((6f * paneScale).dp)) - CompactSlider( - value = sliderValue, - onValueChange = { sliderValue = it }, - valueRange = FPS_LIMITER_MIN.toFloat()..maxFps.toFloat(), - steps = steps, - onValueChangeFinished = { onLimitChanged(sliderValue.roundToInt()) }, - ) - } - } - } -} +internal const val FPS_LIMITER_MIN = 30 +internal const val FPS_LIMITER_DEFAULT = 60 @OptIn(ExperimentalLayoutApi::class) @Composable -private fun ChipFlow(content: @Composable () -> Unit) { +internal fun ChipFlow(content: @Composable () -> Unit) { val paneScale = LocalPaneScale.current val gap = (8f * paneScale).dp FlowRow( diff --git a/app/src/main/runtime/display/XServerDrawerOutputPane.kt b/app/src/main/runtime/display/XServerDrawerOutputPane.kt new file mode 100644 index 000000000..4ec47fd4d --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerOutputPane.kt @@ -0,0 +1,550 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Output/display pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun OutputPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + if (state.outputSwapActive) { + OutputActiveControls(state = state, listener = listener, paneScale = paneScale) + } else if (state.outputDisplayAvailable) { + OutputSendToDisplay(state = state, listener = listener, paneScale = paneScale) + } else { + OutputCastEntry(listener = listener, paneScale = paneScale) + } + } + } + } +} + +@Composable +internal fun OutputActiveControls( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputDeviceHeader(state = state, paneScale = paneScale) + + OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_display)) { + if (state.outputResolutionLabels.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_resolution), paneScale) + InputControlsSimpleDropdown( + options = state.outputResolutionLabels, + selectedIndex = state.outputSelectedResolutionIndex, + onSelected = listener::onOutputResolutionSelected, + ) + Text( + text = if (state.outputPanelScaling) { + stringResource(R.string.session_drawer_output_scaling_note, state.outputPanelNative) + } else { + stringResource(R.string.session_drawer_output_render_note) + }, + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } + } + if (!state.outputPanelScaling && state.outputRefreshLabels.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_refresh_rate), paneScale) + InputControlsSimpleDropdown( + options = state.outputRefreshLabels, + selectedIndex = state.outputSelectedRefreshIndex, + onSelected = listener::onOutputRefreshRateSelected, + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_aspect_ratio), paneScale) + val aspectLabels = + listOf( + stringResource(R.string.session_drawer_output_aspect_fit), + stringResource(R.string.session_drawer_output_aspect_stretch), + stringResource(R.string.session_drawer_output_aspect_zoom), + ) + ChipFlow { + aspectLabels.forEachIndexed { index, label -> + HUDToggleChip( + label = label, + checked = state.outputAspectMode == index, + onClick = { listener.onOutputAspectModeSelected(index) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputAspectModeSelected(index) }, + ), + ) + } + } + } + } + + if (state.outputGameModeSupported) { + OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_game_mode)) { + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputGameModeEnabled, + onClick = { listener.onOutputGameModeToggled(true) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputGameModeToggled(true) }, + ), + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = !state.outputGameModeEnabled, + onClick = { listener.onOutputGameModeToggled(false) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputGameModeToggled(false) }, + ), + ) + } + Text( + text = stringResource(R.string.session_drawer_output_game_mode_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } + } + + if (state.outputVitureConnected) { + OutputGlassesCard(state = state, listener = listener, paneScale = paneScale) + } + + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_return_to_phone), + paneScale = paneScale, + onClick = listener::onOutputReturnToPhone, + ) +} + +@Composable +internal fun OutputGlassesCard( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputCard( + paneScale = paneScale, + title = state.outputVitureName.ifEmpty { stringResource(R.string.session_drawer_output_glasses) }, + ) { + if (state.outputVitureSupportsBrightness) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_brightness), + valueText = "${state.outputVitureBrightness}/${state.outputVitureBrightnessMax}", + value = state.outputVitureBrightness.toFloat(), + valueRange = 0f..state.outputVitureBrightnessMax.toFloat(), + steps = (state.outputVitureBrightnessMax - 1).coerceAtLeast(0), + onValueChange = { listener.onOutputVitureBrightness(it.roundToInt()) }, + ) + } + if (state.outputVitureSupportsFilm) { + if (state.outputVitureFilmStepped) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_shade), + valueText = "${state.outputVitureFilm}/8", + value = state.outputVitureFilm.toFloat(), + valueRange = 0f..8f, + steps = 7, + onValueChange = { listener.onOutputVitureFilm(it.roundToInt()) }, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_shade), paneScale) + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputVitureFilm > 0, + onClick = { listener.onOutputVitureFilm(1) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputVitureFilm(1) }, + ), + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = state.outputVitureFilm == 0, + onClick = { listener.onOutputVitureFilm(0) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputVitureFilm(0) }, + ), + ) + } + } + } + } + if (state.outputVitureSupportsVolume) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_volume), + valueText = "${state.outputVitureVolume}/${state.outputVitureVolumeMax}", + value = state.outputVitureVolume.toFloat(), + valueRange = 0f..state.outputVitureVolumeMax.toFloat(), + steps = (state.outputVitureVolumeMax - 1).coerceAtLeast(0), + onValueChange = { listener.onOutputVitureVolume(it.roundToInt()) }, + ) + } + if (state.outputVitureSupports3D) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_3d), paneScale) + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputViture3D, + onClick = { listener.onOutputViture3D(true) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputViture3D(true) }, + ), + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = !state.outputViture3D, + onClick = { listener.onOutputViture3D(false) }, + modifier = Modifier.paneNavItem( + cornerRadius = (12f * paneScale).dp, + onActivate = { listener.onOutputViture3D(false) }, + ), + ) + } + } + } + Text( + text = stringResource(R.string.session_drawer_output_glasses_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } +} + +@Composable +internal fun OutputCard( + paneScale: Float, + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape((14f * paneScale).dp)) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + PaneSectionLabel(title) + content() + } +} + +@Composable +internal fun OutputFieldLabel(text: String, paneScale: Float) { + Text( + text = text, + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) +} + +@Composable +internal fun OutputDeviceHeader(state: XServerDrawerState, paneScale: Float) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + modifier = Modifier.padding(horizontal = (2f * paneScale).dp), + ) { + Icon( + imageVector = Icons.Outlined.Monitor, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size((22f * paneScale).dp), + ) + Text( + text = state.outputDisplayName.ifEmpty { stringResource(R.string.session_drawer_output_title) }, + color = DrawerTextPrimary, + fontSize = (15f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun OutputSendToDisplay( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputDeviceHeader(state = state, paneScale = paneScale) + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_send_to_display), + paneScale = paneScale, + onClick = listener::onOutputSwapToDisplay, + ) + Text( + text = stringResource(R.string.session_drawer_output_send_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) +} + +@Composable +internal fun OutputCastEntry( + listener: XServerDrawerActionListener, + paneScale: Float, +) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_output_cast_title)) + Text( + text = stringResource(R.string.session_drawer_output_cast_body), + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + lineHeight = (16f * paneScale).sp, + ) + } + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_cast_button), + paneScale = paneScale, + onClick = listener::onOutputCastClick, + ) + Text( + text = stringResource(R.string.session_drawer_output_cast_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) +} + +@Composable +internal fun OutputPaneButton( + label: String, + paneScale: Float, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape((14f * paneScale).dp)) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) + .paneNavItem(cornerRadius = (14f * paneScale).dp, onActivate = onClick) + .clickable { onClick() } + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = label, + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/app/src/main/runtime/display/XServerDrawerRecordDialogs.kt b/app/src/main/runtime/display/XServerDrawerRecordDialogs.kt new file mode 100644 index 000000000..40fb63156 --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerRecordDialogs.kt @@ -0,0 +1,455 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Record-settings + FPS-limiter dialog composables, split out of XServerDrawerMenu.kt (behavior-identical). + +/** Centered popup for choosing recording fps / resolution / quality (+ Record UI), then Record Now. */ +@Composable +internal fun RecordSettingsDialog( + config: RecordUiConfig, + onDismiss: () -> Unit, + onRecordNow: (fpsIndex: Int, resolutionIndex: Int, quality: Int, recordUI: Boolean) -> Unit, +) { + val fpsOptions = config.fpsOptions.ifEmpty { listOf(60) } + val resOptions = config.resolutionLabels.ifEmpty { listOf("Native") } + + var fpsIndex by remember { mutableStateOf(config.fpsIndex.coerceIn(0, fpsOptions.lastIndex)) } + var resIndex by remember { mutableStateOf(config.resolutionIndex.coerceIn(0, resOptions.lastIndex)) } + var quality by remember { mutableStateOf(config.quality.coerceIn(0, RECORD_QUALITY_LABELS.lastIndex)) } + var recordUI by remember { mutableStateOf(config.recordUI) } + + val recordNav = remember { SharedPaneNavRegistry() } + val doRecord = { onRecordNow(fpsIndex, resIndex, quality, recordUI) } + + val shape = RoundedCornerShape(16.dp) + // Cap card height (landscape is short); settings scroll, the Record Now button stays pinned. + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + CompositionLocalProvider(SharedLocalPaneNav provides recordNav) { + DialogPaneNav(recordNav, onDismiss = onDismiss, onStart = doRecord) + Box( + modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .heightIn(max = maxCardHeight) + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.FiberManualRecord, + contentDescription = null, + tint = RecordRed, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.session_record_settings_title), + color = DrawerTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Column( + modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth().sharedPaneNavItem( + onAdjust = { dir -> if (fpsOptions.size > 1) fpsIndex = (fpsIndex + dir).coerceIn(0, fpsOptions.lastIndex) }, + ), + ) { + DrawerSliderRow( + label = stringResource(R.string.session_record_fps), + valueText = "${fpsOptions[fpsIndex]} fps", + value = fpsIndex.toFloat(), + valueRange = 0f..(fpsOptions.lastIndex.coerceAtLeast(1)).toFloat(), + steps = (fpsOptions.size - 2).coerceAtLeast(0), + onValueChange = { if (fpsOptions.size > 1) fpsIndex = it.roundToInt().coerceIn(0, fpsOptions.lastIndex) }, + ) + } + + Box( + modifier = Modifier.fillMaxWidth().sharedPaneNavItem( + onAdjust = { dir -> if (resOptions.size > 1) resIndex = (resIndex + dir).coerceIn(0, resOptions.lastIndex) }, + ), + ) { + DrawerSliderRow( + label = stringResource(R.string.session_record_resolution), + valueText = resOptions[resIndex], + value = resIndex.toFloat(), + valueRange = 0f..(resOptions.lastIndex.coerceAtLeast(1)).toFloat(), + steps = (resOptions.size - 2).coerceAtLeast(0), + onValueChange = { if (resOptions.size > 1) resIndex = it.roundToInt().coerceIn(0, resOptions.lastIndex) }, + ) + } + + Box( + modifier = Modifier.fillMaxWidth().sharedPaneNavItem( + onAdjust = { dir -> quality = (quality + dir).coerceIn(0, RECORD_QUALITY_LABELS.lastIndex) }, + ), + ) { + DrawerSliderRow( + label = stringResource(R.string.session_record_quality), + valueText = RECORD_QUALITY_LABELS[quality], + value = quality.toFloat(), + valueRange = 0f..(RECORD_QUALITY_LABELS.lastIndex).toFloat(), + steps = (RECORD_QUALITY_LABELS.size - 2).coerceAtLeast(0), + onValueChange = { quality = it.roundToInt().coerceIn(0, RECORD_QUALITY_LABELS.lastIndex) }, + ) + } + + Box( + modifier = Modifier.fillMaxWidth().sharedPaneNavItem( + onActivate = { recordUI = !recordUI }, + ), + ) { + DrawerBooleanRow( + title = stringResource(R.string.session_record_include_ui), + checked = recordUI, + onCheckedChange = { recordUI = it }, + subtitle = stringResource(R.string.session_record_include_ui_subtitle), + ) + } + } + + Button( + onClick = doRecord, + modifier = Modifier.fillMaxWidth().height(48.dp).sharedPaneNavItem( + cornerRadius = 12.dp, + onActivate = doRecord, + isEntry = true, + ), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = RecordRed, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Outlined.FiberManualRecord, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.session_record_now), + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +internal fun FPSLimiterCard( + currentLimit: Int, + maxRefreshRate: Int, + onLimitChanged: (Int) -> Unit, +) { + val paneScale = LocalPaneScale.current + val enabled = currentLimit > 0 + val maxFps = maxRefreshRate.coerceAtLeast(FPS_LIMITER_MIN) + val steps = (maxFps - FPS_LIMITER_MIN - 1).coerceAtLeast(0) + + // Slider position tracked locally (readout follows the drag, value survives an off/on toggle); the commit is deferred to release and re-seeds when maxFps changes (e.g. a mid-game refresh-rate change that clamps the limit). + var sliderValue by remember(maxFps) { + mutableStateOf( + (if (currentLimit > 0) currentLimit else FPS_LIMITER_DEFAULT) + .coerceIn(FPS_LIMITER_MIN, maxFps) + .toFloat(), + ) + } + + LaunchedEffect(currentLimit) { + if (currentLimit > 0) { + val target = currentLimit.coerceIn(FPS_LIMITER_MIN, maxFps).toFloat() + if (target != sliderValue) sliderValue = target + } + } + + val borderColor by animateColorAsState( + targetValue = if (enabled) ActiveCardBorder else RestingCardBorder, + animationSpec = tween(140), + label = "fpsLimiterCardBorder", + ) + val shape = RoundedCornerShape((14f * paneScale).dp) + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PaneInnerResting) + .border(1.dp, borderColor, shape) + .padding(horizontal = (12f * paneScale).dp, vertical = (8f * paneScale).dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onLimitChanged(if (enabled) 0 else sliderValue.roundToInt()) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.session_drawer_fps_limiter), + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = + if (enabled) { + "${sliderValue.roundToInt()} FPS" + } else { + stringResource(R.string.session_drawer_fps_limiter_off) + }, + color = if (enabled) DrawerAccent else DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + fontWeight = if (enabled) FontWeight.SemiBold else FontWeight.Normal, + ) + } + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Switch( + checked = enabled, + onCheckedChange = { on -> onLimitChanged(if (on) sliderValue.roundToInt() else 0) }, + colors = outlinedSwitchColors(DrawerAccent, DrawerTextSecondary), + ) + } + } + + AnimatedVisibility( + visible = enabled, + enter = + expandVertically( + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + fadeIn(animationSpec = tween(durationMillis = 160, easing = FastOutSlowInEasing)), + exit = + shrinkVertically( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)), + ) { + Column { + Spacer(Modifier.height((6f * paneScale).dp)) + CompactSlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + valueRange = FPS_LIMITER_MIN.toFloat()..maxFps.toFloat(), + steps = steps, + onValueChangeFinished = { onLimitChanged(sliderValue.roundToInt()) }, + ) + } + } + } +} diff --git a/app/src/main/runtime/display/XServerDrawerTaskManagerPane.kt b/app/src/main/runtime/display/XServerDrawerTaskManagerPane.kt new file mode 100644 index 000000000..00aea57a1 --- /dev/null +++ b/app/src/main/runtime/display/XServerDrawerTaskManagerPane.kt @@ -0,0 +1,1363 @@ +package com.winlator.cmod.runtime.display + +import android.app.Activity +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusable +import com.winlator.cmod.shared.ui.focus.controllerMenuInput +import com.winlator.cmod.shared.ui.focus.controllerFocusBorder +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.ViewList +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord +import androidx.compose.material.icons.outlined.Fullscreen +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PictureInPictureAlt +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.ZoomIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Stable +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.R +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton +import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav as SharedLocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry as SharedPaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem as sharedPaneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder +import kotlin.math.roundToInt + +// Task-manager pane composables, split out of XServerDrawerMenu.kt (behavior-identical). + +@Composable +internal fun TaskManagerPaneContent( + taskManagerState: TaskManagerPaneState, + listener: XServerDrawerActionListener, + onClose: () -> Unit, +) { + var showNewTaskDialog by remember { mutableStateOf(false) } + var processPendingEnd by remember { mutableStateOf(null) } + var expandedAffinityPid by remember { mutableStateOf(null) } + val pendingAffinities = remember { mutableStateMapOf() } + + DisposableEffect(Unit) { + listener.onTaskManagerVisibilityChanged(true) + onDispose { listener.onTaskManagerVisibilityChanged(false) } + } + + LaunchedEffect(taskManagerState.processes) { + val visibleProcessPids = taskManagerState.processes.map { it.pid }.toSet() + val now = System.currentTimeMillis() + pendingAffinities.keys.toList().forEach { pid -> + if (pid !in visibleProcessPids) pendingAffinities.remove(pid) + } + taskManagerState.processes.forEach { process -> + val pending = pendingAffinities[process.pid] + if ( + pending != null && + (pending.affinityMask == process.affinityMask || + now - pending.requestedAtMillis > PendingTaskAffinityTimeoutMs) + ) { + pendingAffinities.remove(process.pid) + } + } + if (expandedAffinityPid != null && expandedAffinityPid !in visibleProcessPids) { + expandedAffinityPid = null + } + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + val affinityCoreCount = + if (taskManagerState.cpuCoreCount > 0) { + taskManagerState.cpuCoreCount + } else { + Runtime.getRuntime().availableProcessors() + } + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + TaskManagerHeader( + cpuPercent = taskManagerState.cpuPercent, + cpuCoreCount = taskManagerState.cpuCoreCount, + cpuCorePercents = taskManagerState.cpuCorePercents, + memoryPercent = taskManagerState.memoryPercent, + memoryDetail = taskManagerState.memoryDetail, + onNewTask = { showNewTaskDialog = true }, + onClose = onClose, + onCpuExpandedChanged = listener::onTaskManagerCpuExpandedChanged, + ) + + TaskManagerProcessHeader() + + Box(modifier = Modifier.weight(1f, fill = true).fillMaxWidth()) { + if (taskManagerState.processes.isEmpty()) { + Text( + text = stringResource(R.string.common_ui_no_items_to_display), + color = DrawerTextSecondary, + fontSize = (13f * paneScale).sp, + modifier = Modifier.fillMaxWidth().padding(top = (24f * paneScale).dp), + textAlign = TextAlign.Center, + ) + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy((12f * paneScale).dp), + ) { + taskManagerState.processes.forEach { process -> + key(process.pid) { + val selectedAffinityMask = + pendingAffinities[process.pid]?.affinityMask ?: process.affinityMask + TaskManagerProcessCard( + process = process, + expanded = expandedAffinityPid == process.pid, + affinityMask = selectedAffinityMask, + coreCount = affinityCoreCount, + onToggleAffinity = { + expandedAffinityPid = + if (expandedAffinityPid == process.pid) null else process.pid + }, + onAffinityMaskChanged = { affinityMask -> + pendingAffinities[process.pid] = + PendingTaskAffinity(affinityMask, System.currentTimeMillis()) + listener.onTaskManagerSetAffinity(process.pid, affinityMask) + }, + onEndProcess = { processPendingEnd = process }, + onBringToFront = { listener.onTaskManagerBringToFront(process.name) }, + ) + } + } + } + } + } + } + } + } + + if (showNewTaskDialog) { + TaskManagerNewTaskDialog( + onDismiss = { showNewTaskDialog = false }, + onConfirm = { command -> + showNewTaskDialog = false + listener.onTaskManagerNewTask(command) + }, + ) + } + + processPendingEnd?.let { process -> + TaskManagerEndProcessDialog( + process = process, + onDismiss = { processPendingEnd = null }, + onConfirm = { + processPendingEnd = null + listener.onTaskManagerEndProcess(process.name) + }, + ) + } +} + +@Composable +internal fun TaskManagerHeader( + cpuPercent: Int, + cpuCoreCount: Int, + cpuCorePercents: List, + memoryPercent: Int, + memoryDetail: String, + onNewTask: () -> Unit, + onClose: () -> Unit, + onCpuExpandedChanged: (Boolean) -> Unit, +) { + val paneScale = LocalPaneScale.current + var cpuExpanded by remember { mutableStateOf(false) } + DisposableEffect(cpuExpanded) { + onCpuExpandedChanged(cpuExpanded) + onDispose { if (cpuExpanded) onCpuExpandedChanged(false) } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.session_task_title), + color = DrawerTextPrimary, + fontSize = (16f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + + TaskManagerCloseButton(onClick = onClose) + } + + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskManagerStatTile( + title = stringResource(R.string.session_task_cpu_usage_format, cpuPercent), + detail = + if (cpuCoreCount > 0) { + pluralStringResource(R.plurals.session_task_core_count, cpuCoreCount, cpuCoreCount) + } else { + "" + }, + modifier = Modifier.weight(1f).fillMaxHeight(), + selected = cpuExpanded, + onClick = { cpuExpanded = !cpuExpanded }, + ) + TaskManagerStatTile( + title = stringResource(R.string.session_task_memory) + " ($memoryPercent%)", + detail = memoryDetail, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + + AnimatedVisibility( + visible = cpuExpanded && cpuCorePercents.isNotEmpty(), + enter = + fadeIn(animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ), + exit = + fadeOut(animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ), + ) { + TaskManagerCpuCoreGrid(cpuCorePercents = cpuCorePercents) + } + + TaskManagerNewTaskButton(onClick = onNewTask) +} + +@Composable +internal fun TaskManagerCloseButton(onClick: () -> Unit) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(120), + label = "taskManagerCloseBg", + ) + val size = (38f * paneScale).dp + val shape = RoundedCornerShape((10f * paneScale).dp) + Box( + modifier = + Modifier + .size(size) + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = (10f * paneScale).dp, onActivate = onClick) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.common_ui_close), + tint = DrawerTextPrimary, + modifier = Modifier.size((22f * paneScale).dp), + ) + } +} + +@Composable +internal fun TaskManagerStatTile( + title: String, + detail: String, + modifier: Modifier = Modifier, + selected: Boolean = false, + onClick: (() -> Unit)? = null, +) { + val paneScale = LocalPaneScale.current + val shape = RoundedCornerShape((10f * paneScale).dp) + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = + when { + pressed -> PaneInnerPressed + else -> PaneInnerResting + }, + animationSpec = tween(120), + label = "taskManagerStatTileBg", + ) + val borderColor by animateColorAsState( + targetValue = if (selected) DrawerAccent else RestingCardBorder, + animationSpec = tween(120), + label = "taskManagerStatTileBorder", + ) + val clickModifier = + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + } else { + Modifier + } + + Column( + modifier = + modifier + .clip(shape) + .background(bgColor) + .border(1.dp, borderColor, shape) + .then(clickModifier) + .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((1f * paneScale).dp), + ) { + Text( + text = title, + color = DrawerAccent, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = detail, + color = DrawerTextSecondary, + fontSize = (10f * paneScale).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TaskManagerCpuCoreGrid(cpuCorePercents: List) { + val paneScale = LocalPaneScale.current + val shape = RoundedCornerShape((10f * paneScale).dp) + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, shape) + .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + ) { + Text( + text = stringResource(R.string.session_task_per_core_usage), + color = DrawerTextPrimary, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + ) { + cpuCorePercents.forEachIndexed { index, percent -> + TaskManagerCpuCoreChip(coreIndex = index, percent = percent) + } + } + } +} + +@Composable +internal fun TaskManagerCpuCoreChip(coreIndex: Int, percent: Int) { + val paneScale = LocalPaneScale.current + val shape = RoundedCornerShape((6f * paneScale).dp) + Row( + modifier = + Modifier + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .padding(horizontal = (6f * paneScale).dp, vertical = (3f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + ) { + Text( + text = stringResource(R.string.session_task_core_label, coreIndex), + color = DrawerTextSecondary, + fontSize = (10f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = "$percent%", + color = DrawerAccent, + fontSize = (10f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +internal fun TaskManagerNewTaskButton(onClick: () -> Unit) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(120), + label = "taskManagerNewTaskBg", + ) + val tint = if (pressed) DrawerAccent.copy(alpha = 0.76f) else DrawerAccent + val shape = RoundedCornerShape((12f * paneScale).dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .paneNavItem(cornerRadius = (12f * paneScale).dp, onActivate = onClick) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = tint, + modifier = Modifier.size((18f * paneScale).dp), + ) + Spacer(Modifier.width((6f * paneScale).dp)) + Text( + text = stringResource(R.string.session_task_new_task), + color = tint, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TaskManagerAffinityOptions( + affinityMask: Int, + coreCount: Int, + onAffinityMaskChanged: (Int) -> Unit, +) { + val paneScale = LocalPaneScale.current + val effectiveCoreCount = coreCount.coerceAtLeast(1).coerceAtMost(32) + val selectedMask = sanitizeTaskAffinityMask(affinityMask, effectiveCoreCount) + val fullMask = taskAffinityFullMask(effectiveCoreCount) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = (8f * paneScale).dp, + end = (8f * paneScale).dp, + bottom = (8f * paneScale).dp, + ), + verticalArrangement = Arrangement.spacedBy((7f * paneScale).dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((6f * paneScale).dp), + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size((15f * paneScale).dp), + ) + Text( + text = stringResource(R.string.session_task_affinity_title), + color = DrawerTextPrimary, + fontSize = (12f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((5f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((5f * paneScale).dp), + ) { + TaskManagerAffinityChip( + label = stringResource(R.string.session_task_affinity_all_cores), + selected = selectedMask == fullMask, + onClick = { onAffinityMaskChanged(fullMask) }, + ) + for (coreIndex in 0 until effectiveCoreCount) { + val bit = 1 shl coreIndex + TaskManagerAffinityChip( + label = stringResource(R.string.session_task_core_label, coreIndex), + selected = (selectedMask and bit) != 0, + onClick = { + val nextMask = + if ((selectedMask and bit) != 0) { + selectedMask and bit.inv() + } else { + selectedMask or bit + } + if ((nextMask and fullMask) != 0) { + onAffinityMaskChanged(nextMask and fullMask) + } + }, + ) + } + } + } +} + +@Composable +internal fun TaskManagerAffinityChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val bgColor = + if (selected) { + DrawerAccent.copy(alpha = 0.16f) + } else { + PaneInnerResting + } + val borderColor = if (selected) DrawerAccent.copy(alpha = 0.56f) else RestingCardBorder + val textColor = if (selected) DrawerAccent else DrawerTextPrimary + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(bgColor) + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .paneNavItem(cornerRadius = 8.dp, onActivate = onClick) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 8.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + tint = DrawerAccent.copy(alpha = if (selected) 1f else 0f), + modifier = Modifier.size(13.dp), + ) + Text( + text = label, + color = textColor, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + ) + } +} + +internal fun taskAffinityFullMask(coreCount: Int): Int { + var mask = 0 + for (index in 0 until coreCount.coerceAtLeast(1).coerceAtMost(32)) { + mask = mask or (1 shl index) + } + return mask +} + +internal fun sanitizeTaskAffinityMask(affinityMask: Int, coreCount: Int): Int { + val fullMask = taskAffinityFullMask(coreCount) + val sanitizedMask = affinityMask and fullMask + return if (sanitizedMask != 0) sanitizedMask else fullMask +} + +@Composable +internal fun TaskManagerActionPopup( + expanded: Boolean, + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + if (!expanded) return + val paneScale = LocalPaneScale.current + val density = LocalDensity.current + val gapPx = with(density) { (4f * paneScale).dp.roundToPx() } + val shape = RoundedCornerShape((12f * paneScale).dp) + Popup( + popupPositionProvider = remember(gapPx) { TaskManagerPopupPositionProvider(gapPx) }, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = + Modifier + .width(IntrinsicSize.Max) + .widthIn(min = (150f * paneScale).dp, max = (240f * paneScale).dp) + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .padding((5f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((4f * paneScale).dp), + content = content, + ) + } +} + +@Composable +internal fun TaskManagerActionPopupItem( + label: String, + onClick: () -> Unit, + icon: ImageVector? = null, +) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) DrawerAccent.copy(alpha = 0.16f) else PaneInnerResting, + animationSpec = tween(120), + label = "taskManagerPopupItem", + ) + val shape = RoundedCornerShape((8f * paneScale).dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, RestingCardBorder, shape) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size((16f * paneScale).dp), + ) + } + Text( + text = label, + color = DrawerTextPrimary, + fontSize = (13f * paneScale).sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun TaskManagerEndProcessDialog( + process: TaskManagerProcess, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val displayName = if (process.isWow64) "${process.name} *32" else process.name + val shape = RoundedCornerShape(12.dp) + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 292.dp) + .fillMaxWidth() + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, GlassExitTint.copy(alpha = 0.32f), shape) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + tint = GlassExitTint, + modifier = Modifier.size(17.dp), + ) + Text( + text = stringResource(R.string.session_task_end_process), + color = DrawerTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + Text( + text = displayName, + color = DrawerTextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(R.string.session_task_confirm_end_process), + color = DrawerTextPrimary, + fontSize = 11.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskManagerDialogButton( + label = stringResource(R.string.common_ui_cancel), + textColor = DrawerTextPrimary, + modifier = Modifier.height(34.dp), + verticalPadding = 0.dp, + onClick = onDismiss, + ) + TaskManagerDialogButton( + label = stringResource(R.string.session_task_end_process), + textColor = GlassExitTint, + modifier = Modifier.height(34.dp), + verticalPadding = 0.dp, + fontWeight = FontWeight.Medium, + backgroundColor = GlassExitTint.copy(alpha = 0.12f), + borderColor = GlassExitTint.copy(alpha = 0.34f), + onClick = onConfirm, + ) + } + } + } + } +} + +@Composable +internal fun TaskManagerNewTaskDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val customLabel = stringResource(R.string.session_task_custom_value) + var selectedLabel by remember { mutableStateOf(NEW_TASK_PRESETS.first()) } + var customMode by remember { mutableStateOf(false) } + var customText by remember { mutableStateOf("") } + var dropdownExpanded by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val shape = RoundedCornerShape(14.dp) + val fieldShape = RoundedCornerShape(10.dp) + + fun submit() { + val command = if (customMode) customText.trim() else selectedLabel.trim().lowercase() + if (command.isNotEmpty()) onConfirm(command) + } + + LaunchedEffect(customMode) { + if (customMode) { + focusRequester.requestFocus() + keyboardController?.show() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .safeDrawingPadding() + .imePadding() + .padding(horizontal = 14.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 310.dp) + .fillMaxWidth() + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.session_task_new_task), + color = DrawerTextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + if (customMode) { + OutlinedTextField( + value = customText, + onValueChange = { customText = it }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .focusRequester(focusRequester), + singleLine = true, + textStyle = + androidx.compose.material3.MaterialTheme.typography.bodyMedium.copy( + color = DrawerTextPrimary, + fontSize = 13.sp, + ), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = DrawerAccent, + unfocusedBorderColor = RestingCardBorder, + focusedTextColor = DrawerTextPrimary, + unfocusedTextColor = DrawerTextPrimary, + focusedContainerColor = PaneInnerResting, + unfocusedContainerColor = PaneInnerResting, + cursorColor = DrawerAccent, + ), + trailingIcon = { + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = DrawerTextSecondary, + modifier = + Modifier + .size(22.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { dropdownExpanded = true }, + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + keyboardController?.hide() + submit() + }, + ), + ) + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(fieldShape) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, fieldShape) + .clickable { dropdownExpanded = true } + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedLabel, + color = DrawerTextPrimary, + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + tint = DrawerTextSecondary, + modifier = Modifier.size(22.dp), + ) + } + } + TaskManagerActionPopup( + expanded = dropdownExpanded, + onDismiss = { dropdownExpanded = false }, + ) { + NEW_TASK_PRESETS.forEach { item -> + TaskManagerActionPopupItem( + label = item, + onClick = { + selectedLabel = item + customMode = false + dropdownExpanded = false + }, + ) + } + TaskManagerActionPopupItem( + label = customLabel, + onClick = { + customMode = true + dropdownExpanded = false + }, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskManagerDialogButton( + label = stringResource(R.string.common_ui_cancel), + textColor = DrawerTextPrimary, + modifier = Modifier.height(34.dp), + verticalPadding = 0.dp, + onClick = onDismiss, + ) + TaskManagerDialogButton( + label = stringResource(R.string.common_ui_ok), + textColor = DrawerAccent, + modifier = Modifier.height(34.dp), + verticalPadding = 0.dp, + backgroundColor = DrawerAccent.copy(alpha = 0.12f), + borderColor = DrawerAccent.copy(alpha = 0.34f), + onClick = { + keyboardController?.hide() + submit() + }, + ) + } + } + } + } +} + +@Composable +internal fun TaskManagerDialogButton( + label: String, + textColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color = PaneInnerResting, + borderColor: Color = RestingCardBorder, + fontWeight: FontWeight = FontWeight.SemiBold, + verticalPadding: Dp = 8.dp, +) { + Box( + modifier = + modifier + .widthIn(min = 72.dp) + .clip(RoundedCornerShape(9.dp)) + .background(backgroundColor) + .border(1.dp, borderColor, RoundedCornerShape(9.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = verticalPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = textColor, + fontSize = 12.sp, + fontWeight = fontWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun TaskManagerProcessHeader() { + val paneScale = LocalPaneScale.current + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = (4f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.session_task_process_name), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Text( + text = stringResource(R.string.session_task_pid), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, + modifier = Modifier.width((54f * paneScale).dp), + ) + Text( + text = stringResource(R.string.session_task_memory), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, + modifier = Modifier.width((78f * paneScale).dp), + ) + Spacer(modifier = Modifier.width((46f * paneScale).dp)) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun TaskManagerProcessCard( + process: TaskManagerProcess, + expanded: Boolean, + affinityMask: Int, + coreCount: Int, + onToggleAffinity: () -> Unit, + onAffinityMaskChanged: (Int) -> Unit, + onEndProcess: () -> Unit, + onBringToFront: () -> Unit, +) { + val paneScale = LocalPaneScale.current + val shape = RoundedCornerShape((8f * paneScale).dp) + val displayName = if (process.isWow64) "${process.name} *32" else process.name + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + var menuExpanded by remember { mutableStateOf(false) } + val bgColor by animateColorAsState( + targetValue = if (pressed) PaneInnerPressed else PaneInnerResting, + animationSpec = tween(120), + label = "taskManagerProcessRowBg", + ) + val borderColor by animateColorAsState( + targetValue = if (expanded) DrawerAccent.copy(alpha = 0.62f) else RestingCardBorder, + animationSpec = tween(160), + label = "taskManagerProcessCardBorder", + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .background(bgColor) + .border(1.dp, borderColor, shape), + ) { + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .paneNavItem( + cornerRadius = (8f * paneScale).dp, + onActivate = onToggleAffinity, + onSecondary = onBringToFront, + ) + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = onToggleAffinity, + onLongClick = { menuExpanded = true }, + ) + .padding(horizontal = (8f * paneScale).dp, vertical = (6f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayName, + color = DrawerTextPrimary, + fontSize = (12f * paneScale).sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = process.pid.toString(), + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + textAlign = TextAlign.End, + modifier = Modifier.width((54f * paneScale).dp), + ) + Text( + text = process.memoryFormatted, + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + textAlign = TextAlign.End, + modifier = Modifier.width((78f * paneScale).dp), + ) + Spacer(modifier = Modifier.width((10f * paneScale).dp)) + Box( + Modifier.paneNavItem(cornerRadius = (8f * paneScale).dp, onActivate = onEndProcess), + ) { + TaskManagerEndButton(onClick = onEndProcess) + } + } + + TaskManagerActionPopup( + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + ) { + TaskManagerActionPopupItem( + label = stringResource(R.string.session_task_bring_to_front), + icon = Icons.Outlined.Monitor, + onClick = { + menuExpanded = false + onBringToFront() + }, + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = + fadeIn(animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ), + exit = + fadeOut(animationSpec = tween(durationMillis = 120, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ), + ) { + TaskManagerAffinityOptions( + affinityMask = affinityMask, + coreCount = coreCount, + onAffinityMaskChanged = onAffinityMaskChanged, + ) + } + } +} + +@Composable +internal fun TaskManagerEndButton(onClick: () -> Unit) { + val paneScale = LocalPaneScale.current + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = if (pressed) TileExitPressed else TileExitResting, + animationSpec = tween(120), + label = "taskManagerEndBtn", + ) + val size = (32f * paneScale).dp + val shape = RoundedCornerShape((8f * paneScale).dp) + Box( + modifier = + Modifier + .size(size) + .clip(shape) + .background(bgColor) + .border(1.dp, GlassExitTint.copy(alpha = 0.34f), shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.session_task_end_process), + tint = GlassExitTint, + modifier = Modifier.size((16f * paneScale).dp), + ) + } +} From 573ccf052a63883e508e1e288d9aebe7489600ea Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Wed, 15 Jul 2026 00:07:26 +0000 Subject: [PATCH 5/8] SteamService: mark the reflection-referenced companion members so a future refactor won't move them --- app/src/main/feature/stores/steam/service/SteamService.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 9849c2cdc..484222332 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -571,6 +571,7 @@ class SteamService : Service() { } /** Pure getter over [isLoggedInFlow] — never write the flow from a read (caused UI flicker on transient CM disconnect); only authoritative sources mutate it (initLoginStatus, onLoggedOn/Off, logOut, clearValues). */ + // Invoked by name via reflection from SteamBridge/SteamClientManager — keep in the companion; do not move to an extension file. val isLoggedIn: Boolean get() = !isLoggingOut && _isLoggedInFlow.value @@ -1390,6 +1391,7 @@ class SteamService : Service() { ?: app?.name.orEmpty() } + // Invoked by name via reflection from SteamBridge — keep in the companion; do not move to an extension file. fun getAppDirPath(gameId: Int): String { val info = getAppInfoOf(gameId) @@ -1464,6 +1466,7 @@ class SteamService : Service() { } /** Resolves the executable for an installed Steam app from its appinfo `config.launch` entries — depot manifests store filenames AES-encrypted and are never decrypted, so scanning them is useless. */ + // Invoked by name via reflection from SteamBridge — keep in the companion; do not move to an extension file. fun getInstalledExe(appId: Int): String = getWindowsLaunchInfos(appId).firstOrNull()?.executable ?: "" From 659f9b28078465d6c2f306e24fc365f83bbdc944 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Wed, 15 Jul 2026 14:25:50 +0000 Subject: [PATCH 6/8] XServer drawer: ignore guide-button auto-repeat so holding doesn't spam the menu open/close --- app/src/main/runtime/display/XServerDisplayActivity.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 932022ebf..5e69993fb 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7961,7 +7961,8 @@ public boolean dispatchKeyEvent(KeyEvent event) { int kc = event.getKeyCode(); boolean down = event.getAction() == KeyEvent.ACTION_DOWN; if (kc == KeyEvent.KEYCODE_BUTTON_B || kc == KeyEvent.KEYCODE_BUTTON_MODE) { - if (down) handleNavigationBackPressed(); + // Only the initial press toggles; ignore auto-repeat so holding doesn't spam open/close. + if (down && event.getRepeatCount() == 0) handleNavigationBackPressed(); return true; } if (down) { @@ -8031,7 +8032,8 @@ public boolean dispatchKeyEvent(KeyEvent event) { } if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) { - if (event.getAction() == KeyEvent.ACTION_DOWN) { + // Only the initial press toggles; ignore auto-repeat so holding doesn't spam open/close. + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { handleNavigationBackPressed(); } return true; From 05457c0996450630635c4249caceb0ccf3c52b32 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Wed, 15 Jul 2026 17:17:22 +0000 Subject: [PATCH 7/8] XServer drawer: hold guide button to open the menu (timer-based; no repeat spam, no stuck state) --- .../display/XServerDisplayActivity.java | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 5e69993fb..45158902e 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -21,6 +21,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.view.InputDevice; import android.view.KeyEvent; @@ -299,6 +300,20 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private boolean isPointerCaptureForcedOff = false; private boolean isVolumeUpPressed = false; private boolean isVolumeDownPressed = false; + private boolean guideHoldPending = false; + private long guideMenuOpenedAt = 0L; + private static final long GUIDE_HOLD_OPEN_MS = 450L; + private static final long GUIDE_HOLD_TAIL_MS = 1200L; + private final Runnable guideHoldOpenRunnable = new Runnable() { + @Override + public void run() { + guideHoldPending = false; + if (drawerStateHolder == null || !drawerStateHolder.isDrawerOpen()) { + guideMenuOpenedAt = SystemClock.uptimeMillis(); + openDrawerMenu(); + } + } + }; private OnExtractFileListener onExtractFileListener; private WinHandler winHandler; private WineRequestHandler wineRequestHandler; @@ -2409,6 +2424,8 @@ public void onPause() { super.onPause(); isVolumeUpPressed = false; isVolumeDownPressed = false; + guideHoldPending = false; + handler.removeCallbacks(guideHoldOpenRunnable); boolean gyroEnabled = preferences.getBoolean("gyro_enabled", false); if (gyroEnabled) { @@ -7960,9 +7977,16 @@ public boolean dispatchKeyEvent(KeyEvent event) { drawerStateHolder.updateControllerConnected(true); int kc = event.getKeyCode(); boolean down = event.getAction() == KeyEvent.ACTION_DOWN; - if (kc == KeyEvent.KEYCODE_BUTTON_B || kc == KeyEvent.KEYCODE_BUTTON_MODE) { - // Only the initial press toggles; ignore auto-repeat so holding doesn't spam open/close. - if (down && event.getRepeatCount() == 0) handleNavigationBackPressed(); + if (kc == KeyEvent.KEYCODE_BUTTON_MODE) { + // Menu open: a fresh press closes it; suppress the tail of the hold that opened it. + if (down && event.getEventTime() - guideMenuOpenedAt > GUIDE_HOLD_TAIL_MS) { + guideMenuOpenedAt = 0L; + handleNavigationBackPressed(); + } + return true; + } + if (kc == KeyEvent.KEYCODE_BUTTON_B) { + if (down) handleNavigationBackPressed(); return true; } if (down) { @@ -8032,13 +8056,22 @@ public boolean dispatchKeyEvent(KeyEvent event) { } if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) { - // Only the initial press toggles; ignore auto-repeat so holding doesn't spam open/close. - if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { - handleNavigationBackPressed(); + // Menu closed: hold the guide button to open (a quick tap does nothing). Timer-based, so a + // missed release can never leave it stuck. + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (!guideHoldPending) { + guideHoldPending = true; + handler.removeCallbacks(guideHoldOpenRunnable); + handler.postDelayed(guideHoldOpenRunnable, GUIDE_HOLD_OPEN_MS); + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + guideHoldPending = false; + handler.removeCallbacks(guideHoldOpenRunnable); } return true; } + if (event.getAction() == KeyEvent.ACTION_DOWN && (event.getKeyCode() == KeyEvent.KEYCODE_HOME || event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT)) { From 233d6c1649110eb4b712bc562e0693cf74ddc5e7 Mon Sep 17 00:00:00 2001 From: Xnick417x Date: Thu, 16 Jul 2026 02:20:40 +0000 Subject: [PATCH 8/8] Build: ship all translated locales (add missing es-419, fi, ja, no, pt, sv, th, tr to resConfigs) --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 9a83a7dea..5c62c9f7e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,7 +101,7 @@ android { abiFilters 'arm64-v8a' } - resConfigs "en", "da", "de", "es", "fr", "hi", "it", "ko", "pl", "pt-rBR", "ro", "ru", "uk", "zh-rCN", "zh-rTW" + resConfigs "en", "da", "de", "es", "b+es+419", "fi", "fr", "hi", "it", "ja", "ko", "no", "pl", "pt", "pt-rBR", "ro", "ru", "sv", "th", "tr", "uk", "zh-rCN", "zh-rTW" buildConfigField("String", "COLD_CLIENT_VERSION", "\"${coldClientVersion}\"") }