diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 6ffe1e9c5a..224fdab3d9 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 @@ -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, achievements: List) { + 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 + 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) + } + + 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}") + } + } } 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 af751a61e4..835c022ab9 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,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) } @@ -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 -> { @@ -339,7 +355,7 @@ class StatsAchievementsGenerator { } return ProcessingResult( - achievements = achievementsOut, + achievements = achievementsWithTimestamps, stats = statsOut, copyDefaultUnlockedImg = copyDefaultUnlockedImg, copyDefaultLockedImg = copyDefaultLockedImg, 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