From 741dba881e97462cb97ddb7e62b0e92464a1f8ca Mon Sep 17 00:00:00 2001 From: Dan Bruno Date: Mon, 2 Mar 2026 19:35:56 -0500 Subject: [PATCH 1/2] Adds function to case insensitive resolve cloud save path --- .../service/epic/EpicCloudSavesManager.kt | 77 ++++++++++++++----- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index abe280eea5..819dc25935 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -17,6 +17,12 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import timber.log.Timber +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name /** * Manages Epic Cloud Saves - downloading and uploading save files @@ -1193,29 +1199,17 @@ object EpicCloudSavesManager { "{appname}" to game.appName, ) - // Map to Wine prefix paths (like GOG does) - // Check for both proper casing (AppData) and legacy lowercase (appdata) - val usersPath = File(winePrefix, "drive_c/users/$user") - val appDataDir = when { - File(usersPath, "AppData").exists() -> "AppData" - File(usersPath, "appdata").exists() -> "appdata" - File(usersPath, "appData").exists() -> "appData" - else -> "AppData" // Default to proper Windows casing - } - - Timber.tag("Epic").d("[Cloud Saves] Using AppData directory name: $appDataDir") - - val appDataPath = File(winePrefix, "drive_c/users/$user/$appDataDir/Local").absolutePath - val appDataRoamingPath = File(winePrefix, "drive_c/users/$user/$appDataDir/Roaming").absolutePath - val documentsPath = File(winePrefix, "drive_c/users/$user/Documents").absolutePath - val savedGamesPath = File(winePrefix, "drive_c/users/$user/Saved Games").absolutePath + val appDataPath = "C:/users/$user/AppData/Local" + val appDataRoamingPath = "C:/users/$user/AppData/Roaming" + val documentsPath = "C:/users/$user/Documents" + val savedGamesPath = "C:/users/$user/Saved Games" pathVars["{appdata}"] = appDataPath pathVars["{localappdata}"] = appDataPath // Windows %LocalAppData% — same as AppData/Local pathVars["{roamingappdata}"] = appDataRoamingPath // Windows %AppData% (Roaming) pathVars["{userdir}"] = documentsPath pathVars["{usersavedgames}"] = savedGamesPath - pathVars["{userprofile}"] = File(winePrefix, "drive_c/users/$user").absolutePath + pathVars["{userprofile}"] = "C:/users/$user" // Normalize path separators first var resolvedPath = cloudSaveFolder.replace("\\", "/") @@ -1252,7 +1246,8 @@ object EpicCloudSavesManager { } } - val finalPath = File(normalizedParts.joinToString("/")) + val resolvedWinePath: String = windowsToWinePath(winePrefix, normalizedParts.joinToString("/")) ?: return null + val finalPath = File(resolvedWinePath) // Check subdirectories for save files // Some games store saves in user-specific subdirectories (e.g., "0/", "1/", etc.) @@ -1278,7 +1273,7 @@ object EpicCloudSavesManager { } // Always check for subdirectories with files - val subDirs = finalPath.listFiles { it -> it.isDirectory } ?: emptyArray() + val subDirs = finalPath.listFiles { it.isDirectory } ?: emptyArray() val dirWithFiles = subDirs.firstOrNull { subDir -> subDir.listFiles()?.any { it.isFile } == true } @@ -1300,6 +1295,50 @@ object EpicCloudSavesManager { return actualPath } + // Resolve a Windows path against a wine prefix path case insensitively + private fun windowsToWinePath(winePrefix:String, windowsPath: String): String? { + val pathParts = windowsPath.replace("\\", "/").split("/").toMutableList() + val drive = pathParts[0] + + if (drive.length != 2 || !drive.endsWith(":")) { + Timber.tag("Epic").w("[Cloud Saves] Could not resolve drive; aborting") + return null + } + + pathParts[0] = "drive_" + pathParts[0][0] + + var currentPath = Path(winePrefix) + + for (segment in pathParts) { + // If we're at a place where the current path doesn't exist, + // just resolve as-is and continue + if (!currentPath.exists() || !currentPath.isDirectory()) { + currentPath = currentPath.resolve(segment) + continue + } + + // Avoid directory traversal if possible by trying to directly resolve + val exactMatchPath = currentPath.resolve(segment) + if (exactMatchPath.exists()) { + currentPath = exactMatchPath + continue + } + + val match = try { + currentPath.listDirectoryEntries().firstOrNull { + it.name.equals(segment, ignoreCase = true) + } + } catch (_: Exception) { + null + } + + // Use matched casing if not, otherwise fallback to requested casing + currentPath = match ?: currentPath.resolve(segment) + } + + return currentPath.absolutePathString() + } + private fun getSyncTimestamp(context: Context, appId: Int): String? { val prefs = context.getSharedPreferences("epic_cloud_saves", Context.MODE_PRIVATE) return prefs.getString("sync_timestamp_$appId", null) From fc9804459e98ea2801f78b988b7dbb07ee7a1c05 Mon Sep 17 00:00:00 2001 From: Dan Bruno Date: Mon, 2 Mar 2026 20:10:46 -0500 Subject: [PATCH 2/2] Address issue in handling installdir path variable by resolving the entire String instead of just the windows part of the path. --- .../service/epic/EpicCloudSavesManager.kt | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt index 819dc25935..91f111967a 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -1199,17 +1199,17 @@ object EpicCloudSavesManager { "{appname}" to game.appName, ) - val appDataPath = "C:/users/$user/AppData/Local" - val appDataRoamingPath = "C:/users/$user/AppData/Roaming" - val documentsPath = "C:/users/$user/Documents" - val savedGamesPath = "C:/users/$user/Saved Games" + val appDataPath = File(winePrefix, "drive_c/users/$user/AppData/Local").absolutePath + val appDataRoamingPath = File(winePrefix, "drive_c/users/$user/AppData/Roaming").absolutePath + val documentsPath = File(winePrefix, "drive_c/users/$user/Documents").absolutePath + val savedGamesPath = File(winePrefix, "drive_c/users/$user/Saved Games").absolutePath pathVars["{appdata}"] = appDataPath pathVars["{localappdata}"] = appDataPath // Windows %LocalAppData% — same as AppData/Local pathVars["{roamingappdata}"] = appDataRoamingPath // Windows %AppData% (Roaming) pathVars["{userdir}"] = documentsPath pathVars["{usersavedgames}"] = savedGamesPath - pathVars["{userprofile}"] = "C:/users/$user" + pathVars["{userprofile}"] = File(winePrefix, "drive_c/users/$user").absolutePath // Normalize path separators first var resolvedPath = cloudSaveFolder.replace("\\", "/") @@ -1246,7 +1246,7 @@ object EpicCloudSavesManager { } } - val resolvedWinePath: String = windowsToWinePath(winePrefix, normalizedParts.joinToString("/")) ?: return null + val resolvedWinePath: String = resolveCaseInsensitivity(normalizedParts.joinToString("/")) val finalPath = File(resolvedWinePath) // Check subdirectories for save files @@ -1295,19 +1295,10 @@ object EpicCloudSavesManager { return actualPath } - // Resolve a Windows path against a wine prefix path case insensitively - private fun windowsToWinePath(winePrefix:String, windowsPath: String): String? { - val pathParts = windowsPath.replace("\\", "/").split("/").toMutableList() - val drive = pathParts[0] - - if (drive.length != 2 || !drive.endsWith(":")) { - Timber.tag("Epic").w("[Cloud Saves] Could not resolve drive; aborting") - return null - } - - pathParts[0] = "drive_" + pathParts[0][0] - - var currentPath = Path(winePrefix) + // Resolve a path case insensitively if there's no direct casing match + private fun resolveCaseInsensitivity(path: String): String { + val pathParts = path.replace("\\", "/").split("/") + var currentPath = Path("/") for (segment in pathParts) { // If we're at a place where the current path doesn't exist,