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
2 changes: 2 additions & 0 deletions app/src/main/java/app/gamenative/data/SteamApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/gamenative/events/AndroidEvent.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.gamenative.events

import app.gamenative.ui.data.CloudSaveStatus
import app.gamenative.ui.enums.Orientation
import java.util.EnumSet

Expand All @@ -23,6 +24,7 @@ interface AndroidEvent<T> : Event<T> {
data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent<Unit>
data class PostInstallSyncStatusChanged(val appId: Int, val isSyncing: Boolean) : AndroidEvent<Unit>
data class LibraryInstallStatusChanged(val appId: Int) : AndroidEvent<Unit>
data class CloudStatusChanged(val appId: Int, val status: CloudSaveStatus) : AndroidEvent<Unit>
data class CustomGameImagesFetched(val appId: String) : AndroidEvent<Unit>
data object RecommendationToggleChanged : AndroidEvent<Unit>
data class GOGAuthCodeReceived(val authCode: String) : AndroidEvent<Unit>
Expand Down
592 changes: 363 additions & 229 deletions app/src/main/java/app/gamenative/service/SteamAutoCloud.kt

Large diffs are not rendered by default.

143 changes: 136 additions & 7 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -384,6 +385,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}

private val syncInProgressApps = ConcurrentHashMap<Int, AtomicBoolean>()
private val cloudSyncStatuses = ConcurrentHashMap<Int, CloudSaveStatus>()

private fun getSyncFlag(appId: Int): AtomicBoolean {
val existing = syncInProgressApps[appId]
Expand All @@ -397,7 +399,12 @@ class SteamService : Service(), IChallengeUrlChanged {

private fun tryAcquireSync(appId: Int): Boolean {
val flag = getSyncFlag(appId)
return flag.compareAndSet(false, true)
val acquired = flag.compareAndSet(false, true)
if (acquired) {
cloudSyncStatuses[appId] = CloudSaveStatus.CHECKING
PluviaApp.events.emit(AndroidEvent.CloudStatusChanged(appId, CloudSaveStatus.CHECKING))
}
return acquired
}

private fun releaseSync(appId: Int) {
Expand All @@ -406,6 +413,49 @@ class SteamService : Service(), IChallengeUrlChanged {
if (flag != null && !flag.get()) {
syncInProgressApps.remove(appId, flag)
}
cloudSyncStatuses.remove(appId)
}

fun isSyncInProgress(appId: Int): Boolean {
return syncInProgressApps[appId]?.get() == true
}

fun getActiveCloudSyncStatus(appId: Int): CloudSaveStatus? {
return cloudSyncStatuses[appId]
}

fun isReadyForCloudOperations(): Boolean {
val service = instance ?: return false
return isConnected &&
isLoggedIn &&
!isStopping &&
!isLoggingOut &&
!isLoginInProgress &&
service._steamCloud != null
}

data class CloudSaveStatusResolution(
val status: CloudSaveStatus,
val conflictTimestamps: Pair<Long, Long>?,
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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
PluviaApp.events.emit(AndroidEvent.CloudStatusChanged(appId, status))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Track whether a game is currently running to prevent premature service stop
Expand Down Expand Up @@ -2351,13 +2401,12 @@ class SteamService : Service(), IChallengeUrlChanged {
return@async PostSyncInfo(SyncResult.InProgress)
}

var syncResult = PostSyncInfo(SyncResult.UnknownFail)
try {
val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail)
// Migrate GSE Saves to Steam userdata
SteamUtils.migrateGSESavesToSteamUserdata(context, appId)

var syncResult = PostSyncInfo(SyncResult.UnknownFail)

val maxAttempts = 3
for (attempt in 1..maxAttempts) {
try {
Expand All @@ -2374,6 +2423,9 @@ class SteamService : Service(), IChallengeUrlChanged {
parentScope = parentScope,
prefixToPath = prefixToPath,
onProgress = onProgress,
onPhaseStarted = { isUploading ->
markCloudSyncStarted(appId, isUploading)
},
).await()

postSyncInfo?.let { info ->
Expand Down Expand Up @@ -2425,10 +2477,78 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

return@async syncResult
} finally {
markCloudSyncFinished(appId, syncResult.syncResult)
releaseSync(appId)
}
return@async syncResult
}

suspend fun resolveCloudSaveStatus(
appId: Int,
prefixToPath: (String) -> String,
): CloudSaveStatusResolution = withContext(Dispatchers.IO) {
if (!isReadyForCloudOperations()) {
return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null)
}

val steamInstance = instance ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null)
val steamCloud = steamInstance._steamCloud ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null)
val appInfo = steamInstance.appDao.findApp(appId) ?: return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null)

val snapshot = try {
SteamAutoCloud.fetchSyncSnapshot(appInfo, steamInstance, steamCloud, prefixToPath)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.tag("SteamCloudSaveStatus").w(e, "Failed to resolve cloud status for appId=$appId")
return@withContext CloudSaveStatusResolution(CloudSaveStatus.OFFLINE, null, null)
}

val status = when {
snapshot.cloudIsNewer && snapshot.hasLocalChanges -> CloudSaveStatus.CONFLICT
snapshot.cloudIsNewer -> CloudSaveStatus.PENDING_DOWNLOAD
snapshot.hasLocalChanges -> CloudSaveStatus.PENDING_UPLOAD
snapshot.localFilesMap.values.none { it.isNotEmpty() } && snapshot.cloudChangeNumber > 0L -> CloudSaveStatus.PENDING_DOWNLOAD
else -> CloudSaveStatus.UP_TO_DATE
}

val conflictTimestamps = if (status == CloudSaveStatus.CONFLICT) {
val localTs = snapshot.conflictLocalTimestamp.takeIf { it > 0L }
?: snapshot.localFilesMap.values.flatten().maxOfOrNull { it.timestamp }
?: 0L
val remoteTs = snapshot.conflictRemoteTimestamp.takeIf { it > 0L }
?: snapshot.changeList.files.maxOfOrNull { it.timestamp.time }
?: 0L
localTs to remoteTs
} else {
null
}

CloudSaveStatusResolution(
status = status,
conflictTimestamps = conflictTimestamps,
conflictUfsVersion = snapshot.conflictUfsVersion,
)
}

fun buildPrefixToPath(context: Context, appId: Int, accountId: Long): (String) -> String {
val container = ContainerUtils.getContainer(context, "STEAM_$appId")
return { prefix ->
PathType.from(prefix).toAbsPath(container, appId, accountId)
}
}

suspend fun launchForceSync(
context: Context,
appId: Int,
preferredSave: SaveLocation = SaveLocation.None,
) {
val accountId = userSteamId?.accountID ?: return
val container = ContainerUtils.getOrCreateContainer(context, "STEAM_$appId")
ContainerManager(context).activateContainer(container)
val prefixToPath = buildPrefixToPath(context, appId, accountId)
forceSyncUserFiles(appId = appId, prefixToPath = prefixToPath, preferredSave = preferredSave).await()
}

suspend fun forceSyncUserFiles(
Expand All @@ -2443,13 +2563,12 @@ class SteamService : Service(), IChallengeUrlChanged {
return@async PostSyncInfo(SyncResult.InProgress)
}

var syncResult = PostSyncInfo(SyncResult.UnknownFail)
try {
val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail)
// Migrate GSE Saves to Steam userdata
SteamUtils.migrateGSESavesToSteamUserdata(context, appId)

var syncResult = PostSyncInfo(SyncResult.UnknownFail)

val maxAttempts = 3
for (attempt in 1..maxAttempts) {
try {
Expand All @@ -2466,6 +2585,9 @@ class SteamService : Service(), IChallengeUrlChanged {
parentScope = parentScope,
prefixToPath = prefixToPath,
overrideLocalChangeNumber = overrideLocalChangeNumber,
onPhaseStarted = { isUploading ->
markCloudSyncStarted(appId, isUploading)
},
).await()

postSyncInfo?.let { info ->
Expand All @@ -2487,10 +2609,11 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

return@async syncResult
} finally {
markCloudSyncFinished(appId, syncResult.syncResult)
releaseSync(appId)
}
return@async syncResult
}

suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) {
Expand Down Expand Up @@ -2526,8 +2649,14 @@ class SteamService : Service(), IChallengeUrlChanged {
steamCloud = steamCloud,
parentScope = this,
prefixToPath = prefixToPath,
onPhaseStarted = { isUploading ->
markCloudSyncStarted(appId, isUploading)
},
).await()

postSyncInfo?.let { info ->
markCloudSyncFinished(appId, info.syncResult)
}
steamCloud.signalAppExitSyncDone(
appId = appId,
clientId = clientId,
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/app/gamenative/ui/data/CloudSaveStatus.kt
Original file line number Diff line number Diff line change
@@ -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
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Loading
Loading