From 7049c2ff6f8e382757a644ca61799b8fa97596b0 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:06:14 +0200 Subject: [PATCH 1/6] Extract Steam cloud path resolver Move the Steam Auto Cloud prefix and absolute-path resolution helpers out of syncUserFiles() so the same path mapping can be reused by the later status snapshot code. This is intended to be a mechanical extraction only: the resolver keeps the existing rootoverride, addPath, embedded %GameInstall%, and remote file-list conversion behavior, and syncUserFiles() continues to call the same logic through the new resolver instance. --- .../app/gamenative/service/SteamAutoCloud.kt | 126 +++++++++--------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index a8e2ae59d9..8457fd70d0 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -140,32 +140,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 +164,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 +184,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 +218,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 +239,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 +248,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,6 +256,49 @@ object SteamAutoCloud { } } + 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, + ) + } + } + } + + 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}") + + val pathResolver = CloudPathResolver(appInfo, prefixToPath) + val hashCacheHits = AtomicInteger(0) + val hashCacheMisses = AtomicInteger(0) + val hashCacheDao = steamInstance.db.steamFileHashCacheDao() + val getFilesDiff: (List, List) -> Pair = { currentFiles, oldFiles -> val overlappingFiles = currentFiles.filter { currentFile -> oldFiles.any { currentFile.prefixPath == it.prefixPath } @@ -305,15 +331,15 @@ object SteamAutoCloud { val hasHashConflicts: (Map>, AppFileChangeList) -> Boolean = { localUserFiles, fileList -> fileList.files.any { file -> - Timber.i("Checking for " + "${getFilePrefix(file, fileList)} in ${localUserFiles.keys}") + Timber.i("Checking for " + "${pathResolver.getFilePrefix(file, fileList)} in ${localUserFiles.keys}") - localUserFiles[getFilePrefix(file, fileList)]?.let { localUserFile -> + 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 ${getFilePrefixPath(file, fileList)} and ${it.prefixPath}") + 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) @@ -439,28 +465,6 @@ object SteamAutoCloud { result } - val fileChangeListToUserFiles: (AppFileChangeList) -> List = { appFileListChange -> - val pathTypePairs = getPathTypePairs(appFileListChange) - - 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 buildUrl: (Boolean, String, String) -> String = { useHttps, urlHost, urlPath -> val scheme = if (useHttps) "https://" else "http://" "$scheme${urlHost}$urlPath" @@ -501,8 +505,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, @@ -809,7 +813,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 @@ -934,7 +938,7 @@ object SteamAutoCloud { it.getAbsPath(prefixToPath).toString().lowercase() to it.sha } val remoteByPath = appFileListChange.files.associate { - getFullFilePath(it, appFileListChange).toString().lowercase() to it.shaFile + pathResolver.getFullFilePath(it, appFileListChange).toString().lowercase() to it.shaFile } val localMatchesRemote = localByPath.keys == remoteByPath.keys && localByPath.all { (path, sha) -> From 456a2a3e93f41cd91968a75943bd5db18688e425 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:06:54 +0200 Subject: [PATCH 2/6] Extract Steam cloud file diff helper Move the local-vs-cached UserFileInfo diff calculation out of syncUserFiles() so both sync and status resolution can use one comparison function. This is intended to be a mechanical extraction only: the created, deleted, modified, and changesExist calculations are unchanged, including the existing SHA comparison logging. --- .../app/gamenative/service/SteamAutoCloud.kt | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 8457fd70d0..9600b05145 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -279,6 +279,35 @@ object SteamAutoCloud { } } + private fun getFilesDiff(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) + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -299,35 +328,6 @@ object SteamAutoCloud { val hashCacheMisses = AtomicInteger(0) val hashCacheDao = steamInstance.db.steamFileHashCacheDao() - val getFilesDiff: (List, List) -> Pair = { currentFiles, oldFiles -> - 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() - - changesExist to FileChanges(deletedFiles, modifiedFiles, newFiles) - } - val hasHashConflicts: (Map>, AppFileChangeList) -> Boolean = { localUserFiles, fileList -> fileList.files.any { file -> From 030d6647c060b3c7ee1448957110fc1ab0b601ed Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:07:41 +0200 Subject: [PATCH 3/6] Extract Steam cloud local file scan helper Move local save discovery out of syncUserFiles() so the later cloud status snapshot can inspect local files without duplicating scan behavior. This is intended to be a mechanical extraction only: Windows UFS patterns, SteamUserData fallback scanning, prefix keys, timestamps, hashes, cloudRoot, and cloudPath are preserved. --- .../app/gamenative/service/SteamAutoCloud.kt | 256 ++++++++++-------- 1 file changed, 137 insertions(+), 119 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 9600b05145..656a646caf 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -308,6 +308,129 @@ object SteamAutoCloud { return changesExist to FileChanges(deletedFiles, modifiedFiles, newFiles) } + 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 } + + 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 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 + + Timber.i("Found ${path.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") + + val relativePath = basePath.relativize(path).pathString + + 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}") + + 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 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 = "" + )) + } + } + + 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) + } + + 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 + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -348,123 +471,6 @@ object SteamAutoCloud { } } - val getLocalUserFilesAsPrefixMap: suspend () -> 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 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 - - Timber.i("Found ${path.pathString}\n\tin ${userFile.prefix}\n\twith sha [${sha.joinToString(", ")}]") - - val relativePath = basePath.relativize(path).pathString - - 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}") - - 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 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 = "" - )) - } - } - - 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) - } - - Timber.i( - "Local save hash cache stats for ${appInfo.id} (${appInfo.name}): " + - "hits=${hashCacheHits.get()}, misses=${hashCacheMisses.get()}, files=${hashCacheHits.get() + hashCacheMisses.get()}", - ) - - result - } - val buildUrl: (Boolean, String, String) -> String = { useHttps, urlHost, urlPath -> val scheme = if (useHttps) "https://" else "http://" "$scheme${urlHost}$urlPath" @@ -798,7 +804,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 @@ -835,7 +847,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 From 2f6ec7dc73344e0be12169c58d04c5d219a25e4f Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:15:23 +0200 Subject: [PATCH 4/6] Extract Steam cloud remote change decision Move the remote-newer conflict decision out of syncUserFiles() so the later status snapshot can classify pending download, upload, and conflict states with the same rules. This is intended to be a mechanical extraction only: cached local-change detection remains timed in microsecAcPrepUserFiles, the cache rehydration shortcut still writes the same DB rows, and conflict timestamps/UFS version are preserved. --- .../app/gamenative/service/SteamAutoCloud.kt | 124 ++++++++++++------ 1 file changed, 81 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 656a646caf..e2b55c2b10 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") @@ -431,6 +439,57 @@ object SteamAutoCloud { 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) + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -938,53 +997,32 @@ 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 { - pathResolver.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 From 0e4ea0fc262b1575265786b6cff4010cb23af1a7 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:16:12 +0200 Subject: [PATCH 5/6] Add Steam cloud sync snapshot callbacks Expose a read-only snapshot path for the UI to resolve cloud status without starting a sync. Add an optional phase callback to syncUserFiles() so SteamService can emit DOWNLOADING and UPLOADING status changes while preserving the existing sync flow. --- .../app/gamenative/service/SteamAutoCloud.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index e2b55c2b10..0ac56e4ec4 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -490,6 +490,75 @@ object SteamAutoCloud { 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 = 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 + } + + 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, + ) + } + + 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, + ) + } + fun syncUserFiles( appInfo: SteamApp, clientId: Long, @@ -500,6 +569,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? @@ -1028,6 +1098,7 @@ object SteamAutoCloud { Timber.i("No local changes but new cloud user files") + onPhaseStarted?.invoke(false) downloadUserFiles(parentScope).await()?.let { return@async it } @@ -1037,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 } @@ -1072,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...") From a7abdc2690fe277fe054fd47f1263f8282cae672 Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Fri, 15 May 2026 11:17:01 +0200 Subject: [PATCH 6/6] Show Steam cloud save status Wire the Steam cloud snapshot and sync phase events into the library and app detail UI. This keeps the SteamAutoCloud sync behavior in the earlier mechanical commits and limits this commit to status presentation, user actions, and service-level state tracking. --- .../main/java/app/gamenative/data/SteamApp.kt | 2 + .../app/gamenative/events/AndroidEvent.kt | 2 + .../app/gamenative/service/SteamService.kt | 143 +++++++++++++- .../app/gamenative/ui/data/CloudSaveStatus.kt | 35 ++++ .../app/gamenative/ui/data/GameDisplayInfo.kt | 7 +- .../ui/screen/library/LibraryAppScreen.kt | 177 +++++++++++++++++- .../ui/screen/library/LibraryScreen.kt | 26 ++- .../screen/library/appscreen/BaseAppScreen.kt | 4 + .../library/appscreen/SteamAppScreen.kt | 110 +++++++++++ app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values-zh-rTW/strings.xml | 3 +- app/src/main/res/values/strings.xml | 11 ++ 23 files changed, 517 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/data/CloudSaveStatus.kt 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/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