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 a8e2ae59d9..0ac56e4ec4 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -74,6 +74,14 @@ object SteamAutoCloud { val wasCacheHit: Boolean, ) + private data class RemoteChangeDecision( + val hasLocalChanges: Boolean, + val rehydrateCache: Boolean, + val conflictUfsVersion: Int? = null, + val remoteTimestamp: Long = 0L, + val localTimestamp: Long = 0L, + ) + /** 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") @@ -140,32 +148,21 @@ object SteamAutoCloud { ) } - fun syncUserFiles( - appInfo: SteamApp, - clientId: Long, - steamInstance: SteamService, - steamCloud: SteamCloud, - preferredSave: SaveLocation = SaveLocation.None, - parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), - prefixToPath: (String) -> String, - overrideLocalChangeNumber: Long? = null, - onProgress: ((message: String, progress: Float) -> Unit)? = null, - ): Deferred = parentScope.async { - val postSyncInfo: PostSyncInfo? - - Timber.i("Retrieving save files of ${appInfo.name}") - + 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. - val uploadRootRemap: Map = appInfo.ufs.saveFilePatterns + 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. - val cloudPrefixToLocalPath: Map = appInfo.ufs.saveFilePatterns + private val cloudPrefixToLocalPath: Map = appInfo.ufs.saveFilePatterns .filter { it.uploadPath != it.path } .associate { p -> val cloudKey = "%${p.uploadRoot.name}%${p.uploadPath}" @@ -175,7 +172,7 @@ object SteamAutoCloud { cloudKey to Paths.get(prefixToPath(p.root.name), p.substitutedPath).pathString } - val getPathTypePairs: (AppFileChangeList) -> List> = { fileList -> + private fun getPathTypePairs(fileList: AppFileChangeList): List> = fileList.pathPrefixes .map { var matchResults = findPlaceholderWithin(it).map { it.value }.toList() @@ -195,12 +192,11 @@ object SteamAutoCloud { val localRootName = uploadRootRemap[placeholder] ?: placeholder placeholder to prefixToPath(localRootName) } - } - val convertPrefixes: (AppFileChangeList) -> List = { fileList -> + private fun convertPrefixes(fileList: AppFileChangeList): List { val pathTypePairs = getPathTypePairs(fileList) - fileList.pathPrefixes.map { prefix -> + 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/") @@ -230,23 +226,18 @@ object SteamAutoCloud { } } - val getFilePrefix: (AppFileInfo, AppFileChangeList) -> String = { file, fileList -> - if (file.pathPrefixIndex < fileList.pathPrefixes.size) { + fun getFilePrefix(file: AppFileInfo, fileList: AppFileChangeList): String { + return if (file.pathPrefixIndex < fileList.pathPrefixes.size) { Paths.get(fileList.pathPrefixes[file.pathPrefixIndex]).pathString } else { "" } } - val getFilePrefixPath: (AppFileInfo, AppFileChangeList) -> String = { file, fileList -> + fun getFilePrefixPath(file: AppFileInfo, fileList: AppFileChangeList): String = 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 -> + 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. @@ -256,7 +247,7 @@ object SteamAutoCloud { // 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@getFullFilePath if (remapped != null) { + return if (remapped != null) { Paths.get(remapped, stripped) } else { Paths.get(prefixToPath(PathType.GameInstall.name), stripped) @@ -265,7 +256,7 @@ object SteamAutoCloud { val convertedPrefixes = convertPrefixes(fileList) - if (file.pathPrefixIndex < fileList.pathPrefixes.size) { + 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 @@ -273,193 +264,341 @@ object SteamAutoCloud { } } - val getFilesDiff: (List, List) -> Pair = { currentFiles, oldFiles -> - val overlappingFiles = currentFiles.filter { currentFile -> - oldFiles.any { currentFile.prefixPath == it.prefixPath } - } + fun fileChangeListToUserFiles(appFileListChange: AppFileChangeList): List { + val pathTypePairs = getPathTypePairs(appFileListChange) - val newFiles = currentFiles.filter { currentFile -> - !oldFiles.any { currentFile.prefixPath == it.prefixPath } + 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, + ) } + } + } - val deletedFiles = oldFiles.filter { oldFile -> - !currentFiles.any { oldFile.prefixPath == it.prefixPath } - } + private fun getFilesDiff(currentFiles: List, oldFiles: List): Pair { + val overlappingFiles = currentFiles.filter { currentFile -> + oldFiles.any { currentFile.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(", ")}]") + val newFiles = currentFiles.filter { currentFile -> + !oldFiles.any { currentFile.prefixPath == it.prefixPath } + } - !it.sha.contentEquals(file.sha) - } - } + val deletedFiles = oldFiles.filter { oldFile -> + !currentFiles.any { oldFile.prefixPath == it.prefixPath } + } - val changesExist = newFiles.isNotEmpty() || deletedFiles.isNotEmpty() || modifiedFiles.isNotEmpty() + 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(", ")}]") - changesExist to FileChanges(deletedFiles, modifiedFiles, newFiles) + !it.sha.contentEquals(file.sha) + } } - val hasHashConflicts: (Map>, AppFileChangeList) -> Boolean = - { localUserFiles, fileList -> - fileList.files.any { file -> - Timber.i("Checking for " + "${getFilePrefix(file, fileList)} in ${localUserFiles.keys}") + val changesExist = newFiles.isNotEmpty() || deletedFiles.isNotEmpty() || modifiedFiles.isNotEmpty() - localUserFiles[getFilePrefix(file, fileList)]?.let { localUserFile -> - localUserFile.firstOrNull { - Timber.i("Comparing ${file.filename} and ${it.filename}") + return changesExist to FileChanges(deletedFiles, modifiedFiles, newFiles) + } - it.filename == file.filename - }?.let { - Timber.i("Comparing SHA of ${getFilePrefixPath(file, fileList)} and ${it.prefixPath}") - Timber.i("[${file.shaFile.joinToString(", ")}]\n[${it.sha.joinToString(", ")}]") + private suspend fun getLocalUserFilesAsPrefixMap( + appInfo: SteamApp, + hashCacheDao: SteamFileHashCacheDao, + hashCacheHits: AtomicInteger, + hashCacheMisses: AtomicInteger, + prefixToPath: (String) -> String, + ): Map> { + val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } - !file.shaFile.contentEquals(it.sha) - } - } == true + 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 getLocalUserFilesAsPrefixMap: suspend () -> Map> = { - val savePatterns = appInfo.ufs.saveFilePatterns.filter { userFile -> userFile.root.isWindows } + 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 filePaths = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = userFile.pattern, + maxDepth = 5, + ).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 - val result = mutableMapOf>() + Timber.i("Found ${path.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") - if (savePatterns.isNotEmpty()) { - savePatterns.forEach { userFile -> - if (userFile.root == PathType.SteamUserData) { - // skip handling, use the logic below to scan SteamUserData - return@forEach - } + val relativePath = basePath.relativize(path).pathString - 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 filePaths = FileUtils.findFilesRecursive( - rootPath = basePath, - pattern = userFile.pattern, - maxDepth = 5, - ).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 + 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 ${path.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") + Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") - val relativePath = basePath.relativize(path).pathString + val prefixKey = Paths.get(userFile.prefix).pathString + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + } + } - add(UserFileInfo( - root = userFile.root, - path = userFile.substitutedPath, - filename = relativePath, - timestamp = Files.getLastModifiedTime(path).toMillis(), - sha = sha, - cloudRoot = userFile.uploadRoot, - cloudPath = userFile.uploadPath - )) - } - } + // Scan SteamUserData root recursively (depth 5) + val rootType = PathType.SteamUserData + val basePath = Paths.get(prefixToPath(rootType.toString())) - Timber.i("Found ${files.size} file(s) in $basePath for pattern ${userFile.pattern}") + Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") - val prefixKey = Paths.get(userFile.prefix).pathString - result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + val steamUserDataPaths = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = "*", + maxDepth = 5, + ).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 = "" + )) } + } - // Scan SteamUserData root recursively (depth 5) - val rootType = PathType.SteamUserData - val basePath = Paths.get(prefixToPath(rootType.toString())) + Timber.i("Found ${files.size} file(s) in $basePath") - Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") + mapOf(Paths.get("%${rootType.name}%").pathString to files) - val steamUserDataPaths = FileUtils.findFilesRecursive( - rootPath = basePath, - pattern = "*", - maxDepth = 5, - ).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 = "" - )) + if (files.isNotEmpty()) { + val prefixKey = "%${rootType.name}%" + 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()}", + ) + + 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, + ) } + } - Timber.i("Found ${files.size} file(s) in $basePath") + return RemoteChangeDecision(hasLocalChanges = hasLocalChanges, rehydrateCache = false) + } - mapOf(Paths.get("%${rootType.name}%").pathString to files) + 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, + ) - if (files.isNotEmpty()) { - val prefixKey = "%${rootType.name}%" - result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) - } + 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 = getLocalUserFilesAsPrefixMap( + appInfo = appInfo, + hashCacheDao = steamInstance.db.steamFileHashCacheDao(), + hashCacheHits = AtomicInteger(0), + hashCacheMisses = AtomicInteger(0), + prefixToPath = prefixToPath, + ) + val allLocalFiles = localFilesMap.values.flatten() + val effectiveLocalChangeNumber = if (cacheIsAbsentOrEmpty && allLocalFiles.isNotEmpty()) { + -1L + } else { + localChangeNumber + } - Timber.i( - "Local save hash cache stats for ${appInfo.id} (${appInfo.name}): " + - "hits=${hashCacheHits.get()}, misses=${hashCacheMisses.get()}, files=${hashCacheHits.get() + hashCacheMisses.get()}", + val pathResolver = CloudPathResolver(appInfo, prefixToPath) + val hasCachedLocalChanges = cachedFileList?.let { + getFilesDiff(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, ) - - result } - val fileChangeListToUserFiles: (AppFileChangeList) -> List = { appFileListChange -> - val pathTypePairs = getPathTypePairs(appFileListChange) + 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, + ) + } - 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, - ) + fun syncUserFiles( + appInfo: SteamApp, + clientId: Long, + steamInstance: SteamService, + steamCloud: SteamCloud, + preferredSave: SaveLocation = SaveLocation.None, + parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + prefixToPath: (String) -> String, + overrideLocalChangeNumber: Long? = null, + onProgress: ((message: String, progress: Float) -> Unit)? = null, + onPhaseStarted: ((isUploading: Boolean) -> Unit)? = null, + ): Deferred = parentScope.async { + val postSyncInfo: PostSyncInfo? + + Timber.i("Retrieving save files of ${appInfo.name}") + + val pathResolver = CloudPathResolver(appInfo, prefixToPath) + val hashCacheHits = AtomicInteger(0) + val hashCacheMisses = AtomicInteger(0) + val hashCacheDao = steamInstance.db.steamFileHashCacheDao() + + val hasHashConflicts: (Map>, AppFileChangeList) -> Boolean = + { localUserFiles, fileList -> + fileList.files.any { file -> + Timber.i("Checking for " + "${pathResolver.getFilePrefix(file, fileList)} in ${localUserFiles.keys}") + + localUserFiles[pathResolver.getFilePrefix(file, fileList)]?.let { localUserFile -> + localUserFile.firstOrNull { + Timber.i("Comparing ${file.filename} and ${it.filename}") + + it.filename == file.filename + }?.let { + Timber.i("Comparing SHA of ${pathResolver.getFilePrefixPath(file, fileList)} and ${it.prefixPath}") + Timber.i("[${file.shaFile.joinToString(", ")}]\n[${it.sha.joinToString(", ")}]") + + !file.shaFile.contentEquals(it.sha) + } + } == true + } } - } val buildUrl: (Boolean, String, String) -> String = { useHttps, urlHost, urlPath -> val scheme = if (useHttps) "https://" else "http://" @@ -501,8 +640,8 @@ object SteamAutoCloud { hashCacheDao = hashCacheDao, file = file, fileList = fileList, - getFilePrefixPath = getFilePrefixPath, - getFullFilePath = getFullFilePath, + getFilePrefixPath = pathResolver::getFilePrefixPath, + getFullFilePath = pathResolver::getFullFilePath, buildUrl = buildUrl, httpClient = downloadHttpClient, totalRawBytes = totalRawBytes, @@ -794,7 +933,13 @@ object SteamAutoCloud { val allLocalUserFiles: List microsecInitCaches = measureTime { - localUserFilesMap = getLocalUserFilesAsPrefixMap() + localUserFilesMap = getLocalUserFilesAsPrefixMap( + appInfo = appInfo, + hashCacheDao = hashCacheDao, + hashCacheHits = hashCacheHits, + hashCacheMisses = hashCacheMisses, + prefixToPath = prefixToPath, + ) allLocalUserFiles = localUserFilesMap.map { it.value }.flatten() }.inWholeMicroseconds @@ -809,7 +954,7 @@ object SteamAutoCloud { parentScope.async { Timber.i("Downloading cloud user files") - val remoteUserFiles = fileChangeListToUserFiles(appFileListChange) + val remoteUserFiles = pathResolver.fileChangeListToUserFiles(appFileListChange) val filesDiff = getFilesDiff(remoteUserFiles, allLocalUserFiles).second microsecDeleteFiles = measureTime { var totalFilesDeleted = 0 @@ -831,7 +976,13 @@ object SteamAutoCloud { val updatedLocalFiles: Map> val hasLocalChanges: Boolean microsecValidateState = measureTime { - updatedLocalFiles = getLocalUserFilesAsPrefixMap() + updatedLocalFiles = getLocalUserFilesAsPrefixMap( + appInfo = appInfo, + hashCacheDao = hashCacheDao, + hashCacheHits = hashCacheHits, + hashCacheMisses = hashCacheMisses, + prefixToPath = prefixToPath, + ) hasLocalChanges = hasHashConflicts(updatedLocalFiles, appFileListChange) filesManaged = updatedLocalFiles.size }.inWholeMicroseconds @@ -916,58 +1067,38 @@ object SteamAutoCloud { } == true }.inWholeMicroseconds - val hasUncachedLocalFiles = cacheIsAbsentOrEmpty && allLocalUserFiles.isNotEmpty() - var rehydratedSilently = false - 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]) - } + val remoteChangeDecision = getRemoteChangeDecision( + hasCachedLocalChanges = hasLocalChanges, + cacheIsAbsentOrEmpty = cacheIsAbsentOrEmpty, + allLocalUserFiles = allLocalUserFiles, + appFileListChange = appFileListChange, + prefixToPath = prefixToPath, + getFullFilePath = pathResolver::getFullFilePath, + ) - if (localMatchesRemote) { - Timber.i("Cache absent but local matches remote — rehydrating cache silently") - with(steamInstance) { - db.withTransaction { - fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) - changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) - } - } - syncResult = SyncResult.UpToDate - filesManaged = allLocalUserFiles.size - rehydratedSilently = true - } else { - hasLocalChanges = true - conflictUfsVersion = CURRENT_UFS_PARSE_VERSION - remoteTimestamp = appFileListChange.files.map { it.timestamp.time }.maxOrNull() ?: 0L - localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L - } + if (remoteChangeDecision.conflictUfsVersion != null) { + conflictUfsVersion = remoteChangeDecision.conflictUfsVersion + remoteTimestamp = remoteChangeDecision.remoteTimestamp + localTimestamp = remoteChangeDecision.localTimestamp } - if (rehydratedSilently) { + if (remoteChangeDecision.rehydrateCache) { + with(steamInstance) { + db.withTransaction { + fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) + changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) + } + } + syncResult = SyncResult.UpToDate + filesManaged = allLocalUserFiles.size // nothing to do — cache is now consistent with cloud - } else if (!hasLocalChanges) { + } else if (!remoteChangeDecision.hasLocalChanges) { // we can safely download the new changes since no changes have been // made locally Timber.i("No local changes but new cloud user files") + onPhaseStarted?.invoke(false) downloadUserFiles(parentScope).await()?.let { return@async it } @@ -977,11 +1108,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 } @@ -1012,6 +1145,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...") diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 5c503bca98..c73399b676 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 @@ -384,6 +385,7 @@ class SteamService : Service(), IChallengeUrlChanged { } private val syncInProgressApps = ConcurrentHashMap() + private val cloudSyncStatuses = ConcurrentHashMap() private fun getSyncFlag(appId: Int): AtomicBoolean { val existing = syncInProgressApps[appId] @@ -397,7 +399,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) { @@ -406,6 +413,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 @@ -2351,13 +2401,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 { @@ -2374,6 +2423,9 @@ class SteamService : Service(), IChallengeUrlChanged { parentScope = parentScope, prefixToPath = prefixToPath, onProgress = onProgress, + onPhaseStarted = { isUploading -> + markCloudSyncStarted(appId, isUploading) + }, ).await() postSyncInfo?.let { info -> @@ -2425,10 +2477,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( @@ -2443,13 +2563,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 { @@ -2466,6 +2585,9 @@ class SteamService : Service(), IChallengeUrlChanged { parentScope = parentScope, prefixToPath = prefixToPath, overrideLocalChangeNumber = overrideLocalChangeNumber, + onPhaseStarted = { isUploading -> + markCloudSyncStarted(appId, isUploading) + }, ).await() postSyncInfo?.let { info -> @@ -2487,10 +2609,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) { @@ -2526,8 +2649,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 103f497551..ee4bd2414b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1017,6 +1017,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 ff21cb3c00..f652be51cf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1132,6 +1132,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 72986022de..fad62a64df 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1206,6 +1206,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 49e59f4c73..d97f9579eb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1190,6 +1190,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 092aa37d53..baa7b6a195 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1186,6 +1186,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 d6c03e3840..c2654d42fc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1204,6 +1204,7 @@ 게임 관리 컨테이너 클라우드 저장 + 대기 중인 원격 작업 도움말 및 정보 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 24a46e91a0..cc69be512b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1203,6 +1203,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 d139eeed5f..2645001029 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1017,6 +1017,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 6e3af31a74..a6ab302e23 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1194,6 +1194,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 e89b10cfe3..fd6330d4a0 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 e6c6192b8d..8060ee6389 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1189,6 +1189,7 @@ Керування грою Контейнер Хмарні збереження + Очікується віддалена операція Довідка та інформація diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dd3acc357d..4ac3e2ae25 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1184,6 +1184,7 @@ 游戏管理 容器 云存档 + 远程操作待处理 帮助与信息 @@ -1545,4 +1546,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 7ac8128c9b..1858aa7229 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1187,6 +1187,7 @@ 遊戲管理 容器 雲端存檔 + 遠端操作待處理 幫助與資訊 @@ -1537,4 +1538,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 a7985c8fdd..937fa2bf49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1249,6 +1249,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