Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions app/src/main/java/app/gamenative/service/SteamAutoCloud.kt
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,27 @@ object SteamAutoCloud {
// not "<WinAppDataRoaming>/saves" — root-only replacement can't express this.
val cloudPrefixToLocalPath: Map<String, String> = appInfo.ufs.saveFilePatterns
.filter { it.uploadPath != it.path }
.associate { p ->
val cloudKey = "%${p.uploadRoot.name}%${p.uploadPath}"
.flatMap { p ->
val localPath = Paths.get(prefixToPath(p.root.name), p.substitutedPath).pathString
val cloudPath = p.uploadPath
.replace("\\", "/")
.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
.trim('/')
val cloudRoot = "%${p.uploadRoot.name}%"
val cloudPrefixes = if (cloudPath.isBlank()) {
listOf(cloudRoot)
} else {
listOf(
"$cloudRoot$cloudPath",
"$cloudRoot/$cloudPath",
)
}
cloudPrefixes.map { cloudKey ->
cloudKey to localPath
}
}
.toMap()

val getPathTypePairs: (AppFileChangeList) -> List<Pair<String, String>> = { fileList ->
fileList.pathPrefixes
Expand Down Expand Up @@ -205,7 +219,13 @@ object SteamAutoCloud {
// 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('/')]
val cloudPrefix = prefix.trimEnd('/')
cloudPrefixToLocalPath.entries
.filter { (cloudKey, _) -> cloudPrefix == cloudKey || cloudPrefix.startsWith("$cloudKey/") }
.maxByOrNull { (cloudKey, _) -> cloudKey.length }
?.let { (cloudKey, localPath) ->
Paths.get(localPath, cloudPrefix.removePrefix(cloudKey).trimStart('/')).pathString
}
?: run {
var modified = prefix

Expand Down
7 changes: 3 additions & 4 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4188,10 +4188,9 @@ class SteamService : Service(), IChallengeUrlChanged {
licenseFlags = packageFromDb?.licenseFlags ?: EnumSet.noneOf(ELicenseFlags::class.java),
)
if (ufsParseVersionOutdated && newApp.ufs.saveFilePatterns.any { it.uploadRoot != it.root || it.uploadPath != it.path }) {
// UFS path logic changed and this app has rootoverrides — clear
// the file cache so the next sync detects the mismatch and
// prompts the user to choose between local and cloud saves.
fileChangeListsDao.deleteByAppId(app.id)
// UFS path logic changed and this app has rootoverrides: store 0 to force one
// full cloud query while preserving the local sync snapshot.
changeNumbersDao.insert(app.id, 0L)
}
newApp
} else {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/app/gamenative/utils/KeyValueUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import `in`.dragonbra.javasteam.types.KeyValue
import java.util.Date
import timber.log.Timber

const val CURRENT_UFS_PARSE_VERSION = 3
const val CURRENT_UFS_PARSE_VERSION = 4

/**
* Extension functions relating to [KeyValue] as the receiver type.
Expand Down
237 changes: 237 additions & 0 deletions app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,218 @@ class SteamAutoCloudTest {
)
}

@Test
fun twoPointMuseumSaveFolderUsesAccountIdAsChildOfCloudDirectory() = runBlocking {
val matchingChangeNumber = 5
db.appChangeNumbersDao().deleteByAppId(steamAppId)
db.appFileChangeListsDao().deleteByAppId(steamAppId)
db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, matchingChangeNumber.toLong()))
db.appFileChangeListsDao().insert(steamAppId, listOf(
app.gamenative.data.UserFileInfo(
root = PathType.WinMyDocuments,
path = "__stale__",
filename = "__placeholder__",
timestamp = 0L,
sha = ByteArray(20) { 0 },
)
))

val localLowRoot = File(tempDir, "local-low")
val userdataRoot = File(tempDir, "userdata")
val saveDir = File(localLowRoot, "Two Point Studios/Two Point Museum/Cloud/75264032/Saves")
saveDir.mkdirs()
File(saveDir, "1003.sav").writeBytes("museum save content".toByteArray())

val saveFilePatterns = listOf(
SaveFilePattern(
root = PathType.WinAppDataLocalLow,
path = "Two Point Studios/Two Point Museum/Cloud/75264032",
pattern = "*",
recursive = 1,
uploadRoot = PathType.WinAppDataLocalLow,
uploadPath = "75264032",
),
)
val appUnderTest = db.steamAppDao().findApp(steamAppId)!!
.copy(ufs = UFS(saveFilePatterns = saveFilePatterns))

val mockAppFileChangeList = mock<AppFileChangeList>()
whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(matchingChangeNumber.toLong())
whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false)
whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0)
whenever(mockAppFileChangeList.pathPrefixes).thenReturn(emptyList())
whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList())
whenever(mockAppFileChangeList.files).thenReturn(emptyList())

every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns
CompletableFuture.completedFuture(mockAppFileChangeList)

val mockUploadBatchResponse = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.AppUploadBatchResponse>()
whenever(mockUploadBatchResponse.batchID).thenReturn(1)
whenever(mockUploadBatchResponse.appChangeNumber).thenReturn((matchingChangeNumber + 1).toLong())

val capturedFilesToUpload = mutableListOf<List<String>>()
every {
mockSteamCloud.beginAppUploadBatch(any(), any(), any(), any(), any(), any(), any())
} answers {
for (i in args.indices) {
val a = args[i]
if (a is List<*> && a.all { it is String } && capturedFilesToUpload.isEmpty()) {
capturedFilesToUpload.add(a as List<String>)
}
}
CompletableFuture.completedFuture(mockUploadBatchResponse)
}

val mockFileUploadInfo = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.FileUploadInfo>()
whenever(mockFileUploadInfo.blockRequests).thenReturn(emptyList())

every { mockSteamCloud.beginFileUpload(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns
CompletableFuture.completedFuture(mockFileUploadInfo)

every { mockSteamCloud.commitFileUpload(any(), any(), any(), any(), any()) } returns
CompletableFuture.completedFuture(true)

every { mockSteamCloud.completeAppUploadBatch(any(), any(), any(), any()) } returns
CompletableFuture.completedFuture(Unit)

val prefixToPath: (String) -> String = { prefix ->
when (prefix) {
"WinAppDataLocalLow" -> localLowRoot.absolutePath
"SteamUserData" -> userdataRoot.absolutePath
else -> tempDir.absolutePath
}
}

val result = SteamAutoCloud.syncUserFiles(
appInfo = appUnderTest,
clientId = clientId,
steamInstance = mockSteamService,
steamCloud = mockSteamCloud,
preferredSave = SaveLocation.None,
prefixToPath = prefixToPath,
).await()

assertNotNull("Result should not be null", result)
assertEquals("Should upload 1 file from the account ID child folder", 1, result!!.filesUploaded)
assertTrue("Uploads should be completed", result.uploadsCompleted)

val filesToUpload = capturedFilesToUpload.singleOrNull() ?: emptyList()
assertTrue(
"Upload should use the account ID cloud prefix and nested Saves file. Got: $filesToUpload",
filesToUpload.contains("%WinAppDataLocalLow%75264032/Saves/1003.sav")
)
}

@Test
fun twoPointMuseumDownloadWithSlashAfterRootLandsInCloudDirectory() = runBlocking {
db.appChangeNumbersDao().deleteByAppId(steamAppId)
db.appFileChangeListsDao().deleteByAppId(steamAppId)
db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0))
db.appFileChangeListsDao().insert(steamAppId, emptyList())

val localLowRoot = File(tempDir, "local-low-download")
val userdataRoot = File(tempDir, "userdata-download")
val cloudContent = "museum cloud save content".toByteArray()
val cloudSha = CryptoHelper.shaHash(cloudContent)
val expectedSave = File(
localLowRoot,
"Two Point Studios/Two Point Museum/Cloud/75264032/Saves/Slot1/1003.sav",
)
val wrongSave = File(localLowRoot, "75264032/Saves/Slot1/1003.sav")

val appUnderTest = db.steamAppDao().findApp(steamAppId)!!
.copy(
ufs = UFS(
saveFilePatterns = listOf(
SaveFilePattern(
root = PathType.WinAppDataLocalLow,
path = "Two Point Studios/Two Point Museum/Cloud/75264032",
pattern = "*",
recursive = 1,
uploadRoot = PathType.WinAppDataLocalLow,
uploadPath = "75264032",
),
),
),
)

val mockFile = mock<AppFileInfo>()
whenever(mockFile.filename).thenReturn("1003.sav")
whenever(mockFile.shaFile).thenReturn(cloudSha)
whenever(mockFile.pathPrefixIndex).thenReturn(0)
whenever(mockFile.timestamp).thenReturn(Date())
whenever(mockFile.rawFileSize).thenReturn(cloudContent.size)

val cloudChangeNumber = 5L
val mockAppFileChangeList = mock<AppFileChangeList>()
whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(cloudChangeNumber)
whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false)
whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0)
whenever(mockAppFileChangeList.pathPrefixes).thenReturn(listOf("%WinAppDataLocalLow%75264032/Saves/Slot1/"))
whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList())
whenever(mockAppFileChangeList.files).thenReturn(listOf(mockFile))

every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns
CompletableFuture.completedFuture(mockAppFileChangeList)

val mockDownloadInfo = mock<FileDownloadInfo>()
whenever(mockDownloadInfo.urlHost).thenReturn("test.example.com")
whenever(mockDownloadInfo.urlPath).thenReturn("/download/two-point-museum")
whenever(mockDownloadInfo.useHttps).thenReturn(true)
whenever(mockDownloadInfo.requestHeaders).thenReturn(emptyList())
whenever(mockDownloadInfo.fileSize).thenReturn(cloudContent.size)
whenever(mockDownloadInfo.rawFileSize).thenReturn(cloudContent.size)
whenever(mockDownloadInfo.timestamp).thenReturn(Date())

every { mockSteamCloud.clientFileDownload(any(), any()) } answers {
assertEquals("%WinAppDataLocalLow%75264032/Saves/Slot1/1003.sav", secondArg<String>())
CompletableFuture.completedFuture(mockDownloadInfo)
}
every { mockSteamCloud.clientFileDownload(any(), any(), any(), any(), any()) } answers {
assertEquals("%WinAppDataLocalLow%75264032/Saves/Slot1/1003.sav", secondArg<String>())
CompletableFuture.completedFuture(mockDownloadInfo)
}

val mockHttpClient = mock<OkHttpClient>()
val mockCall = mock<Call>()
every { Net.httpForParallelDownloads(any()) } returns mockHttpClient
whenever(mockHttpClient.newCall(any())).thenReturn(mockCall)
whenever(mockCall.execute()).thenReturn(
Response.Builder()
.request(okhttp3.Request.Builder().url("https://test.example.com/download/two-point-museum").build())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body(cloudContent.toResponseBody(null))
.build(),
)

val prefixToPath: (String) -> String = { prefix ->
when (prefix) {
"WinAppDataLocalLow" -> localLowRoot.absolutePath
"SteamUserData" -> userdataRoot.absolutePath
else -> tempDir.absolutePath
}
}

val result = SteamAutoCloud.syncUserFiles(
appInfo = appUnderTest,
clientId = clientId,
steamInstance = mockSteamService,
steamCloud = mockSteamCloud,
preferredSave = SaveLocation.None,
prefixToPath = prefixToPath,
).await()

assertNotNull("Result should not be null", result)
assertEquals(SyncResult.Success, result!!.syncResult)
assertEquals(1, result.filesDownloaded)
assertTrue("Downloaded save should land in the game's Cloud directory", expectedSave.exists())
assertFalse("Downloaded save must not land directly under LocalLow/accountId", wrongSave.exists())
assertEquals(cloudContent.contentToString(), expectedSave.readBytes().contentToString())
}

@Test
fun uploadUsesOriginalRootPrefixWhenRootoverrideApplied() = runBlocking {
val matchingChangeNumber = 5
Expand Down Expand Up @@ -2251,6 +2463,31 @@ class SteamAutoCloudTest {
)
}

@Test
fun ufsRefreshPreservesSnapshot_emptyCloudDoesNotDeleteLocalFiles() = runBlocking {
cacheCurrentLocalFiles(0)
val localSave = File(saveFilesDir, "SaveData_0.sav")
assertTrue("Precondition: local save file exists", localSave.exists())

every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns
CompletableFuture.completedFuture(makeCloudFileChangeList(cloudChangeNumber = 0))

val testApp = db.steamAppDao().findApp(steamAppId)!!
val result = SteamAutoCloud.syncUserFiles(
appInfo = testApp,
clientId = clientId,
steamInstance = mockSteamService,
steamCloud = mockSteamCloud,
preferredSave = SaveLocation.None,
prefixToPath = makePrefixToPath(),
).await()

assertNotNull(result)
assertEquals(SyncResult.UpToDate, result!!.syncResult)
assertEquals("No local files should be deleted", 0, result.filesDeleted)
assertTrue("Local save should be preserved when cloud is empty", localSave.exists())
}

// ── Scenario 11: Brand new game, never played anywhere — nothing to sync ──
@Test
fun neverSynced_noLocalFiles_noCloud_succeeds() = runBlocking {
Expand Down
Loading