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