diff --git a/app/src/main/assets/epic/version.dll b/app/src/main/assets/epic/version.dll new file mode 100755 index 0000000000..eeaaa59e80 Binary files /dev/null and b/app/src/main/assets/epic/version.dll differ diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index 12d3f14c40..9fd9fc6031 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -200,6 +200,7 @@ class PluviaApp : SplitCompatApplication() { var inputControlsManager: InputControlsManager? = null var touchpadView: TouchpadView? = null var achievementWatcher: app.gamenative.service.AchievementWatcher? = null + var epicAchievementServer: app.gamenative.service.epic.EpicAchievementServer? = null var isOverlayPaused by mutableStateOf(false) @Volatile @@ -226,6 +227,8 @@ class PluviaApp : SplitCompatApplication() { // per-step catch so one failing teardown doesn't prevent the rest from running runCatching { achievementWatcher?.stop() } .onFailure { Timber.e(it, "shutdownEnvironment: achievementWatcher.stop") } + runCatching { epicAchievementServer?.stop() } + .onFailure { Timber.e(it, "shutdownEnvironment: epicAchievementServer.stop") } runCatching { SteamService.clearCachedAchievements() } .onFailure { Timber.e(it, "shutdownEnvironment: clearCachedAchievements") } runCatching { touchpadView?.releasePointerCapture() } @@ -238,6 +241,7 @@ class PluviaApp : SplitCompatApplication() { inputControlsManager = null touchpadView = null achievementWatcher = null + epicAchievementServer = null ActiveGameRegistry.clear() SteamService.keepAlive = false SteamService.clearPlayingConflict() 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/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/EpicAchievementServer.kt b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt new file mode 100644 index 0000000000..39dbf12ddc --- /dev/null +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt @@ -0,0 +1,168 @@ +package app.gamenative.service.epic + +import app.gamenative.PrefManager +import app.gamenative.ui.util.AchievementNotificationManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.json.JSONObject +import timber.log.Timber +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException + +/** + * Minimal HTTP server bound to 127.0.0.1 that receives achievement unlock + * events from the in-process EOS hook DLL (version.dll). + * + * The hook DLL calls: + * POST http://127.0.0.1:[port]/unlock + * Content-Type: application/json + * {"name": "ACHIEVEMENT_API_NAME"} + * + * The server looks up the display name and icon from [displayNameMap] / + * [iconUrlMap] (populated from [EpicAchievementsManager.cachedAchievements] + * before game launch) and fires [AchievementNotificationManager.show]. + * + * Lifecycle: call [start] just before the game launches and [stop] when it + * exits. Both are safe to call from any thread. + */ +class EpicAchievementServer( + private val displayNameMap: Map, + 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 Auto-assign port + val ss = ServerSocket(0,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 (_: 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) { + 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") + } + + 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 + } + } + + // 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 + } + + // 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) + + // 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") + 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(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) + } + } + + 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 + private const val MAX_LOG_BODY_CHARS = 512 + } +} 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..9f1ab264c3 --- /dev/null +++ b/app/src/main/java/app/gamenative/service/epic/EpicAchievementsManager.kt @@ -0,0 +1,316 @@ +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 +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)) + } + + 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 + } + } + + 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( + EpicAchievement( + 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..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 @@ -585,22 +584,27 @@ class EpicService : Service() { } // ========================================================================== - // CLOUD SAVES HELPERS + // ACHIEVEMENTS // ========================================================================== - /** - * 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 + @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 } } } @@ -616,6 +620,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..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 @@ -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 @@ -3153,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 "") @@ -3426,7 +3428,24 @@ 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() } } } @@ -3521,6 +3540,43 @@ private fun getWineStartCommand( return "\"explorer.exe\"" } + // 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) + } + } + // Start Epic Achievement Server and populate from the cachedAchievements + 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()) { container.executablePath @@ -3550,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") } 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..2824e98a5f --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/launchdependencies/EpicAchievementSpyDependency.kt @@ -0,0 +1,73 @@ +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. + * + * 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 epic achievement service" + + override suspend fun install( + context: Context, + container: Container, + callbacks: LaunchDependencyCallbacks, + gameSource: GameSource, + gameId: Int, + ) { + // 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") + 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") + } + } +}