From faa94610c65b1d4d7460bc2bd4767336c63c98f4 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Wed, 3 Jun 2026 12:51:33 +0100 Subject: [PATCH 1/5] feat(): Epic Achievements & refactor to AchievementWatcher.kt to allow for portability for other stores. --- .../gamenative/service/AchievementWatcher.kt | 74 ++-- .../service/epic/EpicAchievementsManager.kt | 341 ++++++++++++++++++ .../gamenative/service/epic/EpicService.kt | 43 +++ .../screen/library/appscreen/BaseAppScreen.kt | 24 +- .../ui/screen/xserver/XServerScreen.kt | 58 ++- 5 files changed, 497 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt diff --git a/app/src/main/java/app/gamenative/service/AchievementWatcher.kt b/app/src/main/java/app/gamenative/service/AchievementWatcher.kt index 000a9e7b57..78576f3e6b 100644 --- a/app/src/main/java/app/gamenative/service/AchievementWatcher.kt +++ b/app/src/main/java/app/gamenative/service/AchievementWatcher.kt @@ -1,5 +1,4 @@ package app.gamenative.service - import android.os.FileObserver import app.gamenative.ui.util.AchievementNotificationManager import kotlinx.coroutines.CoroutineScope @@ -19,17 +18,14 @@ class AchievementWatcher( private val watchDirs: List, private val displayNameMap: Map, private val iconUrlMap: Map, - private val configDirectory: String?, + private val onUpload: (suspend (unlockedNames: Set, watchDirs: List) -> Unit)? = null, ) { private val observers = mutableListOf() private val notifiedNames = mutableSetOf() private val uploadedNames = mutableSetOf() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var uploadJob: Job? = null - fun start() { - // Snapshot all currently earned achievements so we don't notify for - // pre-existing unlocks when the game writes its initial achievements.json. for (dir in watchDirs) { dir.mkdirs() val achFile = File(dir, "achievements.json") @@ -49,10 +45,9 @@ class AchievementWatcher( } } Timber.tag("achievements").d("AchievementWatcher seeded ${notifiedNames.size} pre-existing achievements") - - // Start Watching for the specific achievement JSON file changes for (dir in watchDirs) { - val observer = object : FileObserver(dir, CLOSE_WRITE or MOVED_TO) { + @Suppress("DEPRECATION") + val observer = object : FileObserver(dir.absolutePath, CLOSE_WRITE or MOVED_TO) { override fun onEvent(event: Int, path: String?) { if (path == "achievements.json") { checkForNewUnlocks(File(dir, "achievements.json")) @@ -64,14 +59,12 @@ class AchievementWatcher( } Timber.tag("achievements").d("AchievementWatcher started, watching ${watchDirs.size} dirs") } - fun stop() { observers.forEach { it.stopWatching() } observers.clear() scope.cancel() Timber.tag("achievements").d("AchievementWatcher stopped") } - private fun checkForNewUnlocks(achFile: File) { if (!achFile.exists()) return var hasNewUnlocks = false @@ -81,13 +74,11 @@ class AchievementWatcher( val entry = json.optJSONObject(name) ?: continue if (!entry.optBoolean("earned", false)) continue if (name in notifiedNames) continue - notifiedNames.add(name) hasNewUnlocks = true val displayName = displayNameMap[name] ?: name val iconUrl = iconUrlMap[name] - - if(PrefManager.achievementShowNotification) { + if (PrefManager.achievementShowNotification) { AchievementNotificationManager.show(displayName, iconUrl) } Timber.tag("achievements").i("Achievement unlocked: $name ($displayName)") @@ -95,50 +86,51 @@ class AchievementWatcher( } catch (e: Exception) { Timber.tag("achievements").w(e, "Failed to parse achievements.json for watcher") } - if (hasNewUnlocks) { scheduleUpload() } } - - /** - * Debounces achievement uploads: waits 5 seconds after the last unlock before uploading to stop server spam - */ private fun scheduleUpload() { uploadJob?.cancel() uploadJob = scope.launch { delay(UPLOAD_DEBOUNCE_MS) - uploadToSteam() + performUpload() } } - - private suspend fun uploadToSteam() { - if (configDirectory == null) { - Timber.tag("achievements").w("No configDirectory set, skipping real-time achievement upload for appId=$appId") - return - } - - if (!SteamService.isConnected) { - Timber.tag("achievements").w("Not connected to Steam, skipping real-time achievement upload for appId=$appId") - SteamService.instance?.addPendingSyncApp(appId) + private suspend fun performUpload() { + if (onUpload == null) { + Timber.tag("achievements").d("No upload callback set for appId=$appId, skipping upload") return } - - // Get unlocked and stats - val (allUnlocked, gseStatsDir) = SteamService.collectGseUnlocksAndStats(watchDirs) - + val allUnlocked = collectUnlockedNames() val newToUpload = allUnlocked - uploadedNames - - Timber.tag("achievements").d("Real-time uploading ${newToUpload.size} new achievements (${allUnlocked.size} total) for appId=$appId") - val result = SteamService.storeAchievementUnlocks(appId, configDirectory, allUnlocked, gseStatsDir ?: watchDirs.first().resolve("stats")) - result.onSuccess { + if (newToUpload.isEmpty()) return + Timber.tag("achievements").d("Uploading ${newToUpload.size} new achievements (${allUnlocked.size} total) for appId=$appId") + try { + onUpload.invoke(allUnlocked, watchDirs) uploadedNames.addAll(allUnlocked) - Timber.tag("achievements").i("Real-time achievement upload succeeded for appId=$appId") - }.onFailure { e -> - Timber.tag("achievements").e(e, "Real-time achievement upload failed for appId=$appId, will retry on next unlock or at exit") + Timber.tag("achievements").i("Achievement upload succeeded for appId=$appId") + } catch (e: Exception) { + Timber.tag("achievements").e(e, "Achievement upload failed for appId=$appId, will retry on next unlock") } } - + private fun collectUnlockedNames(): Set { + val unlocked = mutableSetOf() + for (dir in watchDirs) { + val achFile = File(dir, "achievements.json") + if (!achFile.exists()) continue + try { + val json = JSONObject(achFile.readText(Charsets.UTF_8)) + for (name in json.keys()) { + val entry = json.optJSONObject(name) ?: continue + if (entry.optBoolean("earned", false)) unlocked.add(name) + } + } catch (e: Exception) { + Timber.tag("achievements").w(e, "Failed to read achievements.json in ${dir.absolutePath}") + } + } + return unlocked + } companion object { private const val UPLOAD_DEBOUNCE_MS = 5_000L } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt new file mode 100644 index 0000000000..e3281c3e92 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt @@ -0,0 +1,341 @@ +package app.gamenative.service.epic + +import android.content.Context +import app.gamenative.ui.data.Achievement +import app.gamenative.utils.Net +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +private val GQL_GAME_ACHIEVEMENTS = """ +query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) { + Achievement { + productAchievementsRecordBySandbox(sandboxId: ${'$'}sandboxId, locale: ${'$'}locale) { + achievements { + achievement { + name + hidden + unlockedDisplayName + lockedDisplayName + unlockedDescription + lockedDescription + unlockedIconLink + lockedIconLink + XP + } + } + } + } +} +""".trimIndent() + +private val GQL_PLAYER_ACHIEVEMENTS = """ +query PlayerAchievement(${'$'}epicAccountId: String!, ${'$'}sandboxId: String!) { + PlayerAchievement { + playerAchievementGameRecordsBySandbox(epicAccountId: ${'$'}epicAccountId, sandboxId: ${'$'}sandboxId) { + records { + playerAchievements { + playerAchievement { + achievementName + unlocked + unlockDate + XP + } + } + } + } + } +} +""".trimIndent() + +@Singleton +class EpicAchievementsManager @Inject constructor() { + + // ── API endpoints ───────────────────────────────────────────────────────── + + companion object { + private const val TAG = "EpicAchievements" + private val httpClient: OkHttpClient get() = Net.http + + private const val GQL_URL = "https://launcher.store.epicgames.com/graphql" + + private const val STORE_USER_AGENT = "EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live" + + fun eosAchievementSaveDir(context: Context, gameId: Int): File { + val container = app.gamenative.utils.ContainerUtils.getContainer(context, "EPIC_$gameId") + return File( + container.rootDir, + ".wine/drive_c/users/user/AppData/Local/EpicGamesLauncher/Saved", + ) + } + + fun eosAchievementSaveDirs(context: Context, gameId: Int): List = + listOf(eosAchievementSaveDir(context, gameId)) + } + + data class EpicAchievementInfo( + val name: String, + val displayName: String, + val description: String, + val iconUrl: String?, + val iconGrayUrl: String?, + val hidden: Boolean, + val xp: Int, + ) + + suspend fun fetchAchievementsForDisplay( + context: Context, + namespace: String, + ): List? = withContext(Dispatchers.IO) { + try { + val credentials = EpicAuthManager.getStoredCredentials(context).getOrNull() + ?: return@withContext null + val accessToken = credentials.accessToken.takeIf { it.isNotEmpty() } + ?: return@withContext null + val accountId = credentials.accountId.takeIf { it.isNotEmpty() } + ?: return@withContext null + + val definitions = fetchDefinitions(accessToken, namespace) + ?: return@withContext null + val playerState = fetchPlayerState(accessToken, accountId, namespace) + ?: emptyMap() + + definitions.map { def -> + val state = playerState[def.name] + val isUnlocked = state?.optBoolean("unlocked", false) ?: false + val unlockTimestamp = state?.optLong("unlockDateMs", 0L) + ?.let { epochMs -> (epochMs / 1000L).toInt() } ?: 0 + + Achievement( + displayName = def.displayName, + name = def.name, + isUnlocked = isUnlocked, + description = def.description, + unlockTimestamp = unlockTimestamp, + hidden = def.hidden, + icon = def.iconUrl ?: "", + iconGray = def.iconGrayUrl, + ) + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "fetchAchievementsForDisplay failed for namespace=$namespace") + null + } + } + + suspend fun generateAchievementsFile( + context: Context, + namespace: String, + configDirectory: String, + ): List? = withContext(Dispatchers.IO) { + try { + val credentials = EpicAuthManager.getStoredCredentials(context).getOrNull() + ?: return@withContext null + val accessToken = credentials.accessToken.takeIf { it.isNotEmpty() } + ?: return@withContext null + val accountId = credentials.accountId.takeIf { it.isNotEmpty() } + ?: return@withContext null + + val definitions = fetchDefinitions(accessToken, namespace) + ?: return@withContext null + val playerState = fetchPlayerState(accessToken, accountId, namespace) + ?: emptyMap() + + val achievementsJson = buildAchievementsJson(definitions, playerState) + + val outputDir = File(configDirectory).also { it.mkdirs() } + File(outputDir, "achievements.json").writeText( + achievementsJson.toString(2), + Charsets.UTF_8, + ) + Timber.tag(TAG).i("Generated achievements.json for namespace=$namespace at $configDirectory") + + definitions + } catch (e: Exception) { + Timber.tag(TAG).e(e, "generateAchievementsFile failed for namespace=$namespace") + null + } + } + + suspend fun uploadUnlocks( + @Suppress("UNUSED_PARAMETER") context: Context, + namespace: String, + @Suppress("UNUSED_PARAMETER") accountId: String, + unlockedNames: Set, + ): Result { + // Epic achievements are server-authoritative — the EOS SDK reports them to Epic's + // backend directly via the game's own EOS credentials. Legendary does not expose + // a writable ingest endpoint for third-party clients. Log for visibility. + Timber.tag(TAG).i( + "Epic achievement sync skipped (server-authoritative): " + + "${unlockedNames.size} unlocked for namespace=$namespace", + ) + return Result.success(Unit) + } + + + private fun fetchDefinitions( + accessToken: String, + namespace: String, + ): List? { + val body = JSONObject().apply { + put("query", GQL_GAME_ACHIEVEMENTS) + put("variables", JSONObject().apply { + put("sandboxId", namespace) + put("locale", "en") + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(GQL_URL) + .header("User-Agent", STORE_USER_AGENT) + // Auth header included for consistency; the store GQL endpoint accepts it + .header("Authorization", "Bearer $accessToken") + .post(body) + .build() + + return httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag(TAG).w("GQL definitions fetch failed: HTTP ${response.code} for namespace=$namespace") + return null + } + val bodyStr = response.body?.string() ?: return null + Timber.tag(TAG).d("GQL definitions raw response (first 500 chars): ${bodyStr.take(500)}") + parseDefinitions(JSONObject(bodyStr)) + } + } + + private fun fetchPlayerState( + accessToken: String, + accountId: String, + namespace: String, + ): Map? { + val body = JSONObject().apply { + put("query", GQL_PLAYER_ACHIEVEMENTS) + put("variables", JSONObject().apply { + put("epicAccountId", accountId) + put("sandboxId", namespace) + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(GQL_URL) + .header("User-Agent", STORE_USER_AGENT) + .header("Authorization", "Bearer $accessToken") + .post(body) + .build() + + return httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag(TAG).w("GQL player state fetch failed: HTTP ${response.code} for namespace=$namespace") + return null + } + val bodyStr = response.body?.string() ?: return null + Timber.tag(TAG).d("GQL player state raw response (first 500 chars): ${bodyStr.take(500)}") + parsePlayerState(JSONObject(bodyStr)) + } + } + + private fun parseDefinitions(json: JSONObject): List { + val list = mutableListOf() + val record = json + .optJSONObject("data") + ?.optJSONObject("Achievement") + ?.optJSONObject("productAchievementsRecordBySandbox") + ?: return list + + val achievements = record.optJSONArray("achievements") ?: return list + for (i in 0 until achievements.length()) { + val ach = achievements.optJSONObject(i)?.optJSONObject("achievement") ?: continue + val name = ach.optString("name").takeIf { it.isNotEmpty() } ?: continue + list.add( + EpicAchievementInfo( + name = name, + displayName = ach.optString("unlockedDisplayName") + .ifEmpty { ach.optString("lockedDisplayName") } + .ifEmpty { name }, + description = ach.optString("unlockedDescription") + .ifEmpty { ach.optString("lockedDescription") }, + iconUrl = ach.optString("unlockedIconLink").takeIf { it.isNotEmpty() }, + iconGrayUrl = ach.optString("lockedIconLink").takeIf { it.isNotEmpty() }, + hidden = ach.optBoolean("hidden", false), + xp = ach.optInt("XP", 0), + ), + ) + } + Timber.tag(TAG).d("Parsed ${list.size} achievement definitions") + return list + } + + private fun parsePlayerState(json: JSONObject): Map { + val map = mutableMapOf() + val records = json + .optJSONObject("data") + ?.optJSONObject("PlayerAchievement") + ?.optJSONObject("playerAchievementGameRecordsBySandbox") + ?.optJSONArray("records") + ?: return map + + for (r in 0 until records.length()) { + val record = records.optJSONObject(r) ?: continue + val playerAchievements = record.optJSONArray("playerAchievements") ?: continue + for (i in 0 until playerAchievements.length()) { + val pa = playerAchievements.optJSONObject(i) + ?.optJSONObject("playerAchievement") ?: continue + val name = pa.optString("achievementName").takeIf { it.isNotEmpty() } ?: continue + // unlockDate is an ISO-8601 string e.g. "2024-01-15T12:34:56.000Z" + val isoDate = pa.optString("unlockDate").takeIf { it.isNotEmpty() } + val epochMs: Long = isoDate?.let { + runCatching { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + .parse(it)?.time + }.getOrNull() + } ?: 0L + map[name] = JSONObject().apply { + put("unlocked", pa.optBoolean("unlocked", false)) + put("unlockDateMs", epochMs) + } + } + } + Timber.tag(TAG).d("Parsed player state for ${map.size} achievements") + return map + } + + private fun buildAchievementsJson( + definitions: List, + playerState: Map, + ): JSONObject { + val root = JSONObject() + for (def in definitions) { + val state = playerState[def.name] + val earned = state?.optBoolean("unlocked", false) ?: false + val earnTimeMs = state?.optLong("unlockDateMs", 0L) ?: 0L + val earnTimeSec = (earnTimeMs / 1000L).toInt() + + root.put( + def.name, + JSONObject().apply { + put("hidden", if (def.hidden) 1 else 0) + put("display_name", def.displayName) + put("description", def.description) + put("icon", def.iconUrl ?: "") + put("icon_gray", def.iconGrayUrl ?: "") + put("xp", def.xp) + put("earned", earned) + put("earn_time", earnTimeSec) + }, + ) + } + return root + } +} diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 6af429e229..50963d329f 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -603,6 +603,46 @@ class EpicService : Service() { null } } + + // ========================================================================== + // ACHIEVEMENTS + // ========================================================================== + + @Volatile var cachedAchievements: List? = null + @Volatile var cachedAchievementsNamespace: String? = null + + + suspend fun fetchAchievementsForDisplay( + context: Context, + namespace: String, + ) = getInstance()?.epicAchievementsManager?.fetchAchievementsForDisplay(context, namespace) + + suspend fun generateAchievementsFile( + context: Context, + namespace: String, + configDirectory: String, + ) { + val manager = getInstance()?.epicAchievementsManager ?: return + val info = manager.generateAchievementsFile(context, namespace, configDirectory) + if (info != null) { + cachedAchievements = info + cachedAchievementsNamespace = namespace + } + } + + suspend fun uploadAchievementUnlocks( + context: Context, + namespace: String, + accountId: String, + unlockedNames: Set, + ): Result { + val manager = getInstance()?.epicAchievementsManager + ?: return Result.failure(Exception("EpicService not running")) + return manager.uploadUnlocks(context, namespace, accountId, unlockedNames) + } + + fun getEosAchievementSaveDirs(context: Context, gameId: Int): List = + EpicAchievementsManager.eosAchievementSaveDirs(context, gameId) } private lateinit var notificationHelper: NotificationHelper @@ -616,6 +656,9 @@ class EpicService : Service() { @Inject lateinit var epicOverlayManager: EpicOverlayManager + @Inject + lateinit var epicAchievementsManager: EpicAchievementsManager + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Track active downloads by GameNative Int ID diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 8043a90d0e..1c3d577c97 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -941,7 +941,29 @@ abstract class BaseAppScreen { achievementsState = null } } - GameSource.EPIC -> { } // Add later with Epic achievements + GameSource.EPIC -> { + try { + val game = withContext(Dispatchers.IO) { + app.gamenative.service.epic.EpicService.getInstance() + ?.epicManager?.getGameById(libraryItem.gameId) + } + val namespace = game?.namespace?.takeIf { it.isNotEmpty() } + if (namespace != null) { + achievementsState = withContext(Dispatchers.IO) { + app.gamenative.service.epic.EpicService + .fetchAchievementsForDisplay(context, namespace) + } + } else { + Timber.tag("BaseAppScreen") + .d("No namespace for Epic gameId=${libraryItem.gameId}, skipping achievements fetch") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch Epic achievements for gameId=${libraryItem.gameId}: ${e.message}") + achievementsState = null + } + } GameSource.GOG -> { } // Add later with GOG achievements GameSource.AMAZON -> { } GameSource.CUSTOM_GAME -> { } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 3745f28d20..ccafc187c1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -92,6 +92,7 @@ import app.gamenative.externaldisplay.ExternalDisplaySwapController import app.gamenative.externaldisplay.SwapInputOverlayView import app.gamenative.service.AchievementWatcher import app.gamenative.service.SteamService +import app.gamenative.service.epic.EpicAchievementsManager import app.gamenative.service.epic.EpicService import app.gamenative.service.gog.GOGService import app.gamenative.ui.component.QuickMenu @@ -3426,9 +3427,56 @@ private fun setupXEnvironment( watchDirs = watchDirs, displayNameMap = displayNameMap, iconUrlMap = iconUrlMap, - configDirectory = configDirectory, + onUpload = { unlockedNames, dirs -> + if (configDirectory == null) { + Timber.tag("achievements").w("No configDirectory set, skipping Steam upload for appId=$gameIdInt") + return@AchievementWatcher + } + if (!SteamService.isConnected) { + Timber.tag("achievements").w("Not connected to Steam, queuing pending sync for appId=$gameIdInt") + SteamService.instance?.addPendingSyncApp(gameIdInt) + return@AchievementWatcher + } + val (allUnlocked, gseStatsDir) = SteamService.collectGseUnlocksAndStats(dirs) + SteamService.storeAchievementUnlocks( + gameIdInt, configDirectory, allUnlocked, + gseStatsDir ?: dirs.first().resolve("stats"), + ).onFailure { e -> + Timber.tag("achievements").e(e, "Steam upload failed for appId=$gameIdInt") + } + }, ).also { it.start() } } + } else if (gameSource == GameSource.EPIC) { + val gameIdInt = ContainerUtils.extractGameIdFromContainerId(appId) + if (gameIdInt != null) { + val cachedAchievements = EpicService.cachedAchievements + val namespace = EpicService.cachedAchievementsNamespace + if (cachedAchievements != null && namespace != null) { + val watchDirs = EpicService.getEosAchievementSaveDirs(context, gameIdInt) + val displayNameMap = cachedAchievements.associate { it.name to it.displayName } + val iconUrlMap = cachedAchievements.associate { it.name to it.iconUrl } + PluviaApp.achievementWatcher = AchievementWatcher( + appId = gameIdInt, + watchDirs = watchDirs, + displayNameMap = displayNameMap, + iconUrlMap = iconUrlMap, + onUpload = { unlockedNames, _ -> + val accountId = EpicService.getAccountId() + if (accountId == null) { + Timber.tag("achievements").w("No Epic account ID, skipping upload for appId=$gameIdInt") + return@AchievementWatcher + } + EpicService.uploadAchievementUnlocks(context, namespace, accountId, unlockedNames) + .onFailure { e -> + Timber.tag("achievements").e(e, "Epic upload failed for appId=$gameIdInt") + } + }, + ).also { it.start() } + } else { + Timber.tag("achievements").d("No cached Epic achievements for appId=$gameIdInt, watcher not started") + } + } } // put in separate scope since winhandler start method does some network stuff @@ -3521,6 +3569,14 @@ private fun getWineStartCommand( return "\"explorer.exe\"" } + // Generate achievements.json and populate cache so AchievementWatcher can start + if (game.namespace.isNotEmpty() && gameId != null) { + val saveDir = EpicAchievementsManager.eosAchievementSaveDir(context, gameId) + runBlocking { + EpicService.generateAchievementsFile(context, game.namespace, saveDir.absolutePath) + } + } + // Use container's configured executable path if available, otherwise auto-detect and persist val exePath = if (container.executablePath.isNotEmpty()) { container.executablePath From e351275406d01171664ba5dbed8e7ba7d60ec6e2 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Wed, 3 Jun 2026 13:33:18 +0100 Subject: [PATCH 2/5] feat(): remove unneeded comment --- .../app/gamenative/service/epic/EpicAchievementsManager.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt index e3281c3e92..370c45df1d 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt @@ -174,10 +174,9 @@ class EpicAchievementsManager @Inject constructor() { unlockedNames: Set, ): Result { // Epic achievements are server-authoritative — the EOS SDK reports them to Epic's - // backend directly via the game's own EOS credentials. Legendary does not expose - // a writable ingest endpoint for third-party clients. Log for visibility. + // backend directly via the game's own EOS credentials. Timber.tag(TAG).i( - "Epic achievement sync skipped (server-authoritative): " + + "Epic achievement sync skipped: " + "${unlockedNames.size} unlocked for namespace=$namespace", ) return Result.success(Unit) From 8906514c50586fdb05394bc03a853e18aa1de546 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Fri, 5 Jun 2026 00:22:00 +0100 Subject: [PATCH 3/5] feat(): Managed to get the listener working with help. --- app/src/main/assets/epic/version.dll | Bin 0 -> 120012 bytes app/src/main/java/app/gamenative/PluviaApp.kt | 4 + .../service/epic/EpicAchievementServer.kt | 162 ++++++++++++++++++ .../ui/screen/xserver/XServerScreen.kt | 63 +++---- .../gamenative/utils/LaunchDependencies.kt | 2 + .../EpicAchievementSpyDependency.kt | 78 +++++++++ 6 files changed, 278 insertions(+), 31 deletions(-) create mode 100755 app/src/main/assets/epic/version.dll create mode 100644 app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt create mode 100644 app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt diff --git a/app/src/main/assets/epic/version.dll b/app/src/main/assets/epic/version.dll new file mode 100755 index 0000000000000000000000000000000000000000..eeaaa59e8045951f050938f9b5c8f2f2af621e91 GIT binary patch literal 120012 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, + private val iconUrlMap: Map, +) { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var serverSocket: ServerSocket? = null + + /** The port this server is listening on, or -1 if not started. */ + @Volatile + var port: Int = -1 + private set + + suspend fun start() { + val portReady = CompletableDeferred() + scope.launch { + try { + // Bind to loopback only — port 0 lets the OS pick a free port. + val ss = ServerSocket(0, /* backlog */ 4, java.net.InetAddress.getByName("127.0.0.1")) + serverSocket = ss + port = ss.localPort + portReady.complete(port) + Timber.tag(TAG).i("EpicAchievementServer listening on 127.0.0.1:$port") + + while (!ss.isClosed) { + val client: Socket = try { + ss.accept() + } catch (e: SocketException) { + // ServerSocket was closed via stop() — expected, not an error. + break + } + handleClient(client) + } + } catch (e: Exception) { + portReady.completeExceptionally(e) + Timber.tag(TAG).e(e, "EpicAchievementServer error") + } + } + portReady.await() + } + + fun stop() { + try { + serverSocket?.close() + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Error closing server socket") + } + serverSocket = null + port = -1 + scope.cancel() + Timber.tag(TAG).d("EpicAchievementServer stopped") + } + + // ── Request handling ────────────────────────────────────────────────────── + + private fun handleClient(client: Socket) { + client.use { + try { + val reader = BufferedReader(InputStreamReader(client.getInputStream(), Charsets.UTF_8)) + + // Read request line and headers + val requestLine = reader.readLine() ?: return + var contentLength = 0 + var line: String? + while (true) { + line = reader.readLine() + if (line.isNullOrEmpty()) break + if (line.startsWith("Content-Length:", ignoreCase = true)) { + contentLength = line.substringAfter(":").trim().toIntOrNull() ?: 0 + } + } + + if (contentLength <= 0 || contentLength > MAX_BODY_BYTES) { + sendResponse(client, 400, "Bad Request") + return + } + + // Read body + val bodyChars = CharArray(contentLength) + var totalRead = 0 + while (totalRead < contentLength) { + val read = reader.read(bodyChars, totalRead, contentLength - totalRead) + if (read < 0) break + totalRead += read + } + val body = String(bodyChars, 0, totalRead) + + // Route: only POST /unlock is supported + if (!requestLine.startsWith("POST /unlock")) { + sendResponse(client, 404, "Not Found") + return + } + + val json = JSONObject(body) + val apiName = json.optString("name").takeIf { it.isNotEmpty() } + if (apiName == null) { + sendResponse(client, 400, "Missing name") + return + } + + onUnlock(apiName) + sendResponse(client, 200, "OK") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Error handling achievement client") + } + } + } + + private fun onUnlock(apiName: String) { + val displayName = displayNameMap[apiName] ?: apiName + val iconUrl = iconUrlMap[apiName] + Timber.tag(TAG).i("Achievement unlocked: $apiName ($displayName)") + if (PrefManager.achievementShowNotification) { + AchievementNotificationManager.show(displayName, iconUrl) + } + } + + private fun sendResponse(client: Socket, code: Int, message: String) { + val response = "HTTP/1.1 $code $message\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + client.getOutputStream().write(response.toByteArray(Charsets.UTF_8)) + } + + companion object { + private const val TAG = "EpicAchievements" + + /** Hard cap on accepted body size to prevent runaway reads. */ + private const val MAX_BODY_BYTES = 4096 + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index ccafc187c1..d024c4b3b0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3154,6 +3154,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setSteamAppId(numericAppId.toString()) } } + gameExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + getWineStartCommand(context, appId, container, bootToContainer, testGraphics, appLaunchInfo, envVars, guestProgramLauncherComponent, gameSource, offline) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") @@ -3447,36 +3448,6 @@ private fun setupXEnvironment( }, ).also { it.start() } } - } else if (gameSource == GameSource.EPIC) { - val gameIdInt = ContainerUtils.extractGameIdFromContainerId(appId) - if (gameIdInt != null) { - val cachedAchievements = EpicService.cachedAchievements - val namespace = EpicService.cachedAchievementsNamespace - if (cachedAchievements != null && namespace != null) { - val watchDirs = EpicService.getEosAchievementSaveDirs(context, gameIdInt) - val displayNameMap = cachedAchievements.associate { it.name to it.displayName } - val iconUrlMap = cachedAchievements.associate { it.name to it.iconUrl } - PluviaApp.achievementWatcher = AchievementWatcher( - appId = gameIdInt, - watchDirs = watchDirs, - displayNameMap = displayNameMap, - iconUrlMap = iconUrlMap, - onUpload = { unlockedNames, _ -> - val accountId = EpicService.getAccountId() - if (accountId == null) { - Timber.tag("achievements").w("No Epic account ID, skipping upload for appId=$gameIdInt") - return@AchievementWatcher - } - EpicService.uploadAchievementUnlocks(context, namespace, accountId, unlockedNames) - .onFailure { e -> - Timber.tag("achievements").e(e, "Epic upload failed for appId=$gameIdInt") - } - }, - ).also { it.start() } - } else { - Timber.tag("achievements").d("No cached Epic achievements for appId=$gameIdInt, watcher not started") - } - } } // put in separate scope since winhandler start method does some network stuff @@ -3569,13 +3540,43 @@ private fun getWineStartCommand( return "\"explorer.exe\"" } - // Generate achievements.json and populate cache so AchievementWatcher can start + // Generate achievements.json (used for initial unlock state snapshot) and + // write the achievement server port so the hook DLL can connect at startup. if (game.namespace.isNotEmpty() && gameId != null) { val saveDir = EpicAchievementsManager.eosAchievementSaveDir(context, gameId) runBlocking { EpicService.generateAchievementsFile(context, game.namespace, saveDir.absolutePath) } } + // generateAchievementsFile populates EpicService.cachedAchievements — start the + // achievement server now so the port is available to write to the port file below. + if (PluviaApp.epicAchievementServer == null) { + val cachedAchievements = EpicService.cachedAchievements + if (cachedAchievements != null && gameId != null) { + val displayNameMap = cachedAchievements.associate { it.name to it.displayName } + val iconUrlMap = cachedAchievements.associate { it.name to it.iconUrl } + val server = app.gamenative.service.epic.EpicAchievementServer( + displayNameMap = displayNameMap, + iconUrlMap = iconUrlMap, + ) + runBlocking { server.start() } + PluviaApp.epicAchievementServer = server + Timber.tag("achievements").d( + "EpicAchievementServer started on port ${server.port} for appId=$gameId", + ) + } else { + Timber.tag("achievements").d("No cached Epic achievements for appId=$gameId, server not started") + } + } + val achievementPort = PluviaApp.epicAchievementServer?.port ?: -1 + if (achievementPort > 0) { + // Written to a path the hook DLL can read as a Windows path: + // C:\windows\temp\eos_ach_port.txt + val portFile = File(container.getRootDir(), ".wine/drive_c/windows/temp/eos_ach_port.txt") + portFile.parentFile?.mkdirs() + portFile.writeText(achievementPort.toString(), Charsets.UTF_8) + Timber.tag("XServerScreen").d("Wrote EOS achievement port $achievementPort to ${portFile.absolutePath}") + } // Use container's configured executable path if available, otherwise auto-detect and persist val exePath = if (container.executablePath.isNotEmpty()) { diff --git a/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt b/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt index 25db27ca35..7939acc71e 100644 --- a/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt +++ b/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt @@ -4,6 +4,7 @@ import android.content.Context import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.utils.launchdependencies.BionicDefaultProtonDependency +import app.gamenative.utils.launchdependencies.EpicAchievementSpyDependency import app.gamenative.utils.launchdependencies.EpicOverlayDependency import app.gamenative.utils.launchdependencies.GogScriptInterpreterDependency import app.gamenative.utils.launchdependencies.LaunchDependencyCallbacks @@ -24,6 +25,7 @@ class LaunchDependencies { BionicDefaultProtonDependency, GogScriptInterpreterDependency, EpicOverlayDependency, + EpicAchievementSpyDependency, BionicSteamAssetsDependency, ) diff --git a/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt b/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt new file mode 100644 index 0000000000..eb7814ac49 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt @@ -0,0 +1,78 @@ +package app.gamenative.utils.launchdependencies + +import android.content.Context +import app.gamenative.data.GameSource +import app.gamenative.service.epic.EpicService +import com.winlator.container.Container +import timber.log.Timber +import java.io.File + +/** + * Installs the EOS achievement spy (version.dll) next to the game's .exe and writes + * the achievement server port to C:\windows\temp\eos_ach_port.txt so the DLL can + * connect back to [EpicAchievementServer] at startup. + * + * This is intentionally a lightweight, always-reinstall dependency — the DLL is a + * small asset bundled with the app and the port changes every session. + * + * The DLL is a proxy version.dll that: + * 1. Forwards all real version.dll exports to the system DLL. + * 2. IAT-patches EOS_Achievements_UnlockAchievements and POSTs {"name":"..."} to + * http://127.0.0.1:/unlock when an achievement is successfully unlocked. + */ +object EpicAchievementSpyDependency : LaunchDependency { + private const val TAG = "EpicAchievementSpy" + private const val ASSET_PATH = "epic/version.dll" + + override fun appliesTo(container: Container, gameSource: GameSource, gameId: Int): Boolean = + gameSource == GameSource.EPIC + + /** + * Always reinstall so the port file is refreshed every session. + */ + override fun isSatisfied(context: Context, container: Container, gameSource: GameSource, gameId: Int): Boolean = + false + + override fun getLoadingMessage(context: Context, container: Container, gameSource: GameSource, gameId: Int): String = + "Installing achievement spy" + + override suspend fun install( + context: Context, + container: Container, + callbacks: LaunchDependencyCallbacks, + gameSource: GameSource, + gameId: Int, + ) { + // 1. Copy version.dll next to the game's .exe. + // getInstalledExe returns a path relative to the game's install directory, + // so we need to combine it with the install path to get an absolute path. + val relativeExePath = EpicService.getInstalledExe(gameId) + if (relativeExePath.isEmpty()) { + Timber.tag(TAG).w("No exe path for gameId=$gameId — skipping DLL install") + return + } + val installPath = EpicService.getInstallPath(gameId) + if (installPath.isNullOrEmpty()) { + Timber.tag(TAG).w("No install path for gameId=$gameId — skipping DLL install") + return + } + val exeDir = File(installPath, relativeExePath).parentFile + if (exeDir == null || !exeDir.exists()) { + Timber.tag(TAG).w("Exe directory does not exist: $exeDir — skipping DLL install") + return + } + + val dllDest = File(exeDir, "version.dll") + try { + context.assets.open(ASSET_PATH).use { input -> + dllDest.outputStream().use { output -> + input.copyTo(output) + } + } + Timber.tag(TAG).i("Installed version.dll to ${dllDest.absolutePath}") + } catch (e: Exception) { + // Non-fatal: game launches without achievement notifications. + Timber.tag(TAG).w(e, "Failed to install version.dll — achievements won't be notified") + } + } +} From 37410df57bdd9ccedaa68b6df19712f981aa9180 Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Jun 2026 08:10:31 +0100 Subject: [PATCH 4/5] feat(): Updated epic work and cleaned up code. --- .../app/gamenative/data/EpicAchievement.kt | 11 +++++ .../service/epic/EpicAchievementServer.kt | 14 +++---- .../service/epic/EpicAchievementsManager.kt | 40 ++++--------------- .../gamenative/service/epic/EpicService.kt | 40 +------------------ .../ui/screen/xserver/XServerScreen.kt | 3 +- .../EpicAchievementSpyDependency.kt | 9 +---- 6 files changed, 30 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/EpicAchievement.kt diff --git a/app/src/main/java/app/gamenative/data/EpicAchievement.kt b/app/src/main/java/app/gamenative/data/EpicAchievement.kt new file mode 100644 index 0000000000..cc1e63efb8 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/EpicAchievement.kt @@ -0,0 +1,11 @@ +package app.gamenative.data + +data class EpicAchievement( + val name: String, + val displayName: String, + val description: String, + val iconUrl: String?, + val iconGrayUrl: String?, + val hidden: Boolean, + val xp: Int, +) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt index df828890a6..10697bd0f6 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt @@ -48,8 +48,8 @@ class EpicAchievementServer( val portReady = CompletableDeferred() scope.launch { try { - // Bind to loopback only — port 0 lets the OS pick a free port. - val ss = ServerSocket(0, /* backlog */ 4, java.net.InetAddress.getByName("127.0.0.1")) + // Bind to loopback only Auto-assign port + val ss = ServerSocket(0,4, java.net.InetAddress.getByName("127.0.0.1")) serverSocket = ss port = ss.localPort portReady.complete(port) @@ -84,8 +84,6 @@ class EpicAchievementServer( Timber.tag(TAG).d("EpicAchievementServer stopped") } - // ── Request handling ────────────────────────────────────────────────────── - private fun handleClient(client: Socket) { client.use { try { @@ -139,10 +137,10 @@ class EpicAchievementServer( } } - private fun onUnlock(apiName: String) { - val displayName = displayNameMap[apiName] ?: apiName - val iconUrl = iconUrlMap[apiName] - Timber.tag(TAG).i("Achievement unlocked: $apiName ($displayName)") + private fun onUnlock(name: String) { + val displayName = displayNameMap[name] ?: name + val iconUrl = iconUrlMap[name] + Timber.tag(TAG).i("Achievement unlocked: $name ($displayName)") if (PrefManager.achievementShowNotification) { AchievementNotificationManager.show(displayName, iconUrl) } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt index 370c45df1d..9f1ab264c3 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt @@ -3,6 +3,7 @@ package app.gamenative.service.epic import android.content.Context import app.gamenative.ui.data.Achievement import app.gamenative.utils.Net +import app.gamenative.data.EpicAchievement import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -17,6 +18,7 @@ import java.util.Locale import javax.inject.Inject import javax.inject.Singleton + private val GQL_GAME_ACHIEVEMENTS = """ query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) { Achievement { @@ -83,16 +85,6 @@ class EpicAchievementsManager @Inject constructor() { listOf(eosAchievementSaveDir(context, gameId)) } - data class EpicAchievementInfo( - val name: String, - val displayName: String, - val description: String, - val iconUrl: String?, - val iconGrayUrl: String?, - val hidden: Boolean, - val xp: Int, - ) - suspend fun fetchAchievementsForDisplay( context: Context, namespace: String, @@ -137,7 +129,7 @@ class EpicAchievementsManager @Inject constructor() { context: Context, namespace: String, configDirectory: String, - ): List? = withContext(Dispatchers.IO) { + ): List? = withContext(Dispatchers.IO) { try { val credentials = EpicAuthManager.getStoredCredentials(context).getOrNull() ?: return@withContext null @@ -167,26 +159,10 @@ class EpicAchievementsManager @Inject constructor() { } } - suspend fun uploadUnlocks( - @Suppress("UNUSED_PARAMETER") context: Context, - namespace: String, - @Suppress("UNUSED_PARAMETER") accountId: String, - unlockedNames: Set, - ): Result { - // Epic achievements are server-authoritative — the EOS SDK reports them to Epic's - // backend directly via the game's own EOS credentials. - Timber.tag(TAG).i( - "Epic achievement sync skipped: " + - "${unlockedNames.size} unlocked for namespace=$namespace", - ) - return Result.success(Unit) - } - - private fun fetchDefinitions( accessToken: String, namespace: String, - ): List? { + ): List? { val body = JSONObject().apply { put("query", GQL_GAME_ACHIEVEMENTS) put("variables", JSONObject().apply { @@ -245,8 +221,8 @@ class EpicAchievementsManager @Inject constructor() { } } - private fun parseDefinitions(json: JSONObject): List { - val list = mutableListOf() + private fun parseDefinitions(json: JSONObject): List { + val list = mutableListOf() val record = json .optJSONObject("data") ?.optJSONObject("Achievement") @@ -258,7 +234,7 @@ class EpicAchievementsManager @Inject constructor() { val ach = achievements.optJSONObject(i)?.optJSONObject("achievement") ?: continue val name = ach.optString("name").takeIf { it.isNotEmpty() } ?: continue list.add( - EpicAchievementInfo( + EpicAchievement( name = name, displayName = ach.optString("unlockedDisplayName") .ifEmpty { ach.optString("lockedDisplayName") } @@ -311,7 +287,7 @@ class EpicAchievementsManager @Inject constructor() { } private fun buildAchievementsJson( - definitions: List, + definitions: List, playerState: Map, ): JSONObject { val root = JSONObject() diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 50963d329f..c3c1ac2de6 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -8,8 +8,7 @@ import android.os.IBinder import app.gamenative.data.DownloadInfo import app.gamenative.data.EpicCredentials import app.gamenative.data.EpicGame -import app.gamenative.data.LaunchInfo -import app.gamenative.data.LibraryItem +import app.gamenative.data.EpicAchievement import app.gamenative.data.EpicGameToken import app.gamenative.utils.MarkerUtils import app.gamenative.enums.Marker @@ -584,34 +583,13 @@ class EpicService : Service() { return instance.epicOverlayManager.removeOverlay(context, container) } - // ========================================================================== - // CLOUD SAVES HELPERS - // ========================================================================== - - /** - * Get the Epic account ID from stored credentials - */ - fun getAccountId(): String? { - return try { - val context = getInstance()?.applicationContext ?: return null - val credentialsResult = kotlinx.coroutines.runBlocking(Dispatchers.IO) { - EpicAuthManager.getStoredCredentials(context) - } - credentialsResult.getOrNull()?.accountId - } catch (e: Exception) { - Timber.tag("Epic").e(e, "Failed to get account ID") - null - } - } - // ========================================================================== // ACHIEVEMENTS // ========================================================================== - @Volatile var cachedAchievements: List? = null + @Volatile var cachedAchievements: List? = null @Volatile var cachedAchievementsNamespace: String? = null - suspend fun fetchAchievementsForDisplay( context: Context, namespace: String, @@ -629,20 +607,6 @@ class EpicService : Service() { cachedAchievementsNamespace = namespace } } - - suspend fun uploadAchievementUnlocks( - context: Context, - namespace: String, - accountId: String, - unlockedNames: Set, - ): Result { - val manager = getInstance()?.epicAchievementsManager - ?: return Result.failure(Exception("EpicService not running")) - return manager.uploadUnlocks(context, namespace, accountId, unlockedNames) - } - - fun getEosAchievementSaveDirs(context: Context, gameId: Int): List = - EpicAchievementsManager.eosAchievementSaveDirs(context, gameId) } private lateinit var notificationHelper: NotificationHelper diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index d024c4b3b0..bf2e013623 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3548,8 +3548,7 @@ private fun getWineStartCommand( EpicService.generateAchievementsFile(context, game.namespace, saveDir.absolutePath) } } - // generateAchievementsFile populates EpicService.cachedAchievements — start the - // achievement server now so the port is available to write to the port file below. + // Start Epic Achievement Server and populate from the cachedAchievements if (PluviaApp.epicAchievementServer == null) { val cachedAchievements = EpicService.cachedAchievements if (cachedAchievements != null && gameId != null) { diff --git a/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt b/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt index eb7814ac49..2824e98a5f 100644 --- a/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt +++ b/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt @@ -12,9 +12,6 @@ import java.io.File * the achievement server port to C:\windows\temp\eos_ach_port.txt so the DLL can * connect back to [EpicAchievementServer] at startup. * - * This is intentionally a lightweight, always-reinstall dependency — the DLL is a - * small asset bundled with the app and the port changes every session. - * * The DLL is a proxy version.dll that: * 1. Forwards all real version.dll exports to the system DLL. * 2. IAT-patches EOS_Achievements_UnlockAchievements and POSTs {"name":"..."} to @@ -34,7 +31,7 @@ object EpicAchievementSpyDependency : LaunchDependency { false override fun getLoadingMessage(context: Context, container: Container, gameSource: GameSource, gameId: Int): String = - "Installing achievement spy" + "Installing epic achievement service" override suspend fun install( context: Context, @@ -43,9 +40,7 @@ object EpicAchievementSpyDependency : LaunchDependency { gameSource: GameSource, gameId: Int, ) { - // 1. Copy version.dll next to the game's .exe. - // getInstalledExe returns a path relative to the game's install directory, - // so we need to combine it with the install path to get an absolute path. + // Copy the version.dll next to game.exe, uses absolute path. val relativeExePath = EpicService.getInstalledExe(gameId) if (relativeExePath.isEmpty()) { Timber.tag(TAG).w("No exe path for gameId=$gameId — skipping DLL install") From ba6daf6f0e81a41e72a0383eb5b6e8e936620bbf Mon Sep 17 00:00:00 2001 From: phobos665 Date: Mon, 8 Jun 2026 09:32:12 +0100 Subject: [PATCH 5/5] feat(): Fixed up the offline launch issues --- .../gamenative/service/epic/EpicAchievementServer.kt | 10 +++++++++- .../app/gamenative/ui/screen/xserver/XServerScreen.kt | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt index 10697bd0f6..39dbf12ddc 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt @@ -58,10 +58,11 @@ class EpicAchievementServer( while (!ss.isClosed) { val client: Socket = try { ss.accept() - } catch (e: SocketException) { + } catch (_: SocketException) { // ServerSocket was closed via stop() — expected, not an error. break } + Timber.tag(TAG).i("Accepted achievement API socket from ${client.inetAddress.hostAddress}:${client.port}") handleClient(client) } } catch (e: Exception) { @@ -101,6 +102,9 @@ class EpicAchievementServer( } } + // Log every request we receive, regardless of route, to validate hook traffic. + Timber.tag(TAG).i("Incoming achievement API request: requestLine='$requestLine', contentLength=$contentLength") + if (contentLength <= 0 || contentLength > MAX_BODY_BYTES) { sendResponse(client, 400, "Bad Request") return @@ -116,6 +120,9 @@ class EpicAchievementServer( } val body = String(bodyChars, 0, totalRead) + // Keep logs safe/compact while still showing payload visibility for debugging. + Timber.tag(TAG).d("Incoming achievement API body: ${body.take(MAX_LOG_BODY_CHARS)}") + // Route: only POST /unlock is supported if (!requestLine.startsWith("POST /unlock")) { sendResponse(client, 404, "Not Found") @@ -156,5 +163,6 @@ class EpicAchievementServer( /** Hard cap on accepted body size to prevent runaway reads. */ private const val MAX_BODY_BYTES = 4096 + private const val MAX_LOG_BODY_CHARS = 512 } } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index bf2e013623..0dedd657d7 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3606,8 +3606,7 @@ private fun getWineStartCommand( // Get Epic launch parameters Timber.tag("XServerScreen").d("Building Epic launch parameters for ${game.appName}...") val runArguments: List = runBlocking { - val offlineLaunch = offline || container.isEpicOfflineMode; - val result = EpicService.buildLaunchParameters(context, container, game, offlineLaunch) + val result = EpicService.buildLaunchParameters(context, container, game, container.isEpicOfflineMode) if (result.isFailure) { Timber.tag("XServerScreen").e(result.exceptionOrNull(), "Failed to build Epic launch parameters") }