Skip to content
Merged
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
49 changes: 48 additions & 1 deletion app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3065,7 +3065,7 @@ class SteamService : Service(), IChallengeUrlChanged {
val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await()
val schemaArray = userStats.schema.toByteArray()
val generator = StatsAchievementsGenerator()
val result = generator.generateStatsAchievements(schemaArray, configDirectory)
val result = generator.generateStatsAchievements(schemaArray, userStats, configDirectory)
cachedAchievements = result.achievements
cachedAchievementsAppId = appId

Expand All @@ -3080,6 +3080,53 @@ class SteamService : Service(), IChallengeUrlChanged {
}
File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8)
}

// Seed the GSE Saves file with the real earned state from Steam to avoid re-trigger notifications
val context = instance!!.applicationContext
val gseDirs = getGseSaveDirs(context, appId)
seedGseSaveAchievements(gseDirs, result.achievements)
}

// Seed the GSE achievements file to ensure that we don't get early unlock triggers (Games such as Brotato do re-triggers on launch).
// merges results with ones from Steam Servers so we don't overwrite offline achievements.
private fun seedGseSaveAchievements(dirs: List<File>, achievements: List<app.gamenative.statsgen.Achievement>) {
if (achievements.isEmpty()) return
for (dir in dirs) {
try {
dir.mkdirs()
val file = File(dir, "achievements.json")
// grab existing file or create new if nothing exists.
val merged = if (file.exists()) {
try {
JSONObject(file.readText(Charsets.UTF_8))
} catch (e: Exception) {
Timber.w(e, "Failed to parse existing GSE achievements.json in ${dir.absolutePath}, starting fresh")
JSONObject()
}
} else {
JSONObject()
}

// Apply achievements earned & timestamp to file where matched & persists local if local is earned & timestamped.
for (ach in achievements) {
val existing = if (merged.has(ach.name)) merged.getJSONObject(ach.name) else JSONObject()
val localEarned = existing.optBoolean("earned", false)
val steamEarned = ach.unlocked ?: false
val earned = localEarned || steamEarned

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.

P2: Merged achievement seeding trusts existing local flags in a shared non-account-scoped GSE path, which can leak unlocks across Steam accounts. getGseSaveDirs includes .../users/xuser/AppData/Roaming/GSE Saves/<appId> (not scoped to accountId), and the new merge logic (earned = localEarned || steamEarned) preserves any prior earned=true even when the current Steam user hasn't unlocked it. syncAchievementsFromGoldberg later collects all earned=true entries from every gseDir and uploads them to Steam, allowing account A's offline achievements to be incorrectly synced as account B's.

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 3115:

<comment>Merged achievement seeding trusts existing local flags in a shared non-account-scoped GSE path, which can leak unlocks across Steam accounts. `getGseSaveDirs` includes `.../users/xuser/AppData/Roaming/GSE Saves/<appId>` (not scoped to `accountId`), and the new merge logic (`earned = localEarned || steamEarned`) preserves any prior `earned=true` even when the current Steam user hasn't unlocked it. `syncAchievementsFromGoldberg` later collects all `earned=true` entries from every gseDir and uploads them to Steam, allowing account A's offline achievements to be incorrectly synced as account B's.</comment>

<file context>
@@ -3107,11 +3107,17 @@ class SteamService : Service(), IChallengeUrlChanged {
-                        existing.put("earned_time", ach.unlockTimestamp ?: 0)
+                        val localEarned = existing.optBoolean("earned", false)
+                        val steamEarned = ach.unlocked ?: false
+                        val earned = localEarned || steamEarned
+                        val localTime = existing.optLong("earned_time", 0L)
+                        val steamTime = (ach.unlockTimestamp ?: 0).toLong()
</file context>

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.

Maybe one for later as we don't allow account switching and the logic around jumping around games is a bit opaque right now.

One for us as a dev team to discuss.

val localTime = existing.optLong("earned_time", 0L)
val steamTime = (ach.unlockTimestamp ?: 0).toLong()
val earnedTime = maxOf(localTime, steamTime)
existing.put("earned", earned)
existing.put("earned_time", earnedTime)
merged.put(ach.name, existing)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

file.writeText(merged.toString(2), Charsets.UTF_8)
Timber.d("Seeded GSE Saves achievements.json in ${dir.absolutePath}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e: Exception) {
Timber.e(e, "Failed to seed GSE Saves achievements.json in ${dir.absolutePath}")
}
}
}

fun getGseSaveDirs(context: Context, appId: Int): List<File> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.gamenative.statsgen

import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.callback.UserStatsCallback
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
Expand All @@ -22,7 +23,7 @@ class StatsAchievementsGenerator {
return sb.toString()
}

fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult {
fun generateStatsAchievements(schema: ByteArray, userStats: UserStatsCallback, configDirectory: String): ProcessingResult {
val parsedSchema = vdfParser.binaryLoads(schema)
val achievementsOut = mutableListOf<Achievement>()
val statsOut = mutableListOf<Stat>()
Expand Down Expand Up @@ -150,11 +151,26 @@ class StatsAchievementsGenerator {
}
}

// Use expandedAchievements from JavaSteam and match achievements with the correct timestamp
val expandedByName = userStats.getExpandedAchievements().filter { it.name != null }.associateBy { it.name!! }
val achievementsWithTimestamps = achievementsOut.map { ach ->
val expanded = expandedByName[ach.name]
if (expanded != null && expanded.isUnlocked) {
ach.copy(
unlocked = true,
unlockTimestamp = expanded.unlockTimestamp,
formattedUnlockTime = expanded.getFormattedUnlockTime()
)
} else {
ach
}
}

var copyDefaultUnlockedImg = false
var copyDefaultLockedImg = false
val outputAchievements = mutableListOf<Map<String, Any>>()

for (ach in achievementsOut) {
for (ach in achievementsWithTimestamps) {
val outputAch = mutableMapOf<String, Any>()
outputAch["name"] = ach.name
outputAch["displayName"] = ach.displayName ?: emptyMap<String, String>()
Expand Down Expand Up @@ -186,9 +202,9 @@ class StatsAchievementsGenerator {
outputAch["progress"] = ach.progress
}

ach.unlocked?.let { outputAch["unlocked"] = it }
ach.unlockTimestamp?.let { outputAch["unlockTimestamp"] = it }
ach.formattedUnlockTime?.let { outputAch["formattedUnlockTime"] = it }
// Seed the earned & earn_time to defaults. We set this correctly in the GSE file.
outputAch["earned"] = false
outputAch["earn_time"] = 0

outputAchievements.add(outputAch)
}
Expand Down Expand Up @@ -251,7 +267,7 @@ class StatsAchievementsGenerator {

val orderedKeys = listOf(
"hidden", "displayName", "description", "icon", "icon_gray", "name",
"unlocked", "unlockTimestamp", "formattedUnlockTime"
"earned", "earn_time", "formattedUnlockTime"
)

for ((index, ach) in outputAchievements.withIndex()) {
Expand Down Expand Up @@ -284,10 +300,10 @@ class StatsAchievementsGenerator {
jsonBuilder.append("\"$escapedText\"")
}
}
"hidden", "unlockTimestamp" -> {
"hidden", "earn_time" -> {
jsonBuilder.append(" \"$key\": $value")
}
"unlocked" -> {
"earned" -> {
jsonBuilder.append(" \"$key\": ${value.toString().lowercase()}")
}
else -> {
Expand Down Expand Up @@ -339,7 +355,7 @@ class StatsAchievementsGenerator {
}

return ProcessingResult(
achievements = achievementsOut,
achievements = achievementsWithTimestamps,
stats = statsOut,
copyDefaultUnlockedImg = copyDefaultUnlockedImg,
copyDefaultLockedImg = copyDefaultLockedImg,
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres
feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery
play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity
hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose
javasteam = "1.8.0.1-18-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam
javasteam = "1.8.0.1-19-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam
json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json
junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit
junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit
Expand Down
Loading