Skip to content
Merged
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 modified app/src/main/assets/redirect.tzst
Binary file not shown.
22 changes: 20 additions & 2 deletions app/src/main/java/app/gamenative/service/DownloadService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app.gamenative.service

import android.content.Context
import android.os.Environment
import app.gamenative.PrefManager
import app.gamenative.utils.StorageUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -37,10 +38,27 @@ object DownloadService {
baseExternalAppDirPath = extFiles?.parentFile?.path ?: ""

val sm = context.getSystemService(android.os.storage.StorageManager::class.java)
externalVolumePaths = StorageUtils.getAllExternalFilesDirs(context)
val appFilesDirs = StorageUtils.getAllExternalFilesDirs(context)
.filter { Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED }
.filter { sm?.getStorageVolume(it)?.isPrimary != true }
.map { it.absolutePath }
// both layouts per volume: legacy Android/data (existing installs) + public root (new installs)
externalVolumePaths = appFilesDirs
.flatMap { dir -> listOfNotNull(dir.absolutePath, StorageUtils.publicInstallRoot(dir)?.absolutePath) }
.distinct()

migrateExternalStoragePath()
}

// Android/data paths pay a ~1000x FUSE metadata penalty (MediaProvider disables kernel
// caching there); repoint the install pref at the public root so new installs avoid it
private fun migrateExternalStoragePath() {
val pref = PrefManager.externalStoragePath
if (pref.isBlank() || !pref.contains("/Android/data/")) return
val public = StorageUtils.publicInstallRoot(File(pref)) ?: return
if (StorageUtils.ensureInstallRoot(public)) {
Timber.i("Migrating external install root from $pref to ${public.absolutePath}")
PrefManager.externalStoragePath = public.absolutePath

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Existing external GOG, Epic, and Amazon installs become unreachable after startup migration: only the preference moves, while game directories stay under Android/data/.../files. Migrate those directories (or retain legacy roots for each service) before changing the persisted root.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/DownloadService.kt, line 60:

<comment>Existing external GOG, Epic, and Amazon installs become unreachable after startup migration: only the preference moves, while game directories stay under `Android/data/.../files`. Migrate those directories (or retain legacy roots for each service) before changing the persisted root.</comment>

<file context>
@@ -37,10 +38,27 @@ object DownloadService {
+        val public = StorageUtils.publicInstallRoot(File(pref)) ?: return
+        if (StorageUtils.ensureInstallRoot(public)) {
+            Timber.i("Migrating external install root from $pref to ${public.absolutePath}")
+            PrefManager.externalStoragePath = public.absolutePath
+        }
     }
</file context>

}
}

@Synchronized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ class AmazonService : Service() {
return if (game.isInstalled && game.installPath.isNotEmpty()) game.installPath else null
}

/** Persist a new install path for [appId] after an on-disk migration. */
fun updateInstallPath(appId: Int, path: String) {
runBlocking(Dispatchers.IO) {
val game = instance?.amazonManager?.getGameByAppId(appId) ?: return@runBlocking
if (game.isInstalled && game.installPath != path) {
instance?.amazonManager?.markInstalled(game.productId, path, game.installSize, game.versionId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: updateInstallPath accesses the mutable instance field twice via safe-call — the second read can silently miss the markInstalled call if the service is destroyed between reads. Capture instance into a local val (as isUpdatePending does) so the DB update is consistent with the on-disk migration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/amazon/AmazonService.kt, line 324:

<comment>`updateInstallPath` accesses the mutable `instance` field twice via safe-call — the second read can silently miss the `markInstalled` call if the service is destroyed between reads. Capture `instance` into a local val (as `isUpdatePending` does) so the DB update is consistent with the on-disk migration.</comment>

<file context>
@@ -316,6 +316,16 @@ class AmazonService : Service() {
+            runBlocking(Dispatchers.IO) {
+                val game = instance?.amazonManager?.getGameByAppId(appId) ?: return@runBlocking
+                if (game.isInstalled && game.installPath != path) {
+                    instance?.amazonManager?.markInstalled(game.productId, path, game.installSize, game.versionId)
+                }
+            }
</file context>

}
}
}

/** Convert appId to productId via DB lookup. */
fun getProductIdByAppId(appId: Int): String? {
return getAmazonGameByAppId(appId)?.productId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,15 @@ class EpicService : Service() {
}
}

fun updateInstallPath(appId: Int, path: String) {
runBlocking(Dispatchers.IO) {
val game = getInstance()?.epicManager?.getGameById(appId) ?: return@runBlocking
if (game.installPath != path) {
getInstance()?.epicManager?.updateGame(game.copy(installPath = path))
}
}
Comment on lines +369 to +375

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Double getInstance() call creates a silent-failure window if the service is destroyed between the two calls. Capture the instance once at the top of the runBlocking block and reuse it, same as the AmazonService.updateInstallPath pattern.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/epic/EpicService.kt, line 369:

<comment>Double getInstance() call creates a silent-failure window if the service is destroyed between the two calls. Capture the instance once at the top of the runBlocking block and reuse it, same as the AmazonService.updateInstallPath pattern.</comment>

<file context>
@@ -366,6 +366,15 @@ class EpicService : Service() {
             }
         }
 
+        fun updateInstallPath(appId: Int, path: String) {
+            runBlocking(Dispatchers.IO) {
+                val game = getInstance()?.epicManager?.getGameById(appId) ?: return@runBlocking
</file context>
Suggested change
fun updateInstallPath(appId: Int, path: String) {
runBlocking(Dispatchers.IO) {
val game = getInstance()?.epicManager?.getGameById(appId) ?: return@runBlocking
if (game.installPath != path) {
getInstance()?.epicManager?.updateGame(game.copy(installPath = path))
}
}
fun updateInstallPath(appId: Int, path: String) {
runBlocking(Dispatchers.IO) {
val instance = getInstance() ?: return@runBlocking
val game = instance.epicManager.getGameById(appId) ?: return@runBlocking
if (game.installPath != path) {
instance.epicManager.updateGame(game.copy(installPath = path))
}
}
}

}

suspend fun getInstalledExe(appId: Int): String {
return getInstance()?.epicManager?.getInstalledExe(appId) ?: ""
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/app/gamenative/service/gog/GOGService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ class GOGService : Service() {
}
}

fun updateInstallPath(gameId: String, path: String) {
runBlocking(Dispatchers.IO) {
val manager = getInstance()?.gogManager ?: return@runBlocking
val game = manager.getGameFromDbById(gameId) ?: return@runBlocking
if (game.installPath != path) manager.updateGame(game.copy(installPath = path))
}
}

fun verifyInstallation(gameId: String): Pair<Boolean, String?> {
return getInstance()?.gogManager?.verifyInstallation(gameId)
?: Pair(false, "Service not available")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -561,16 +561,18 @@ fun SettingsGroupInterface(
useExternalStorage = it
PrefManager.useExternalStorage = it
if (it && dirs.isNotEmpty()) {
PrefManager.externalStoragePath = dirs[0].absolutePath
PrefManager.externalStoragePath = StorageUtils.preferredInstallRoot(dirs[0])
}
},
)
if (useExternalStorage) {
// Currently selected item
var selectedIndex by rememberSaveable {
var selectedIndex by rememberSaveable(dirs) {
mutableStateOf(
dirs.indexOfFirst { it.absolutePath == PrefManager.externalStoragePath }
.takeIf { it >= 0 } ?: 0,
dirs.indexOfFirst { dir ->
dir.absolutePath == PrefManager.externalStoragePath ||
StorageUtils.publicInstallRoot(dir)?.absolutePath == PrefManager.externalStoragePath
}.takeIf { it >= 0 } ?: 0,
)
}
SettingsListDropdown(
Expand All @@ -579,7 +581,7 @@ fun SettingsGroupInterface(
value = selectedIndex,
onItemSelected = { idx ->
selectedIndex = idx
PrefManager.externalStoragePath = dirs[idx].absolutePath
PrefManager.externalStoragePath = StorageUtils.preferredInstallRoot(dirs[idx])
},
colors = settingsTileColorsAlt(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3667,6 +3667,16 @@ private fun setupXEnvironment(
envVars.remove("DXVK_FRAME_RATE")
envVars.remove("VKD3D_FRAME_RATE")
if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1")

val ffpGameDir = runCatching {
Container.drivesIterator(container.drives).asSequence()
.firstOrNull { it[0] == "A" }?.let { File(it[1]).canonicalFile.path }
}.getOrNull() ?: ""
if (ffpGameDir.startsWith("/storage/")) {
envVars.put("FFP_ENABLE", "1")
envVars.put("FFP_MARKERS", "/steamapps/common/;/dosdevices/a:")
}

val graphicsDriverConfig = KeyValueSet(container.getGraphicsDriverConfig())
if (graphicsDriverConfig.get("version").lowercase(Locale.getDefault()).contains("gen8")) {
var tuDebug = envVars.get("TU_DEBUG")
Expand Down
25 changes: 22 additions & 3 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1043,11 +1043,30 @@ object ContainerUtils {
}
}

if (gameFolderPath != null) {
val resolvedGameFolderPath = if (gameSource == GameSource.CUSTOM_GAME) {
gameFolderPath
} else {
StorageUtils.resolveLegacyGameDir(gameFolderPath)
}

if (resolvedGameFolderPath != null && resolvedGameFolderPath != gameFolderPath) {
when (gameSource) {
GameSource.GOG ->
GOGService.updateInstallPath(extractGameIdFromContainerId(appId).toString(), resolvedGameFolderPath)
GameSource.EPIC ->
EpicService.updateInstallPath(extractGameIdFromContainerId(appId), resolvedGameFolderPath)
GameSource.AMAZON ->
runCatching { extractGameIdFromContainerId(appId) }.getOrNull()
?.let { AmazonService.updateInstallPath(it, resolvedGameFolderPath) }
else -> {}
}
}
Comment on lines +1046 to +1063

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make legacy-directory migration and provider metadata updates atomic or retryable.

The directory can be moved successfully while the provider update silently no-ops because its service is not running, leaving the container path and database path inconsistent.

  • app/src/main/java/app/gamenative/utils/ContainerUtils.kt#L1046-L1063: do not remap A: or complete migration until the provider path is durably updated.
  • app/src/main/java/app/gamenative/service/amazon/AmazonService.kt#L319-L327: replace the nullable service-instance update with guaranteed persistence or an explicit failure.
  • app/src/main/java/app/gamenative/service/epic/EpicService.kt#L369-L376: replace the nullable service-instance update with guaranteed persistence or an explicit failure.
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt#L304-L310: replace the nullable service-instance update with guaranteed persistence or an explicit failure.
📍 Affects 4 files
  • app/src/main/java/app/gamenative/utils/ContainerUtils.kt#L1046-L1063 (this comment)
  • app/src/main/java/app/gamenative/service/amazon/AmazonService.kt#L319-L327
  • app/src/main/java/app/gamenative/service/epic/EpicService.kt#L369-L376
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt#L304-L310
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/utils/ContainerUtils.kt` around lines 1046 -
1063, Make legacy-directory migration retryable or atomic by ensuring provider
metadata is durably persisted before completing the remap in ContainerUtils
around resolvedGameFolderPath and the GameSource update branches. Update
AmazonService, EpicService, and GOGService path-update methods to persist
through a guaranteed mechanism or return an explicit failure instead of silently
no-oping when the service instance is unavailable; propagate that failure so
migration is not finalized until the provider path update succeeds. Affected
sites: app/src/main/java/app/gamenative/utils/ContainerUtils.kt:1046-1063
requires migration gating;
app/src/main/java/app/gamenative/service/amazon/AmazonService.kt:319-327,
app/src/main/java/app/gamenative/service/epic/EpicService.kt:369-376, and
app/src/main/java/app/gamenative/service/gog/GOGService.kt:304-310 require
guaranteed persistence or explicit failure.


if (resolvedGameFolderPath != null) {
// Check if A: drive is already mapped to the correct path
var hasCorrectADrive = false
for (drive in Container.drivesIterator(container.drives)) {
if (drive[0] == "A" && drive[1] == gameFolderPath) {
if (drive[0] == "A" && drive[1] == resolvedGameFolderPath) {
hasCorrectADrive = true
break
}
Expand All @@ -1058,7 +1077,7 @@ object ContainerUtils {
val currentDrives = container.drives
// Rebuild drives string, excluding existing A: drive and adding new one
val drivesBuilder = StringBuilder()
drivesBuilder.append("A:$gameFolderPath")
drivesBuilder.append("A:$resolvedGameFolderPath")

// Add all other drives (excluding A:)
for (drive in Container.drivesIterator(currentDrives)) {
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/app/gamenative/utils/StorageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,55 @@ object StorageUtils {
return result
}

private const val PUBLIC_INSTALL_DIR_NAME = "GameNative"

/**
* Maps an app-specific dir (<volume>/Android/data/<pkg>/files) to a public install root
* (<volume>/GameNative). MediaProvider disables FUSE kernel caching under Android/data,
* making per-open metadata ops ~1000x slower there; public dirs get normal dcache treatment.
*/
fun publicInstallRoot(appFilesDir: File): File? {
val path = appFilesDir.absolutePath
val idx = path.indexOf("/Android/data/")
if (idx <= 0) return null
return File(path.substring(0, idx), PUBLIC_INSTALL_DIR_NAME)
}

fun ensureInstallRoot(dir: File): Boolean {
if (!dir.isDirectory && !dir.mkdirs()) return false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Modern builds cannot create the proposed <volume>/GameNative root under scoped storage, so ensureInstallRoot falls back to the slow Android/data/.../files location and this optimization is ineffective. Use a user-granted SAF tree/MediaStore-compatible location, or retain an app-specific directory for these builds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/utils/StorageUtils.kt, line 116:

<comment>Modern builds cannot create the proposed `<volume>/GameNative` root under scoped storage, so `ensureInstallRoot` falls back to the slow `Android/data/.../files` location and this optimization is ineffective. Use a user-granted SAF tree/MediaStore-compatible location, or retain an app-specific directory for these builds.</comment>

<file context>
@@ -98,6 +98,55 @@ object StorageUtils {
+    }
+
+    fun ensureInstallRoot(dir: File): Boolean {
+        if (!dir.isDirectory && !dir.mkdirs()) return false
+        runCatching { File(dir, ".nomedia").createNewFile() }
+        return true
</file context>

runCatching { File(dir, ".nomedia").createNewFile() }
return true
}

fun preferredInstallRoot(appFilesDir: File): String {
val public = publicInstallRoot(appFilesDir)
if (public != null && ensureInstallRoot(public)) return public.absolutePath
return appFilesDir.absolutePath
}

fun resolveLegacyGameDir(path: String?): String? {
if (path.isNullOrBlank()) return path
val idx = path.indexOf("/Android/data/")
if (idx <= 0) return path
val filesIdx = path.indexOf("/files/", idx)
if (filesIdx < 0) return path
val legacyRoot = File(path.substring(0, filesIdx + "/files".length))
val rel = path.substring(filesIdx + "/files/".length)
val src = File(path)
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (!src.isDirectory) return if (dst.isDirectory) dst.absolutePath else path
if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
dst.parentFile?.mkdirs()
return if (src.renameTo(dst)) {
Timber.i("Migrated game dir $path to ${dst.absolutePath}")
dst.absolutePath
} else {
Timber.w("Could not migrate $path; leaving in place")
path
}
}

/**
* Gets all app-specific external files directories, using StorageManager as a fallback
* for cases where context.getExternalFilesDirs(null) might return null or incomplete results
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import java.util.concurrent.atomic.AtomicLong;

public abstract class ImageFsInstaller {
public static final byte LATEST_VERSION = 28;
public static final byte LATEST_VERSION = 29;

private static void resetContainerImgVersions(Context context) {
ContainerManager manager = new ContainerManager(context);
Expand Down
Binary file modified app/src/modern/assets/libredirect-bionic-wx.so
Binary file not shown.
Loading