From f4e3d3e45020f0e9d2a63bcd753f2addebd85632 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 14 Jun 2026 19:39:18 -0700 Subject: [PATCH 1/2] Fix case-insensitive path resolution to not bail on unreadable dirs resolveCaseInsensitive() gave up case-insensitive matching for the rest of the path the moment listFiles() returned null (unreadable dirs like /data on Android, or not-yet-created dirs). Anchored at File("/"), the walk bailed on the first segment and appended everything verbatim, including lowercase AppData subfolders like locallow, so Epic cloud saves never matched the on-disk LocalLow. Descend with the literal segment instead of bailing, resuming matching at any level we can read. This fixes the Disco Elysium save detection at the shared helper, so GOG saves benefit too, and removes the Epic-specific resolveAbsolutePathCaseInsensitive workaround. --- .../service/epic/EpicCloudSavesManager.kt | 40 +------------------ .../java/app/gamenative/utils/FileUtils.kt | 10 +---- .../service/epic/EpicCloudSavesTest.kt | 17 ++++++-- 3 files changed, 17 insertions(+), 50 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 414efaaedb..75b63405cc 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -1263,15 +1263,8 @@ object EpicCloudSavesManager { } // Resolve against on-disk casing to avoid creating duplicate dirs (e.g. locallow vs LocalLow). - val joinedPath = "/${canonicalizeAppDataSegments(normalizedParts).joinToString("/")}" - val trustedRoots = buildList { - add(usersPath) - add(File(winePrefix)) - if (installDir.isNotEmpty()) { - add(File(installDir)) - } - } - val resolved = resolveAbsolutePathCaseInsensitive(joinedPath, trustedRoots) + val joinedPath = canonicalizeAppDataSegments(normalizedParts).joinToString("/") + val resolved = FileUtils.resolveCaseInsensitive(File("/"), joinedPath) // guard against path traversal escaping the wine prefix val absPath = resolved.absolutePath val withinPrefix = absPath.startsWith("$winePrefix/") || absPath == winePrefix || @@ -1329,35 +1322,6 @@ object EpicCloudSavesManager { return actualPath } - internal fun resolveAbsolutePathCaseInsensitive( - path: String, - trustedRoots: List = emptyList(), - ): File { - val normalizedPath = path.replace('\\', '/') - trustedRoots.firstNotNullOfOrNull { root -> - val rootPath = root.absolutePath.replace('\\', '/').trimEnd('/') - if (normalizedPath == rootPath || normalizedPath.startsWith("$rootPath/")) { - val relativePath = normalizedPath.removePrefix(rootPath).trimStart('/') - FileUtils.resolveCaseInsensitive(root, relativePath) - } else { - null - } - }?.let { return it } - - val pathFile = File(normalizedPath) - val rootPath = pathFile.toPath().root?.toString()?.replace('\\', '/') - val base = when { - pathFile.isAbsolute && rootPath != null -> File(rootPath) - normalizedPath.startsWith("/") -> File("/") - else -> File("") - } - val relativePath = when { - pathFile.isAbsolute && rootPath != null -> normalizedPath.removePrefix(rootPath).trimStart('/') - else -> normalizedPath.trimStart('/') - } - return FileUtils.resolveCaseInsensitive(base, relativePath) - } - // Fixes issue where saves were being lost due to inconsistencies in lower-case sub-folders in AppData internal fun canonicalizeAppDataSegments(segments: List): List { return segments.mapIndexed { index, segment -> diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 7ad8339fa8..90bde3d2ec 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -226,15 +226,9 @@ object FileUtils { fun resolveCaseInsensitive(baseDir: File, relativePath: String): File { val segments = relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() } var current = baseDir - for ((i, segment) in segments.withIndex()) { + for (segment in segments) { val match = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } - if (match != null) { - current = match - } else { - // append remaining segments verbatim - for (j in i until segments.size) current = File(current, segments[j]) - return current - } + current = match ?: File(current, segment) } return current } diff --git a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt index aed62ba000..db53a432c9 100644 --- a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt +++ b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt @@ -9,6 +9,7 @@ import app.gamenative.service.epic.manifest.EpicManifest import app.gamenative.service.epic.manifest.FileManifest import app.gamenative.service.epic.manifest.FileManifestList import app.gamenative.service.epic.manifest.ManifestMeta +import app.gamenative.utils.FileUtils import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder @@ -92,15 +93,23 @@ class EpicCloudSavesTest { .absolutePath .replace('\\', '/') - val resolved = EpicCloudSavesManager.resolveAbsolutePathCaseInsensitive( - metadataPath, - trustedRoots = listOf(wineUserDir), - ) + val resolved = FileUtils.resolveCaseInsensitive(File("/"), metadataPath) assertEquals(saveDir.absolutePath, resolved.absolutePath) assertTrue(resolved.exists()) } + @Test + fun `resolveCaseInsensitive keeps matching after an unmatched parent segment`() { + val base = tmpDir.newFolder("base") + val deep = File(base, "Existing/Nested/LocalLow").apply { mkdirs() } + + val resolved = FileUtils.resolveCaseInsensitive(base, "existing/nested/locallow") + + assertEquals(deep.absolutePath, resolved.absolutePath) + assertTrue(resolved.exists()) + } + @Test fun `AppData child segments are canonicalized before creating save paths`() { val segments = listOf("data", "prefix", "AppData", "locallow", "ZAUM Studio") From eb47201f369cd2d55e4ee750bcf72923e822dd65 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 18 Jun 2026 17:02:04 -0700 Subject: [PATCH 2/2] Eliminated epic specific changes for case insensitive folders --- .../service/epic/EpicCloudSavesManager.kt | 18 +----------------- .../service/epic/EpicCloudSavesTest.kt | 9 --------- 2 files changed, 1 insertion(+), 26 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 75b63405cc..7d81ff15d1 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt @@ -1263,7 +1263,7 @@ object EpicCloudSavesManager { } // Resolve against on-disk casing to avoid creating duplicate dirs (e.g. locallow vs LocalLow). - val joinedPath = canonicalizeAppDataSegments(normalizedParts).joinToString("/") + val joinedPath = normalizedParts.joinToString("/") val resolved = FileUtils.resolveCaseInsensitive(File("/"), joinedPath) // guard against path traversal escaping the wine prefix val absPath = resolved.absolutePath @@ -1322,22 +1322,6 @@ object EpicCloudSavesManager { return actualPath } - // Fixes issue where saves were being lost due to inconsistencies in lower-case sub-folders in AppData - internal fun canonicalizeAppDataSegments(segments: List): List { - return segments.mapIndexed { index, segment -> - if (index > 0 && segments[index - 1].equals("AppData", ignoreCase = true)) { - when (segment.lowercase()) { - "local" -> "Local" - "locallow" -> "LocalLow" - "roaming" -> "Roaming" - else -> segment - } - } else { - segment - } - } - } - 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) diff --git a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt index db53a432c9..5ff138932d 100644 --- a/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt +++ b/app/src/test/java/app/gamenative/service/epic/EpicCloudSavesTest.kt @@ -110,15 +110,6 @@ class EpicCloudSavesTest { assertTrue(resolved.exists()) } - @Test - fun `AppData child segments are canonicalized before creating save paths`() { - val segments = listOf("data", "prefix", "AppData", "locallow", "ZAUM Studio") - - val canonicalized = EpicCloudSavesManager.canonicalizeAppDataSegments(segments) - - assertEquals(listOf("data", "prefix", "AppData", "LocalLow", "ZAUM Studio"), canonicalized) - } - @Test fun `CustomFields round-trips through write then read`() { val original = CustomFields()