Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/src/main/assets/epic/version.dll
Binary file not shown.
4 changes: 4 additions & 0 deletions app/src/main/java/app/gamenative/PluviaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() }
Expand All @@ -238,6 +241,7 @@ class PluviaApp : SplitCompatApplication() {
inputControlsManager = null
touchpadView = null
achievementWatcher = null
epicAchievementServer = null
ActiveGameRegistry.clear()
SteamService.keepAlive = false
SteamService.clearPlayingConflict()
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/gamenative/data/EpicAchievement.kt
Original file line number Diff line number Diff line change
@@ -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,
)
74 changes: 33 additions & 41 deletions app/src/main/java/app/gamenative/service/AchievementWatcher.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package app.gamenative.service

import android.os.FileObserver
import app.gamenative.ui.util.AchievementNotificationManager
import kotlinx.coroutines.CoroutineScope
Expand All @@ -19,17 +18,14 @@ class AchievementWatcher(
private val watchDirs: List<File>,
private val displayNameMap: Map<String, String>,
private val iconUrlMap: Map<String, String?>,
private val configDirectory: String?,
private val onUpload: (suspend (unlockedNames: Set<String>, watchDirs: List<File>) -> Unit)? = null,
) {
private val observers = mutableListOf<FileObserver>()
private val notifiedNames = mutableSetOf<String>()
private val uploadedNames = mutableSetOf<String>()
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")
Expand All @@ -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"))
Expand All @@ -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
Expand All @@ -81,64 +74,63 @@ 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)")
}
} 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<String> {
val unlocked = mutableSetOf<String>()
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
}
Expand Down
168 changes: 168 additions & 0 deletions app/src/main/java/app/gamenative/service/epic/EpicAchievementServer.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>,
private val iconUrlMap: Map<String, String?>,
) {
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<Int>()
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
}
}
Loading