diff --git a/app/src/main/java/app/gamenative/data/SteamApp.kt b/app/src/main/java/app/gamenative/data/SteamApp.kt index e10598e7ae..33b7a4097c 100644 --- a/app/src/main/java/app/gamenative/data/SteamApp.kt +++ b/app/src/main/java/app/gamenative/data/SteamApp.kt @@ -165,6 +165,8 @@ data class SteamApp( get() = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/$id/$clientTgaHash.tga" val headerUrl: String get() = "https://shared.steamstatic.com/store_item_assets/steam/apps/$id/header.jpg" + val supportsCloudSaves: Boolean + get() = ufs.quota > 0 || ufs.maxNumFiles > 0 || ufs.saveFilePatterns.isNotEmpty() // source: https://github.com/Nemirtingas/games-infos/blob/3915100198bac34553b3c862f9e295d277f5520a/steam_retriever/Program.cs#L589C43-L589C89 fun getSmallCapsuleUrl(language: Language = Language.english): String? { diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 8febd684bf..c04375b9d4 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -1,5 +1,6 @@ package app.gamenative.events +import app.gamenative.ui.data.CloudSaveStatus import app.gamenative.ui.enums.Orientation import java.util.EnumSet @@ -23,6 +24,7 @@ interface AndroidEvent : Event { data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent data class PostInstallSyncStatusChanged(val appId: Int, val isSyncing: Boolean) : AndroidEvent data class LibraryInstallStatusChanged(val appId: Int) : AndroidEvent + data class CloudStatusChanged(val appId: Int, val status: CloudSaveStatus) : AndroidEvent data class CustomGameImagesFetched(val appId: String) : AndroidEvent data object RecommendationToggleChanged : AndroidEvent data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 3e1c90ddc0..62fe3a1910 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -6,9 +6,11 @@ import app.gamenative.R import app.gamenative.data.PostSyncInfo import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp +import app.gamenative.data.SteamFileHashCache import app.gamenative.data.UserFileInfo import app.gamenative.data.UserFilesDownloadResult import app.gamenative.data.UserFilesUploadResult +import app.gamenative.db.dao.SteamFileHashCacheDao import app.gamenative.enums.PathType import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult @@ -32,6 +34,7 @@ import java.nio.file.FileSystemException import java.nio.file.Path import java.nio.file.Paths import java.util.Date +import java.util.concurrent.atomic.AtomicInteger import java.util.stream.Collectors import java.util.zip.ZipInputStream import kotlin.io.path.name @@ -57,7 +60,6 @@ import java.io.IOException import java.io.OutputStream import java.net.SocketTimeoutException import java.nio.file.attribute.FileTime -import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong /** @@ -67,6 +69,11 @@ object SteamAutoCloud { private const val MAX_USER_FILE_RETRIES = 3 + internal data class HashLookupResult( + val sha: ByteArray, + val wasCacheHit: Boolean, + ) + /** Computes SHA-1 hash by streaming the file in chunks to avoid OOM on large files. */ private fun streamingShaHash(path: Path): ByteArray { val digest = MessageDigest.getInstance("SHA-1") @@ -100,6 +107,39 @@ object SteamAutoCloud { return total } + internal suspend fun getCachedShaOrHash( + appId: Int, + path: Path, + hashCacheDao: SteamFileHashCacheDao, + ): HashLookupResult { + val absPath = path.pathString + val sizeBytes = Files.size(path) + val mtimeMillis = Files.getLastModifiedTime(path).toMillis() + val cached = hashCacheDao.getByAppIdAndPath(appId, absPath) + + if (cached != null && cached.sizeBytes == sizeBytes && cached.mtimeMillis == mtimeMillis) { + return HashLookupResult( + sha = cached.sha, + wasCacheHit = true, + ) + } + + val sha = streamingShaHash(path) + hashCacheDao.insert( + SteamFileHashCache( + appId = appId, + absPath = absPath, + sizeBytes = sizeBytes, + mtimeMillis = mtimeMillis, + sha = sha, + ), + ) + return HashLookupResult( + sha = sha, + wasCacheHit = false, + ) + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -110,6 +150,7 @@ object SteamAutoCloud { prefixToPath: (String) -> String, overrideLocalChangeNumber: Long? = null, onProgress: ((message: String, progress: Float) -> Unit)? = null, + onPhaseStarted: ((isUploading: Boolean) -> Unit)? = null, ): Deferred = parentScope.async { val postSyncInfo: PostSyncInfo? @@ -202,6 +243,10 @@ object SteamAutoCloud { Paths.get(getFilePrefix(file, fileList), file.filename).pathString } + val hashCacheHits = AtomicInteger(0) + val hashCacheMisses = AtomicInteger(0) + val hashCacheDao = steamInstance.db.steamFileHashCacheDao() + val getFullFilePath: (AppFileInfo, AppFileChangeList) -> Path = getFullFilePath@{ file, fileList -> val gameInstallPrefix = "%${PathType.GameInstall.name}%" if (file.filename.startsWith(gameInstallPrefix)) { @@ -278,7 +323,7 @@ object SteamAutoCloud { } } - val getLocalUserFilesAsPrefixMap: () -> Map> = { + val getLocalUserFilesAsPrefixMap: suspend () -> Map> = { val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } val result = mutableMapOf>() @@ -294,27 +339,40 @@ object SteamAutoCloud { Timber.i("Looking for saves in $basePath with pattern ${userFile.pattern} (prefix ${userFile.prefix})") - val files = FileUtils.findFilesRecursive( + val filePaths = FileUtils.findFilesRecursive( rootPath = basePath, pattern = userFile.pattern, maxDepth = 5, - ).map { - val sha = streamingShaHash(it) + ).collect(Collectors.toList()) + val files = buildList { + for (path in filePaths) { + val hashLookup = getCachedShaOrHash( + appId = appInfo.id, + path = path, + hashCacheDao = hashCacheDao, + ) + if (hashLookup.wasCacheHit) { + hashCacheHits.incrementAndGet() + } else { + hashCacheMisses.incrementAndGet() + } + val sha = hashLookup.sha - Timber.i("Found ${it.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") + Timber.i("Found ${path.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") - val relativePath = basePath.relativize(it).pathString + val relativePath = basePath.relativize(path).pathString - UserFileInfo( - root = userFile.root, - path = userFile.substitutedPath, - filename = relativePath, - timestamp = Files.getLastModifiedTime(it).toMillis(), - sha = sha, - cloudRoot = userFile.uploadRoot, - cloudPath = userFile.uploadPath - ) - }.collect(Collectors.toList()) + add(UserFileInfo( + root = userFile.root, + path = userFile.substitutedPath, + filename = relativePath, + timestamp = Files.getLastModifiedTime(path).toMillis(), + sha = sha, + cloudRoot = userFile.uploadRoot, + cloudPath = userFile.uploadPath + )) + } + } Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") @@ -329,28 +387,41 @@ object SteamAutoCloud { Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") - val files = FileUtils.findFilesRecursive( + val steamUserDataPaths = FileUtils.findFilesRecursive( rootPath = basePath, pattern = "*", maxDepth = 5, - ).map { - val sha = streamingShaHash(it) - - val relativePath = basePath.relativize(it).pathString - - Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") - - // Store relative path in filename; empty path component - UserFileInfo( - root = rootType, - path = "", - filename = relativePath, - timestamp = Files.getLastModifiedTime(it).toMillis(), - sha = sha, - cloudRoot = rootType, - cloudPath = "" - ) - }.collect(Collectors.toList()) + ).collect(Collectors.toList()) + val files = buildList { + for (path in steamUserDataPaths) { + val hashLookup = getCachedShaOrHash( + appId = appInfo.id, + path = path, + hashCacheDao = hashCacheDao, + ) + if (hashLookup.wasCacheHit) { + hashCacheHits.incrementAndGet() + } else { + hashCacheMisses.incrementAndGet() + } + val sha = hashLookup.sha + + val relativePath = basePath.relativize(path).pathString + + Timber.i("Found ${path.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") + + // Store relative path in filename; empty path component + add(UserFileInfo( + root = rootType, + path = "", + filename = relativePath, + timestamp = Files.getLastModifiedTime(path).toMillis(), + sha = sha, + cloudRoot = rootType, + cloudPath = "" + )) + } + } Timber.i("Found ${files.size} file(s) in $basePath") @@ -361,6 +432,11 @@ object SteamAutoCloud { result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) } + Timber.i( + "Local save hash cache stats for ${appInfo.id} (${appInfo.name}): " + + "hits=${hashCacheHits.get()}, misses=${hashCacheMisses.get()}, files=${hashCacheHits.get() + hashCacheMisses.get()}", + ) + result } @@ -423,6 +499,7 @@ object SteamAutoCloud { val result = downloadSingleFile( appInfo = appInfo, steamCloud = steamCloud, + hashCacheDao = hashCacheDao, file = file, fileList = fileList, getFilePrefixPath = getFilePrefixPath, @@ -892,6 +969,7 @@ object SteamAutoCloud { Timber.i("No local changes but new cloud user files") + onPhaseStarted?.invoke(false) downloadUserFiles(parentScope).await()?.let { return@async it } @@ -901,11 +979,13 @@ object SteamAutoCloud { when (preferredSave) { SaveLocation.Local -> { // overwrite remote save with the local one + onPhaseStarted?.invoke(true) uploadUserFiles(parentScope).await() } SaveLocation.Remote -> { // overwrite local save with the remote one + onPhaseStarted?.invoke(false) downloadUserFiles(parentScope).await()?.let { return@async it } @@ -936,6 +1016,7 @@ object SteamAutoCloud { if (hasLocalChanges) { Timber.i("Found local changes and no new cloud user files") + onPhaseStarted?.invoke(true) uploadUserFiles(parentScope).await() } else { Timber.i("No local changes and no new cloud user files, doing nothing...") @@ -966,6 +1047,8 @@ object SteamAutoCloud { filesDownloaded = filesDownloaded, filesDeleted = filesDeleted, filesManaged = filesManaged, + hashCacheHits = hashCacheHits.get(), + hashCacheMisses = hashCacheMisses.get(), bytesUploaded = bytesUploaded, bytesDownloaded = bytesDownloaded, microsecTotal = microsecTotal, @@ -986,6 +1069,7 @@ object SteamAutoCloud { private suspend fun downloadSingleFile( appInfo: SteamApp, steamCloud: SteamCloud, + hashCacheDao: SteamFileHashCacheDao, file: AppFileInfo, fileList: AppFileChangeList, getFilePrefixPath: (AppFileInfo, AppFileChangeList) -> String, @@ -1136,6 +1220,21 @@ object SteamAutoCloud { return null } + val actualSize = Files.size(actualFilePath) + if (actualSize != totalFileSize) { + Timber.w("Downloaded size for $prefixedPath was $actualSize, expected $totalFileSize - skipping cache seed") + } else { + hashCacheDao.insert( + SteamFileHashCache( + appId = appInfo.id, + absPath = actualFilePath.pathString, + sizeBytes = actualSize, + mtimeMillis = Files.getLastModifiedTime(actualFilePath).toMillis(), + sha = streamingShaHash(actualFilePath), + ), + ) + } + val finishedFiles = completedFiles.incrementAndGet() val finalProgress = if (totalRawBytes > 0L) { (downloadedRawBytes.get().toFloat() / totalRawBytes).coerceIn(0f, 1f) @@ -1187,4 +1286,391 @@ object SteamAutoCloud { ) } } + + // --------------------------------------------------------------------------------------------- + // Cloud save status snapshot + // + // The helpers below are used by fetchSyncSnapshot() to compute a read-only cloud save status + // for the UI without starting a sync. They intentionally duplicate logic that lives inline in + // syncUserFiles() above so the sync code stays untouched relative to master. + // --------------------------------------------------------------------------------------------- + + private data class RemoteChangeDecision( + val hasLocalChanges: Boolean, + val rehydrateCache: Boolean, + val conflictUfsVersion: Int? = null, + val remoteTimestamp: Long = 0L, + val localTimestamp: Long = 0L, + ) + + private class CloudPathResolver( + private val appInfo: SteamApp, + private val prefixToPath: (String) -> String, + ) { + // When a rootoverride remaps a root (e.g. GameInstall → WinAppDataRoaming), the cloud + // still stores files under the original root placeholder (uploadRoot). Map those + // placeholders to the local root so downloads land in the right directory. + private val uploadRootRemap: Map = appInfo.ufs.saveFilePatterns + .filter { it.uploadRoot != it.root } + .associate { "%${it.uploadRoot.name}%" to it.root.name } + + // Full-prefix remap for patterns where addPath shifts the local subfolder relative to + // the cloud path. E.g. cloud "%GameInstall%saves" must land at "/MyGame/saves", + // not "/saves" — root-only replacement can't express this. + private val cloudPrefixToLocalPath: Map = appInfo.ufs.saveFilePatterns + .filter { it.uploadPath != it.path } + .associate { p -> + val cloudKey = "%${p.uploadRoot.name}%${p.uploadPath}" + .replace("{64BitSteamID}", SteamUtils.getSteamId64().toString()) + .replace("{Steam3AccountID}", SteamUtils.getSteam3AccountId().toString()) + .trimEnd('/') // keep consistent with the trimEnd done at lookup time + cloudKey to Paths.get(prefixToPath(p.root.name), p.substitutedPath).pathString + } + + private fun getPathTypePairs(fileList: AppFileChangeList): List> = + fileList.pathPrefixes + .map { + var matchResults = findPlaceholderWithin(it).map { it.value }.toList() + val bare = if (it.startsWith("ROOT_MOD")) listOf("ROOT_MOD") else emptyList() + + Timber.i("Mapping prefix $it and found $matchResults") + + if (matchResults.isEmpty()) { + matchResults = List(1) { PathType.DEFAULT.name } + } + + matchResults + bare + } + .flatten() + .distinct() + .map { placeholder -> + val localRootName = uploadRootRemap[placeholder] ?: placeholder + placeholder to prefixToPath(localRootName) + } + + private fun convertPrefixes(fileList: AppFileChangeList): List { + val pathTypePairs = getPathTypePairs(fileList) + + return fileList.pathPrefixes.map { prefix -> + // Full-prefix match first: handles addPath case where the cloud path omits a + // subfolder that the local path includes. Root-only replacement can't express this. + // Cloud prefixes sometimes include a trailing slash (e.g. "%WinAppDataLocalLow%76561198035529760/save1/") + // but the map keys are built without one — trim before lookup so they match. + cloudPrefixToLocalPath[prefix.trimEnd('/')] + ?: run { + var modified = prefix + + val prefixContainsNoPlaceholder = findPlaceholderWithin(prefix).none() + + if (prefixContainsNoPlaceholder) { + modified = Paths.get(PathType.DEFAULT.name, prefix).pathString + } + + pathTypePairs.forEach { + modified = modified.replace(it.first, it.second) + } + + // if the prefix has not been modified then there were no placeholders in it + // so we need to set it to point to the default path + if (modified == prefix) { + modified = Paths.get(prefixToPath(PathType.DEFAULT.name), modified).toString() + } + + modified + } + } + } + + fun getFilePrefix(file: AppFileInfo, fileList: AppFileChangeList): String { + return if (file.pathPrefixIndex < fileList.pathPrefixes.size) { + Paths.get(fileList.pathPrefixes[file.pathPrefixIndex]).pathString + } else { + "" + } + } + + fun getFilePrefixPath(file: AppFileInfo, fileList: AppFileChangeList): String = + Paths.get(getFilePrefix(file, fileList), file.filename).pathString + + fun getFullFilePath(file: AppFileInfo, fileList: AppFileChangeList): Path { + val gameInstallPrefix = "%${PathType.GameInstall.name}%" + if (file.filename.startsWith(gameInstallPrefix)) { + // Steam API sometimes returns prefix="" and filename="%GameInstall%save0.dat" instead of splitting correctly. + // Strip the embedded prefix (and any leading slash) to get the bare filename. + val stripped = file.filename.removePrefix(gameInstallPrefix).trimStart('/') + // If a Windows rootoverride remaps GameInstall → another directory (e.g. + // Danganronpa 2: WinMyDocuments/My Games/Danganronpa2/), download there instead + // of the raw game-install folder so the game can find its saves. + val remapped = cloudPrefixToLocalPath[gameInstallPrefix] + return if (remapped != null) { + Paths.get(remapped, stripped) + } else { + Paths.get(prefixToPath(PathType.GameInstall.name), stripped) + } + } + + val convertedPrefixes = convertPrefixes(fileList) + + return if (file.pathPrefixIndex < fileList.pathPrefixes.size) { + Paths.get(convertedPrefixes[file.pathPrefixIndex], file.filename) + } else { + // if the file does not reference any prefix then we need to set it to the default path + Paths.get(prefixToPath(PathType.DEFAULT.name), file.filename) + } + } + + fun fileChangeListToUserFiles(appFileListChange: AppFileChangeList): List { + val pathTypePairs = getPathTypePairs(appFileListChange) + + return appFileListChange.files.map { + UserFileInfo( + root = if (it.pathPrefixIndex < pathTypePairs.size) { + PathType.from(pathTypePairs[it.pathPrefixIndex].first) + } else { + PathType.GameInstall + }, + path = if (it.pathPrefixIndex < pathTypePairs.size) { + appFileListChange.pathPrefixes[it.pathPrefixIndex] + } else { + "" + }, + filename = it.filename, + timestamp = it.timestamp.time, + sha = it.shaFile, + ) + } + } + } + + private fun snapshotFilesDiff(currentFiles: List, oldFiles: List): Pair { + val overlappingFiles = currentFiles.filter { currentFile -> + oldFiles.any { currentFile.prefixPath == it.prefixPath } + } + + val newFiles = currentFiles.filter { currentFile -> + !oldFiles.any { currentFile.prefixPath == it.prefixPath } + } + + val deletedFiles = oldFiles.filter { oldFile -> + !currentFiles.any { oldFile.prefixPath == it.prefixPath } + } + + val modifiedFiles = overlappingFiles.filter { file -> + oldFiles.first { + it.prefixPath == file.prefixPath + }.let { + Timber.i("Comparing SHA of ${it.prefixPath} and ${file.prefixPath}") + Timber.i("[${it.sha.joinToString(", ")}]\n[${file.sha.joinToString(", ")}]") + + !it.sha.contentEquals(file.sha) + } + } + + val changesExist = newFiles.isNotEmpty() || deletedFiles.isNotEmpty() || modifiedFiles.isNotEmpty() + + return changesExist to FileChanges(deletedFiles, modifiedFiles, newFiles) + } + + private fun snapshotLocalUserFilesAsPrefixMap( + appInfo: SteamApp, + prefixToPath: (String) -> String, + ): Map> { + val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } + + val result = mutableMapOf>() + + if (savePatterns.isNotEmpty()) { + savePatterns.forEach { userFile -> + if (userFile.root == PathType.SteamUserData) { + // skip handling, use the logic below to scan SteamUserData + return@forEach + } + + val basePath = Paths.get(prefixToPath(userFile.root.toString()), userFile.substitutedPath) + + Timber.i("Looking for saves in $basePath with pattern ${userFile.pattern} (prefix ${userFile.prefix})") + + val files = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = userFile.pattern, + maxDepth = 5, + ).map { + val sha = streamingShaHash(it) + + Timber.i("Found ${it.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") + + val relativePath = basePath.relativize(it).pathString + + UserFileInfo( + root = userFile.root, + path = userFile.substitutedPath, + filename = relativePath, + timestamp = Files.getLastModifiedTime(it).toMillis(), + sha = sha, + cloudRoot = userFile.uploadRoot, + cloudPath = userFile.uploadPath + ) + }.collect(Collectors.toList()) + + Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") + + val prefixKey = Paths.get(userFile.prefix).pathString + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + } + } + + // Scan SteamUserData root recursively (depth 5) + val rootType = PathType.SteamUserData + val basePath = Paths.get(prefixToPath(rootType.toString())) + + Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") + + val files = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = "*", + maxDepth = 5, + ).map { + val sha = streamingShaHash(it) + + val relativePath = basePath.relativize(it).pathString + + Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") + + // Store relative path in filename; empty path component + UserFileInfo( + root = rootType, + path = "", + filename = relativePath, + timestamp = Files.getLastModifiedTime(it).toMillis(), + sha = sha, + cloudRoot = rootType, + cloudPath = "" + ) + }.collect(Collectors.toList()) + + Timber.i("Found ${files.size} file(s) in $basePath") + + mapOf(Paths.get("%${rootType.name}%").pathString to files) + + if (files.isNotEmpty()) { + val prefixKey = "%${rootType.name}%" + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + } + + return result + } + + private fun getRemoteChangeDecision( + hasCachedLocalChanges: Boolean, + cacheIsAbsentOrEmpty: Boolean, + allLocalUserFiles: List, + appFileListChange: AppFileChangeList, + prefixToPath: (String) -> String, + getFullFilePath: (AppFileInfo, AppFileChangeList) -> Path, + ): RemoteChangeDecision { + var hasLocalChanges = hasCachedLocalChanges + val hasUncachedLocalFiles = cacheIsAbsentOrEmpty && allLocalUserFiles.isNotEmpty() + if (hasUncachedLocalFiles) { + // no cache but local files exist. before declaring conflict, + // check if local state is byte-identical to remote — this is + // the "cache-wiped by destructive migration, nothing actually + // changed" case and should be silent. key by absolute filesystem + // path: cloud stores files as (pathPrefixIndex, basename) while + // local scan stores filename as subdir-relative path with a + // single pattern prefix, so basename-only keys won't match for + // nested files. + // windows paths are case-insensitive; steam cloud and wine may + // disagree on case. lowercase the keys so content-identical + // files compare equal regardless. + val localByPath = allLocalUserFiles.associate { + it.getAbsPath(prefixToPath).toString().lowercase() to it.sha + } + val remoteByPath = appFileListChange.files.associate { + getFullFilePath(it, appFileListChange).toString().lowercase() to it.shaFile + } + val localMatchesRemote = localByPath.keys == remoteByPath.keys && + localByPath.all { (path, sha) -> + sha.contentEquals(remoteByPath[path]) + } + + if (localMatchesRemote) { + Timber.i("Cache absent but local matches remote — rehydrating cache silently") + return RemoteChangeDecision(hasLocalChanges = false, rehydrateCache = true) + } else { + hasLocalChanges = true + return RemoteChangeDecision( + hasLocalChanges = hasLocalChanges, + rehydrateCache = false, + conflictUfsVersion = CURRENT_UFS_PARSE_VERSION, + remoteTimestamp = appFileListChange.files.map { it.timestamp.time }.maxOrNull() ?: 0L, + localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L, + ) + } + } + + return RemoteChangeDecision(hasLocalChanges = hasLocalChanges, rehydrateCache = false) + } + + internal data class CloudSyncSnapshot( + val cloudIsNewer: Boolean, + val localFilesMap: Map>, + val hasLocalChanges: Boolean, + val conflictUfsVersion: Int?, + val conflictLocalTimestamp: Long, + val conflictRemoteTimestamp: Long, + val cloudChangeNumber: Long, + val changeList: AppFileChangeList, + ) + + internal suspend fun fetchSyncSnapshot( + appInfo: SteamApp, + steamInstance: SteamService, + steamCloud: SteamCloud, + prefixToPath: (String) -> String, + ): CloudSyncSnapshot { + val localChangeNumber = steamInstance.changeNumbersDao.getByAppId(appInfo.id)?.changeNumber ?: -1 + val cachedFileList = steamInstance.fileChangeListsDao.getByAppId(appInfo.id) + val cacheIsAbsentOrEmpty = cachedFileList == null || cachedFileList.userFileInfo.isEmpty() + val changeNumber = if (!cacheIsAbsentOrEmpty && localChangeNumber >= 0) localChangeNumber else 0L + val changeList = steamCloud.getAppFileListChange(appInfo.id, changeNumber).await() + val cloudChangeNumber = changeList.currentChangeNumber + val localFilesMap = snapshotLocalUserFilesAsPrefixMap(appInfo, prefixToPath) + val allLocalFiles = localFilesMap.values.flatten() + val effectiveLocalChangeNumber = if (cacheIsAbsentOrEmpty && allLocalFiles.isNotEmpty()) { + -1L + } else { + localChangeNumber + } + + val pathResolver = CloudPathResolver(appInfo, prefixToPath) + val hasCachedLocalChanges = cachedFileList?.let { + snapshotFilesDiff(allLocalFiles, it.userFileInfo).first + } == true + val remoteChangeDecision = if (effectiveLocalChangeNumber < cloudChangeNumber) { + getRemoteChangeDecision( + hasCachedLocalChanges = hasCachedLocalChanges, + cacheIsAbsentOrEmpty = cacheIsAbsentOrEmpty, + allLocalUserFiles = allLocalFiles, + appFileListChange = changeList, + prefixToPath = prefixToPath, + getFullFilePath = pathResolver::getFullFilePath, + ) + } else { + RemoteChangeDecision( + hasLocalChanges = hasCachedLocalChanges, + rehydrateCache = false, + ) + } + + return CloudSyncSnapshot( + cloudIsNewer = effectiveLocalChangeNumber < cloudChangeNumber && !remoteChangeDecision.rehydrateCache, + localFilesMap = localFilesMap, + hasLocalChanges = remoteChangeDecision.hasLocalChanges, + conflictUfsVersion = remoteChangeDecision.conflictUfsVersion, + conflictLocalTimestamp = remoteChangeDecision.localTimestamp, + conflictRemoteTimestamp = remoteChangeDecision.remoteTimestamp, + cloudChangeNumber = cloudChangeNumber, + changeList = changeList, + ) + } } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index cd4cb08770..baed5c005a 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -49,6 +49,7 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent +import app.gamenative.ui.data.CloudSaveStatus import app.gamenative.utils.CaseInsensitiveFileSystem import app.gamenative.utils.ContainerUtils import app.gamenative.utils.FileUtils @@ -377,6 +378,7 @@ class SteamService : Service(), IChallengeUrlChanged { } private val syncInProgressApps = ConcurrentHashMap() + private val cloudSyncStatuses = ConcurrentHashMap() private fun getSyncFlag(appId: Int): AtomicBoolean { val existing = syncInProgressApps[appId] @@ -390,7 +392,12 @@ class SteamService : Service(), IChallengeUrlChanged { private fun tryAcquireSync(appId: Int): Boolean { val flag = getSyncFlag(appId) - return flag.compareAndSet(false, true) + val acquired = flag.compareAndSet(false, true) + if (acquired) { + cloudSyncStatuses[appId] = CloudSaveStatus.CHECKING + PluviaApp.events.emit(AndroidEvent.CloudStatusChanged(appId, CloudSaveStatus.CHECKING)) + } + return acquired } private fun releaseSync(appId: Int) { @@ -399,6 +406,49 @@ class SteamService : Service(), IChallengeUrlChanged { if (flag != null && !flag.get()) { syncInProgressApps.remove(appId, flag) } + cloudSyncStatuses.remove(appId) + } + + fun isSyncInProgress(appId: Int): Boolean { + return syncInProgressApps[appId]?.get() == true + } + + fun getActiveCloudSyncStatus(appId: Int): CloudSaveStatus? { + return cloudSyncStatuses[appId] + } + + fun isReadyForCloudOperations(): Boolean { + val service = instance ?: return false + return isConnected && + isLoggedIn && + !isStopping && + !isLoggingOut && + !isLoginInProgress && + service._steamCloud != null + } + + data class CloudSaveStatusResolution( + val status: CloudSaveStatus, + val conflictTimestamps: Pair?, + val conflictUfsVersion: Int?, + ) + + private fun markCloudSyncStarted(appId: Int, isUploading: Boolean) { + val status = if (isUploading) CloudSaveStatus.UPLOADING else CloudSaveStatus.DOWNLOADING + cloudSyncStatuses[appId] = status + PluviaApp.events.emit(AndroidEvent.CloudStatusChanged(appId, status)) + } + + private fun markCloudSyncFinished(appId: Int, result: SyncResult) { + val status = when (result) { + SyncResult.Success, + SyncResult.UpToDate, + -> CloudSaveStatus.UP_TO_DATE + SyncResult.Conflict -> CloudSaveStatus.CONFLICT + SyncResult.PendingOperations -> CloudSaveStatus.PENDING_OPERATIONS + else -> CloudSaveStatus.FAILED + } + PluviaApp.events.emit(AndroidEvent.CloudStatusChanged(appId, status)) } // Track whether a game is currently running to prevent premature service stop @@ -2342,13 +2392,12 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } + var syncResult = PostSyncInfo(SyncResult.UnknownFail) try { val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) // Migrate GSE Saves to Steam userdata SteamUtils.migrateGSESavesToSteamUserdata(context, appId) - var syncResult = PostSyncInfo(SyncResult.UnknownFail) - val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { @@ -2365,6 +2414,9 @@ class SteamService : Service(), IChallengeUrlChanged { parentScope = parentScope, prefixToPath = prefixToPath, onProgress = onProgress, + onPhaseStarted = { isUploading -> + markCloudSyncStarted(appId, isUploading) + }, ).await() postSyncInfo?.let { info -> @@ -2416,10 +2468,78 @@ class SteamService : Service(), IChallengeUrlChanged { } } - return@async syncResult } finally { + markCloudSyncFinished(appId, syncResult.syncResult) releaseSync(appId) } + return@async syncResult + } + + suspend fun resolveCloudSaveStatus( + appId: Int, + prefixToPath: (String) -> String, + ): CloudSaveStatusResolution = withContext(Dispatchers.IO) { + if (!isReadyForCloudOperations()) { + return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null) + } + + val steamInstance = instance ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null) + val steamCloud = steamInstance._steamCloud ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null) + val appInfo = steamInstance.appDao.findApp(appId) ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null) + + val snapshot = try { + SteamAutoCloud.fetchSyncSnapshot(appInfo, steamInstance, steamCloud, prefixToPath) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.tag("SteamCloudSaveStatus").w(e, "Failed to resolve cloud status for appId=$appId") + return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null) + } + + val status = when { + snapshot.cloudIsNewer && snapshot.hasLocalChanges -> CloudSaveStatus.CONFLICT + snapshot.cloudIsNewer -> CloudSaveStatus.PENDING_DOWNLOAD + snapshot.hasLocalChanges -> CloudSaveStatus.PENDING_UPLOAD + snapshot.localFilesMap.values.none { it.isNotEmpty() } && snapshot.cloudChangeNumber > 0L -> CloudSaveStatus.PENDING_DOWNLOAD + else -> CloudSaveStatus.UP_TO_DATE + } + + val conflictTimestamps = if (status == CloudSaveStatus.CONFLICT) { + val localTs = snapshot.conflictLocalTimestamp.takeIf { it > 0L } + ?: snapshot.localFilesMap.values.flatten().maxOfOrNull { it.timestamp } + ?: 0L + val remoteTs = snapshot.conflictRemoteTimestamp.takeIf { it > 0L } + ?: snapshot.changeList.files.maxOfOrNull { it.timestamp.time } + ?: 0L + localTs to remoteTs + } else { + null + } + + CloudSaveStatusResolution( + status = status, + conflictTimestamps = conflictTimestamps, + conflictUfsVersion = snapshot.conflictUfsVersion, + ) + } + + fun buildPrefixToPath(context: Context, appId: Int, accountId: Long): (String) -> String { + val container = ContainerUtils.getContainer(context, "STEAM_$appId") + return { prefix -> + PathType.from(prefix).toAbsPath(container, appId, accountId) + } + } + + suspend fun launchForceSync( + context: Context, + appId: Int, + preferredSave: SaveLocation = SaveLocation.None, + ) { + val accountId = userSteamId?.accountID ?: return + val container = ContainerUtils.getOrCreateContainer(context, "STEAM_$appId") + ContainerManager(context).activateContainer(container) + val prefixToPath = buildPrefixToPath(context, appId, accountId) + forceSyncUserFiles(appId = appId, prefixToPath = prefixToPath, preferredSave = preferredSave).await() } suspend fun forceSyncUserFiles( @@ -2434,13 +2554,12 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } + var syncResult = PostSyncInfo(SyncResult.UnknownFail) try { val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) // Migrate GSE Saves to Steam userdata SteamUtils.migrateGSESavesToSteamUserdata(context, appId) - var syncResult = PostSyncInfo(SyncResult.UnknownFail) - val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { @@ -2457,6 +2576,9 @@ class SteamService : Service(), IChallengeUrlChanged { parentScope = parentScope, prefixToPath = prefixToPath, overrideLocalChangeNumber = overrideLocalChangeNumber, + onPhaseStarted = { isUploading -> + markCloudSyncStarted(appId, isUploading) + }, ).await() postSyncInfo?.let { info -> @@ -2478,10 +2600,11 @@ class SteamService : Service(), IChallengeUrlChanged { } } - return@async syncResult } finally { + markCloudSyncFinished(appId, syncResult.syncResult) releaseSync(appId) } + return@async syncResult } suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { @@ -2516,8 +2639,14 @@ class SteamService : Service(), IChallengeUrlChanged { steamCloud = steamCloud, parentScope = this, prefixToPath = prefixToPath, + onPhaseStarted = { isUploading -> + markCloudSyncStarted(appId, isUploading) + }, ).await() + postSyncInfo?.let { info -> + markCloudSyncFinished(appId, info.syncResult) + } steamCloud.signalAppExitSyncDone( appId = appId, clientId = clientId, diff --git a/app/src/main/java/app/gamenative/ui/data/CloudSaveStatus.kt b/app/src/main/java/app/gamenative/ui/data/CloudSaveStatus.kt new file mode 100644 index 0000000000..c064aa8bb4 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/data/CloudSaveStatus.kt @@ -0,0 +1,35 @@ +package app.gamenative.ui.data + +import android.content.Context +import app.gamenative.R + +enum class CloudSaveStatus { + CHECKING, + DOWNLOADING, + UPLOADING, + UP_TO_DATE, + PENDING_DOWNLOAD, + PENDING_UPLOAD, + PENDING_OPERATIONS, + FAILED, + CONFLICT, + OFFLINE; + + val isActive: Boolean + get() = this == CHECKING || this == DOWNLOADING || this == UPLOADING +} + +fun CloudSaveStatus.toDisplayString(context: Context): String = context.getString( + when (this) { + CloudSaveStatus.CHECKING -> R.string.cloud_saves_checking + CloudSaveStatus.DOWNLOADING -> R.string.cloud_saves_downloading + CloudSaveStatus.UPLOADING -> R.string.cloud_saves_uploading + CloudSaveStatus.UP_TO_DATE -> R.string.cloud_saves_up_to_date + CloudSaveStatus.PENDING_DOWNLOAD -> R.string.cloud_saves_pending_download + CloudSaveStatus.PENDING_UPLOAD -> R.string.cloud_saves_pending_upload + CloudSaveStatus.PENDING_OPERATIONS -> R.string.cloud_saves_pending_operations + CloudSaveStatus.FAILED -> R.string.cloud_saves_failed + CloudSaveStatus.CONFLICT -> R.string.cloud_saves_conflict + CloudSaveStatus.OFFLINE -> R.string.cloud_saves_offline + }, +) diff --git a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt index 8aead2a8bf..ce9415e15a 100644 --- a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt +++ b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt @@ -22,5 +22,10 @@ data class GameDisplayInfo( val headerUrl: String? = null, // Header image URL (for SteamGridDB, can use grid as header) val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU") val compatibilityColor: ULong? = null, // Compatibility message color (ARGB) + val hasCloudSaves: Boolean? = null, + val lastSyncStateText: String? = null, + val cloudSaveStatus: CloudSaveStatus? = null, + val conflictLocalTimestamp: Long? = null, + val conflictRemoteTimestamp: Long? = null, + val conflictUfsVersion: Int? = null, ) - diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 937d077b40..490472e5d3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -46,17 +46,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.SyncProblem import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -102,12 +110,15 @@ import app.gamenative.NetworkMonitor import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.LibraryItem +import app.gamenative.enums.SaveLocation import app.gamenative.service.SteamService import app.gamenative.ui.component.GamepadAction import app.gamenative.ui.component.GamepadActionBar import app.gamenative.ui.component.GamepadButton import app.gamenative.ui.component.LoadingScreen +import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.CloudSaveStatus import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.internal.fakeAppInfo @@ -493,6 +504,160 @@ private fun formatBytes(bytes: Long): String { } } +@Composable +private fun CloudSaveStatusRow( + displayInfo: GameDisplayInfo, + onForceCloudSync: ((SaveLocation) -> Unit)?, + modifier: Modifier = Modifier, +) { + val showConflictDialog = remember { mutableStateOf(false) } + val (cloudIcon, cloudColor) = when (displayInfo.cloudSaveStatus) { + CloudSaveStatus.UP_TO_DATE -> + Icons.Default.CloudDone to PluviaTheme.colors.statusInstalled + CloudSaveStatus.DOWNLOADING, CloudSaveStatus.PENDING_DOWNLOAD -> + Icons.Default.CloudDownload to Color(0xFFFFA726) + CloudSaveStatus.UPLOADING, CloudSaveStatus.PENDING_UPLOAD -> + Icons.Default.CloudUpload to Color(0xFFFFA726) + CloudSaveStatus.PENDING_OPERATIONS -> + Icons.Default.Sync to Color(0xFFFFA726) + CloudSaveStatus.FAILED, CloudSaveStatus.CONFLICT -> + Icons.Default.CloudOff to MaterialTheme.colorScheme.error + CloudSaveStatus.OFFLINE -> + Icons.Default.CloudOff to Color.White.copy(alpha = 0.45f) + else -> + Icons.Default.Cloud to Color.White.copy(alpha = 0.7f) + } + + Row( + modifier = modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = cloudIcon, + contentDescription = null, + tint = cloudColor, + modifier = Modifier.size(20.dp), + ) + Text( + text = displayInfo.lastSyncStateText ?: stringResource(R.string.game_options_cloud_saves), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + CloudSaveActionButton( + status = displayInfo.cloudSaveStatus, + onForceCloudSync = onForceCloudSync, + onShowConflictDialog = { showConflictDialog.value = true }, + ) + } + + CloudSaveConflictDialog( + visible = showConflictDialog.value, + displayInfo = displayInfo, + onForceCloudSync = onForceCloudSync, + onDismiss = { showConflictDialog.value = false }, + ) +} + +@Composable +private fun CloudSaveActionButton( + status: CloudSaveStatus?, + onForceCloudSync: ((SaveLocation) -> Unit)?, + onShowConflictDialog: () -> Unit, +) { + if (onForceCloudSync == null) return + + val syncButtonState = when (status) { + CloudSaveStatus.PENDING_DOWNLOAD, + CloudSaveStatus.PENDING_UPLOAD, + CloudSaveStatus.PENDING_OPERATIONS, + -> Triple(Icons.Default.Sync, Color(0xFF3E2800), Color(0xFFFFA726)) + CloudSaveStatus.CONFLICT -> + Triple(Icons.Default.SyncProblem, MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError) + else -> null + } ?: return + + val (syncIcon, containerColor, contentColor) = syncButtonState + FilledTonalIconButton( + onClick = { + if (status == CloudSaveStatus.CONFLICT) { + onShowConflictDialog() + } else { + onForceCloudSync(SaveLocation.None) + } + }, + modifier = Modifier.size(32.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = containerColor.copy(alpha = 0.25f), + contentColor = contentColor, + ), + ) { + Icon( + imageVector = syncIcon, + contentDescription = stringResource(R.string.cloud_saves_force_sync), + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun CloudSaveConflictDialog( + visible: Boolean, + displayInfo: GameDisplayInfo, + onForceCloudSync: ((SaveLocation) -> Unit)?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val localDate = remember(displayInfo.conflictLocalTimestamp) { + displayInfo.conflictLocalTimestamp?.let { Date(it).toString() } ?: "" + } + val remoteDate = remember(displayInfo.conflictRemoteTimestamp) { + displayInfo.conflictRemoteTimestamp?.let { Date(it).toString() } ?: "" + } + val conflictTitleAndMessage = displayInfo.conflictUfsVersion + ?.let { version -> + val titleId = context.resources.getIdentifier( + "main_save_conflict_upgrade_v${version}_title", + "string", + context.packageName, + ) + val messageId = context.resources.getIdentifier( + "main_save_conflict_upgrade_v${version}_message", + "string", + context.packageName, + ) + if (titleId != 0 && messageId != 0) { + context.getString(titleId) to context.getString(messageId, localDate, remoteDate) + } else { + null + } + } + ?: ( + context.getString(R.string.main_save_conflict_title) to + context.getString(R.string.main_save_conflict_message, localDate, remoteDate) + ) + + MessageDialog( + visible = visible, + title = conflictTitleAndMessage.first, + message = conflictTitleAndMessage.second, + confirmBtnText = stringResource(R.string.main_keep_remote), + dismissBtnText = stringResource(R.string.main_keep_local), + onConfirmClick = { + onDismiss() + onForceCloudSync?.invoke(SaveLocation.Remote) + }, + onDismissClick = { + onDismiss() + onForceCloudSync?.invoke(SaveLocation.Local) + }, + onDismissRequest = onDismiss, + ) +} + @Composable internal fun AppScreenContent( modifier: Modifier = Modifier, @@ -509,6 +674,7 @@ internal fun AppScreenContent( onDeleteDownloadClick: () -> Unit, onUpdateClick: () -> Unit, onBack: () -> Unit = {}, + onForceCloudSync: ((SaveLocation) -> Unit)? = null, vararg optionsMenu: AppMenuOption, ) { val context = LocalContext.current @@ -879,7 +1045,16 @@ internal fun AppScreenContent( } } } else { - Spacer(modifier = Modifier.weight(1f)) + if (isInstalled && displayInfo.hasCloudSaves == true) { + CloudSaveStatusRow( + displayInfo = displayInfo, + onForceCloudSync = onForceCloudSync, + modifier = Modifier + .weight(1f) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } } // Secondary action icons (right-aligned) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 6ef81c5847..b5ee6fbf1e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -287,7 +288,7 @@ private fun LibraryScreenContent( } } - var selectedAppId by remember { mutableStateOf(null) } + var selectedAppId by rememberSaveable { mutableStateOf(null) } val carouselListState = rememberLazyListState() val isViewWide = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE var currentPaneType by remember { mutableStateOf(PrefManager.libraryLayout) } @@ -314,6 +315,9 @@ private fun LibraryScreenContent( var wasOptionsPanelOpen by remember { mutableStateOf(false) } // Keep a stable reference to the selected item so detail view doesn't disappear during list refresh/pagination. var selectedLibraryItem by remember { mutableStateOf(null) } + val currentSelectedLibraryItem = selectedLibraryItem ?: selectedAppId?.let { appId -> + state.appInfoList.find { it.appId == appId } + } val filterFabExpanded by remember(currentPaneType, listState, carouselListState) { derivedStateOf { if (currentPaneType == PaneType.CAROUSEL) { @@ -442,11 +446,21 @@ private fun LibraryScreenContent( onSearchQuery("") } - BackHandler(selectedLibraryItem != null) { + BackHandler(selectedAppId != null) { selectedAppId = null selectedLibraryItem = null } + LaunchedEffect(selectedAppId, state.appInfoList) { + if (selectedAppId == null) { + selectedLibraryItem = null + } else { + state.appInfoList.find { it.appId == selectedAppId }?.let { resolvedItem -> + selectedLibraryItem = resolvedItem + } + } + } + // Restore focus when returning from game detail (without reloading list) LaunchedEffect(selectedAppId) { if (selectedAppId != null) { @@ -478,7 +492,7 @@ private fun LibraryScreenContent( // The detail (game) page deliberately does NOT use this — the hero image is meant // to bleed through the cutout, so AppScreenContent insets only the elements that // need to stay tappable (e.g. the back button) instead. - val safePaddingModifier = if (selectedLibraryItem == null) { + val safePaddingModifier = if (currentSelectedLibraryItem == null) { Modifier.windowInsetsPadding( WindowInsets.statusBars .union(WindowInsets.displayCutout) @@ -994,18 +1008,18 @@ private fun LibraryScreenContent( } } else { LibraryDetailPane( - libraryItem = selectedLibraryItem, + libraryItem = currentSelectedLibraryItem, onBack = { selectedAppId = null selectedLibraryItem = null }, onClickPlay = { - selectedLibraryItem?.let { libraryItem -> + currentSelectedLibraryItem?.let { libraryItem -> onClickPlay(libraryItem.appId, it) } }, onTestGraphics = { - selectedLibraryItem?.let { libraryItem -> + currentSelectedLibraryItem?.let { libraryItem -> onTestGraphics(libraryItem.appId) } }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 5510869c31..23e3335dbe 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -27,6 +27,7 @@ import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.enums.SaveLocation import app.gamenative.events.AndroidEvent import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.data.AppMenuOption @@ -548,6 +549,8 @@ abstract class BaseAppScreen { uri: android.net.Uri, ): Boolean = false + protected open fun getForceCloudSync(context: Context, libraryItem: LibraryItem): ((SaveLocation) -> Unit)? = null + /** * Get config-related menu options (e.g. Export config, Import config). * By default returns only Export config when supported; sources can override @@ -1213,6 +1216,7 @@ abstract class BaseAppScreen { } }, onBack = onBack, + onForceCloudSync = getForceCloudSync(context, libraryItem), optionsMenu = optionsMenu.toTypedArray(), ) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index de85f036d8..90195e5696 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -39,10 +39,13 @@ import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.LibraryItem +import app.gamenative.enums.LoginResult import app.gamenative.enums.Marker import app.gamenative.enums.PathType +import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent +import app.gamenative.events.SteamEvent import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirPath @@ -50,7 +53,9 @@ import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.CloudSaveStatus import app.gamenative.ui.data.GameDisplayInfo +import app.gamenative.ui.data.toDisplayString import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.enums.DialogType import app.gamenative.utils.ContainerUtils @@ -338,6 +343,93 @@ class SteamAppScreen : BaseAppScreen() { gameName = appInfo.name, ) + val hasCloudSaves = appInfo.supportsCloudSaves && !ContainerUtils.isLocalSavesOnly(context, libraryItem.appId) + val cloudConnectivityVersion = remember(gameId) { mutableStateOf(0) } + val syncStateText = remember(gameId) { mutableStateOf(null) } + val cloudSaveStatus = remember(gameId) { mutableStateOf(null) } + val conflictLocalTimestamp = remember(gameId) { mutableStateOf(null) } + val conflictRemoteTimestamp = remember(gameId) { mutableStateOf(null) } + val conflictUfsVersion = remember(gameId) { mutableStateOf(null) } + + DisposableEffect(gameId) { + val setOffline = { + cloudSaveStatus.value = CloudSaveStatus.OFFLINE + syncStateText.value = CloudSaveStatus.OFFLINE.toDisplayString(context) + } + val onLogonEnded: (SteamEvent.LogonEnded) -> Unit = { event -> + if (event.loginResult == LoginResult.Success) { + cloudConnectivityVersion.value++ + } else { + setOffline() + } + } + val onDisconnected: (SteamEvent.Disconnected) -> Unit = { setOffline() } + val onRemotelyDisconnected: (SteamEvent.RemotelyDisconnected) -> Unit = { setOffline() } + val onCloudStatusChanged: (AndroidEvent.CloudStatusChanged) -> Unit = { event -> + if (event.appId == gameId) { + cloudSaveStatus.value = event.status + syncStateText.value = event.status.toDisplayString(context) + if (event.status == CloudSaveStatus.FAILED || event.status == CloudSaveStatus.CONFLICT) { + cloudConnectivityVersion.value++ + } + } + } + PluviaApp.events.on(onLogonEnded) + PluviaApp.events.on(onDisconnected) + PluviaApp.events.on(onRemotelyDisconnected) + PluviaApp.events.on(onCloudStatusChanged) + onDispose { + PluviaApp.events.off(onLogonEnded) + PluviaApp.events.off(onDisconnected) + PluviaApp.events.off(onRemotelyDisconnected) + PluviaApp.events.off(onCloudStatusChanged) + } + } + + LaunchedEffect(gameId, cloudConnectivityVersion.value, hasCloudSaves, isInstalled) { + if (!hasCloudSaves || !isInstalled) return@LaunchedEffect + if (!SteamService.isReadyForCloudOperations()) { + cloudSaveStatus.value = CloudSaveStatus.OFFLINE + syncStateText.value = CloudSaveStatus.OFFLINE.toDisplayString(context) + return@LaunchedEffect + } + SteamService.getActiveCloudSyncStatus(gameId)?.let { activeStatus -> + cloudSaveStatus.value = activeStatus + syncStateText.value = activeStatus.toDisplayString(context) + return@LaunchedEffect + } + if (cloudSaveStatus.value == null) { + cloudSaveStatus.value = CloudSaveStatus.CHECKING + syncStateText.value = CloudSaveStatus.CHECKING.toDisplayString(context) + } + val accountId = SteamService.userSteamId?.accountID ?: PrefManager.steamUserAccountId.toLong() + val prefixToPath = runCatching { + SteamService.buildPrefixToPath(context, gameId, accountId) + }.getOrElse { + cloudSaveStatus.value = CloudSaveStatus.OFFLINE + syncStateText.value = CloudSaveStatus.OFFLINE.toDisplayString(context) + return@LaunchedEffect + } + val cloudStatusResolution = SteamService.resolveCloudSaveStatus(gameId, prefixToPath) + if (SteamService.isSyncInProgress(gameId)) return@LaunchedEffect + conflictUfsVersion.value = cloudStatusResolution.conflictUfsVersion + if (cloudStatusResolution.status == CloudSaveStatus.CONFLICT) { + cloudStatusResolution.conflictTimestamps?.let { (local, remote) -> + conflictLocalTimestamp.value = local + conflictRemoteTimestamp.value = remote + } ?: run { + conflictLocalTimestamp.value = null + conflictRemoteTimestamp.value = null + } + } else { + conflictLocalTimestamp.value = null + conflictRemoteTimestamp.value = null + conflictUfsVersion.value = null + } + cloudSaveStatus.value = cloudStatusResolution.status + syncStateText.value = cloudStatusResolution.status.toDisplayString(context) + } + return GameDisplayInfo( name = appInfo.name, developer = appInfo.developer, @@ -353,6 +445,12 @@ class SteamAppScreen : BaseAppScreen() { playtimeText = playtimeText, compatibilityMessage = compatibilityMessage, compatibilityColor = compatibilityColor, + hasCloudSaves = hasCloudSaves, + lastSyncStateText = syncStateText.value, + cloudSaveStatus = cloudSaveStatus.value, + conflictLocalTimestamp = conflictLocalTimestamp.value, + conflictRemoteTimestamp = conflictRemoteTimestamp.value, + conflictUfsVersion = conflictUfsVersion.value, ) } @@ -841,6 +939,18 @@ class SteamAppScreen : BaseAppScreen() { return options } + override fun getForceCloudSync(context: Context, libraryItem: LibraryItem): ((SaveLocation) -> Unit) = { saveLocation -> + if (PrefManager.usageAnalyticsEnabled) { + PostHog.capture( + event = "cloud_sync_forced", + properties = mapOf("game_name" to libraryItem.name), + ) + } + CoroutineScope(Dispatchers.IO).launch { + SteamService.launchForceSync(context, libraryItem.gameId, saveLocation) + } + } + override fun loadContainerData(context: Context, libraryItem: LibraryItem): ContainerData { val container = ContainerUtils.getOrCreateContainer(context, libraryItem.appId) return ContainerUtils.toContainerData(container) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 5fa1f8789d..cf18016e19 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1013,6 +1013,7 @@ Spilstyring Container Cloud-gemmer + Afventende fjernhandling Hjælp & info diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f5018f3155..368edc2bd9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1128,6 +1128,7 @@ Spielverwaltung Container Cloud-Speicher + Remote-Vorgang ausstehend Hilfe & Info diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2c4b6ad354..9a5b187589 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1202,6 +1202,7 @@ Gestión del juego Contenedor Guardados en la nube + Operación remota pendiente Ayuda e información diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1ca0954bf9..93ced0b97d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1186,6 +1186,7 @@ Gestion du jeu Conteneur Sauvegardes cloud + Opération distante en attente Aide & infos diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cebe4470ae..a6c5994748 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1182,6 +1182,7 @@ Gestione gioco Contenitore Salvataggi cloud + Operazione remota in sospeso Aiuto & info diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 35fc025f5f..5233f56243 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1200,6 +1200,7 @@ 게임 관리 컨테이너 클라우드 저장 + 대기 중인 원격 작업 도움말 및 정보 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 13a6da0015..68eef7e7bc 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1199,6 +1199,7 @@ Zarządzanie grą Kontener Zapisy w chmurze + Oczekująca operacja zdalna Pomoc i informacje diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 9b35d0b033..ae101147ab 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1013,6 +1013,7 @@ Gerenciamento do jogo Contêiner Salvamentos na nuvem + Operação remota pendente Ajuda e informações diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 0a94462744..3f875fa928 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1190,6 +1190,7 @@ Gestionare jocuri Container Salvări cloud + Operațiune la distanță în așteptare Ajutor & Info diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 264169b1a9..455ca25c20 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -437,6 +437,7 @@ Нажмите назад ещё раз для выхода Игра не установлена Облачные сохранения + Ожидается удалённая операция Контейнер Управление игрой Справка и информация diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a98b0949e5..7031275702 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1185,6 +1185,7 @@ Керування грою Контейнер Хмарні збереження + Очікується віддалена операція Довідка та інформація diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index da9d984a8d..aa16b124d5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1180,6 +1180,7 @@ 游戏管理 容器 云存档 + 远程操作待处理 帮助与信息 @@ -1541,4 +1542,4 @@ 提示:如果你使用的是 Mali GPU,请使用系统驱动。 提示:出现黑屏?尝试使用菜单中的\"%s\"选项检查驱动是否正常工作。 启用在线游玩并提升兼容性\n不一定总能正常工作\n尝试前请先备份存档 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1c37c18520..ff9363c63d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1183,6 +1183,7 @@ 遊戲管理 容器 雲端存檔 + 遠端操作待處理 幫助與資訊 @@ -1533,4 +1534,4 @@ 提示:如果你使用的是 Mali GPU,請使用系統驅動。 提示:出現黑畫面?嘗試使用選單中的「%s」選項檢查驅動是否正常運作。 啟用線上遊玩並提升相容性\n不一定總能正常運作\n嘗試前請先備份存檔 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 69074d0826..1a093ddac8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1245,6 +1245,17 @@ Game Management Container Cloud Saves + Up to Date + Pending Download + Pending Upload + Remote Operation Pending + Checking… + Downloading… + Uploading… + Conflict + Failed + Offline + Force Sync Help & Info