Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class AchievementWatcher(

if (!SteamService.isConnected) {
Timber.tag("achievements").w("Not connected to Steam, skipping real-time achievement upload for appId=$appId")
SteamService.instance?.addPendingSyncApp(appId)
return
}

Expand Down
117 changes: 116 additions & 1 deletion app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ class SteamService : Service(), IChallengeUrlChanged {

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Persisted pending achievement sync state is global and not reliably cleared on service teardown, allowing stale app IDs to be replayed in a later session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/SteamService.kt, line 2640:

<comment>Persisted pending achievement sync state is global and not reliably cleared on service teardown, allowing stale app IDs to be replayed in a later session.</comment>

<file context>
@@ -2636,7 +2637,7 @@ class SteamService : Service(), IChallengeUrlChanged {
         private fun clearUserData(clearCloudSyncState: Boolean = false) {
             PrefManager.clearSteamSessionPreferences()
-
+            instance?.clearPendingSync()
             clearDatabase(clearCloudSyncState = clearCloudSyncState)
         }
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a task queue that's the idea

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var reconnectJob: Job? = null
private var offlineAchievementSyncJob: Job? = null
private val pendingSyncAppIds: MutableSet<Int> = java.util.concurrent.ConcurrentHashMap.newKeySet()
private val pendingSyncFileLock = Any()
private val pendingSyncFile by lazy { File(applicationContext.filesDir, "pending_achievement_sync.txt") }

private val onEndProcess: (AndroidEvent.EndProcess) -> Unit = {
Companion.stop()
Expand Down Expand Up @@ -2289,6 +2293,7 @@ class SteamService : Service(), IChallengeUrlChanged {
suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) {
async {
if (isOffline || !isConnected) {
instance?.addPendingSyncApp(appId)
return@async
}

Expand Down Expand Up @@ -2342,6 +2347,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}
} finally {
releaseSync(appId)
instance?.removePendingSyncApp(appId)
}
}
}
Expand Down Expand Up @@ -2631,7 +2637,7 @@ class SteamService : Service(), IChallengeUrlChanged {

private fun clearUserData(clearCloudSyncState: Boolean = false) {
PrefManager.clearSteamSessionPreferences()

instance?.clearPendingSync()
clearDatabase(clearCloudSyncState = clearCloudSyncState)
}

Expand Down Expand Up @@ -3060,6 +3066,13 @@ class SteamService : Service(), IChallengeUrlChanged {
super.onCreate()
instance = this

// Restore any app IDs that were pending achievement sync before the service was killed
pendingSyncAppIds.addAll(
runCatching {
pendingSyncFile.readLines().mapNotNull { it.trim().toIntOrNull() }
}.getOrDefault(emptyList())
)

// JavaSteam logger CME hot-fix
runCatching {
val clazz = Class.forName("in.dragonbra.javasteam.util.log.LogManager")
Expand Down Expand Up @@ -3313,6 +3326,9 @@ class SteamService : Service(), IChallengeUrlChanged {
_unifiedFriends = null

reconnectJob?.cancel()
offlineAchievementSyncJob?.cancel()
offlineAchievementSyncJob = null
pendingSyncAppIds.clear()
isStopping = false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
retryAttempt = 0

Expand Down Expand Up @@ -3362,6 +3378,8 @@ class SteamService : Service(), IChallengeUrlChanged {
Timber.i("Disconnected from Steam. User initiated: ${callback.isUserInitiated}")

isConnected = false
offlineAchievementSyncJob?.cancel()
offlineAchievementSyncJob = null

if (!isStopping && retryAttempt < MAX_RETRY_ATTEMPTS) {
retryAttempt++
Expand Down Expand Up @@ -3458,6 +3476,8 @@ class SteamService : Service(), IChallengeUrlChanged {
scope.launch {
resumePendingWorkshopDownloads()
}

syncPendingOfflineAchievements()
}

else -> {
Expand Down Expand Up @@ -3510,6 +3530,101 @@ class SteamService : Service(), IChallengeUrlChanged {
}
}

internal fun addPendingSyncApp(appId: Int) {
synchronized(pendingSyncFileLock) {
pendingSyncAppIds.add(appId)
runCatching { pendingSyncFile.writeText(pendingSyncAppIds.joinToString("\n")) }
}
Timber.tag("achievements").d("Recording appId=$appId for offline achievement sync on reconnect")
}

internal fun removePendingSyncApp(appId: Int) {
synchronized(pendingSyncFileLock) {
pendingSyncAppIds.remove(appId)
runCatching {
if (pendingSyncAppIds.isEmpty()) pendingSyncFile.delete()
else pendingSyncFile.writeText(pendingSyncAppIds.joinToString("\n"))
}
}
}

internal fun clearPendingSync() {
synchronized(pendingSyncFileLock) {
pendingSyncAppIds.clear()
runCatching { pendingSyncFile.delete() }
}
}

private fun syncPendingOfflineAchievements() {
offlineAchievementSyncJob?.cancel()
offlineAchievementSyncJob = scope.launch {
try {
delay(2_000)

if (!isConnected || !isLoggedIn) {
Timber.tag("achievements").d("Skipping reconnect achievement sync sweep — Steam no longer connected")
return@launch
}

val appsToSync = pendingSyncAppIds.toSet()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (appsToSync.isEmpty()) {
Timber.tag("achievements").d("Skipping reconnect achievement sync sweep — no apps were closed while offline")
return@launch
}

Timber.tag("achievements").i("Syncing offline achievements for ${appsToSync.size} app(s) closed while disconnected")
for (appId in appsToSync) {
ensureActive()

if (!isConnected || !isLoggedIn) {
Timber.tag("achievements").d("Stopping reconnect achievement sync sweep — Steam no longer connected")
return@launch
}

val gseSaveDirs = getGseSaveDirs(applicationContext, appId).filter { it.isDirectory }
if (gseSaveDirs.isEmpty()) {
removePendingSyncApp(appId)
continue
}

val hasOfflineAchievementData = gseSaveDirs.any { dir ->
File(dir, "achievements.json").exists() ||
(File(dir, "stats").isDirectory && (File(dir, "stats").listFiles()?.isNotEmpty() == true))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
if (!hasOfflineAchievementData) {
removePendingSyncApp(appId)
continue
}

if (!tryAcquireSync(appId)) {
Timber.tag("achievements").d("Skipping reconnect achievement sync for appId=$appId — sync already in progress")
continue
}

try {
Timber.tag("achievements").i("Attempting reconnect achievement sync for appId=$appId")
syncAchievementsFromGoldberg(applicationContext, appId)
removePendingSyncApp(appId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Comment on lines +3604 to +3610
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a double-catch? Or is this due to typing difficulties?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coroutine cancelation requires cancelation exception, ik it looks weird

Timber.tag("achievements").e(e, "Reconnect achievement sync failed for appId=$appId")
} finally {
releaseSync(appId)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.tag("achievements").e(e, "Reconnect achievement sync sweep failed")
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
} finally {
if (offlineAchievementSyncJob?.isActive != true) {
offlineAchievementSyncJob = null
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private fun onLoggedOff(callback: LoggedOffCallback) {
Timber.i("Logged off of Steam: ${callback.result}")

Expand Down
Loading