From d03502814a85ff6f4912c6b5c58b48958fcdd155 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 16:33:08 +0100 Subject: [PATCH 1/6] fix(): achievement timestamp fixes --- .../app/gamenative/service/SteamService.kt | 2 +- .../statsgen/StatsAchievementsGenerator.kt | 30 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 6ffe1e9c5a..c63bb57f04 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -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 diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index af751a61e4..c2e0857e17 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -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 @@ -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() val statsOut = mutableListOf() @@ -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>() - for (ach in achievementsOut) { + for (ach in achievementsWithTimestamps) { val outputAch = mutableMapOf() outputAch["name"] = ach.name outputAch["displayName"] = ach.displayName ?: emptyMap() @@ -186,8 +202,8 @@ class StatsAchievementsGenerator { outputAch["progress"] = ach.progress } - ach.unlocked?.let { outputAch["unlocked"] = it } - ach.unlockTimestamp?.let { outputAch["unlockTimestamp"] = it } + ach.unlocked?.let { outputAch["earned"] = it } + ach.unlockTimestamp?.let { outputAch["earn_time"] = it } ach.formattedUnlockTime?.let { outputAch["formattedUnlockTime"] = it } outputAchievements.add(outputAch) @@ -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()) { @@ -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 -> { From cf1583f9a84a4812a2ea59d8d70292bb1e591acd Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 17:21:40 +0100 Subject: [PATCH 2/6] fix(): Fixed issue with achievement json --- .../app/gamenative/statsgen/StatsAchievementsGenerator.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index c2e0857e17..0599936e46 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -202,8 +202,8 @@ class StatsAchievementsGenerator { outputAch["progress"] = ach.progress } - ach.unlocked?.let { outputAch["earned"] = it } - ach.unlockTimestamp?.let { outputAch["earn_time"] = it } + outputAch["earned"] = ach.unlocked ?: false + outputAch["earn_time"] = ach.unlockTimestamp ?: 0 ach.formattedUnlockTime?.let { outputAch["formattedUnlockTime"] = it } outputAchievements.add(outputAch) From 4724c6593ebaca804a671d5d37d11591e8dbec33 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 17:35:21 +0100 Subject: [PATCH 3/6] Changed so that we just seed the achievement file with defaults, then populate the earned details in the GSE Achievements file. --- .../app/gamenative/service/SteamService.kt | 27 +++++++++++++++++++ .../statsgen/StatsAchievementsGenerator.kt | 8 +++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index c63bb57f04..2c3cffe7e8 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3080,6 +3080,33 @@ 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) + private fun seedGseSaveAchievements(dirs: List, achievements: List) { + if (achievements.isEmpty()) return + val json = JSONObject() + for (ach in achievements) { + val entry = JSONObject() + entry.put("earned", ach.unlocked ?: false) + entry.put("earned_time", ach.unlockTimestamp ?: 0) + json.put(ach.name, entry) + } + val content = json.toString(2) + for (dir in dirs) { + try { + dir.mkdirs() + File(dir, "achievements.json").writeText(content, Charsets.UTF_8) + Timber.d("Seeded GSE Saves achievements.json in ${dir.absolutePath}") + } catch (e: Exception) { + Timber.e(e, "Failed to seed GSE Saves achievements.json in ${dir.absolutePath}") + } + } } fun getGseSaveDirs(context: Context, appId: Int): List { diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index 0599936e46..835c022ab9 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -202,9 +202,9 @@ class StatsAchievementsGenerator { outputAch["progress"] = ach.progress } - outputAch["earned"] = ach.unlocked ?: false - outputAch["earn_time"] = ach.unlockTimestamp ?: 0 - 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) } @@ -355,7 +355,7 @@ class StatsAchievementsGenerator { } return ProcessingResult( - achievements = achievementsOut, + achievements = achievementsWithTimestamps, stats = statsOut, copyDefaultUnlockedImg = copyDefaultUnlockedImg, copyDefaultLockedImg = copyDefaultLockedImg, From 433b5f351392bd50df00e0ee72e8db5aa0455e89 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 20:23:31 +0100 Subject: [PATCH 4/6] fix(): add latest javasteam version with achievement fixes. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfcd0c3a43..be873ad45a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 From ff69ab5c700b1c3607a2f8b83fcd5553086e0b7c Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 20:36:11 +0100 Subject: [PATCH 5/6] fix(): Fixed issue where we'd potentially overwrite achievements that were triggered but not synced. --- .../app/gamenative/service/SteamService.kt | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 2c3cffe7e8..bbc950a3e1 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3087,21 +3087,35 @@ class SteamService : Service(), IChallengeUrlChanged { 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) + // 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, achievements: List) { if (achievements.isEmpty()) return - val json = JSONObject() - for (ach in achievements) { - val entry = JSONObject() - entry.put("earned", ach.unlocked ?: false) - entry.put("earned_time", ach.unlockTimestamp ?: 0) - json.put(ach.name, entry) - } - val content = json.toString(2) for (dir in dirs) { try { dir.mkdirs() - File(dir, "achievements.json").writeText(content, Charsets.UTF_8) + 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 + for (ach in achievements) { + val existing = if (merged.has(ach.name)) merged.getJSONObject(ach.name) else JSONObject() + existing.put("earned", ach.unlocked ?: false) + existing.put("earned_time", ach.unlockTimestamp ?: 0) + merged.put(ach.name, existing) + } + + file.writeText(merged.toString(2), Charsets.UTF_8) Timber.d("Seeded GSE Saves achievements.json in ${dir.absolutePath}") } catch (e: Exception) { Timber.e(e, "Failed to seed GSE Saves achievements.json in ${dir.absolutePath}") From 51ffa5b056d66db97ea960f9209af1eaf720ed60 Mon Sep 17 00:00:00 2001 From: Daniel Joyce Date: Mon, 25 May 2026 21:10:12 +0100 Subject: [PATCH 6/6] fix(): fix issue where I was still overwriting the entries. --- .../main/java/app/gamenative/service/SteamService.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bbc950a3e1..224fdab3d9 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3107,11 +3107,17 @@ class SteamService : Service(), IChallengeUrlChanged { JSONObject() } - // Apply achievements earned & timestamp to file where matched + // 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() - existing.put("earned", ach.unlocked ?: false) - 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() + val earnedTime = maxOf(localTime, steamTime) + existing.put("earned", earned) + existing.put("earned_time", earnedTime) merged.put(ach.name, existing) }