diff --git a/app/src/main/assets/redirect.tzst b/app/src/main/assets/redirect.tzst index 3e9bc47bd1..79c1371234 100644 Binary files a/app/src/main/assets/redirect.tzst and b/app/src/main/assets/redirect.tzst differ diff --git a/app/src/main/java/app/gamenative/service/DownloadService.kt b/app/src/main/java/app/gamenative/service/DownloadService.kt index 511f9e55f4..c05e72541d 100644 --- a/app/src/main/java/app/gamenative/service/DownloadService.kt +++ b/app/src/main/java/app/gamenative/service/DownloadService.kt @@ -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 @@ -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 + } } @Synchronized diff --git a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt index 77433b8f55..1b14cb4bad 100644 --- a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt +++ b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt @@ -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) + } + } + } + /** Convert appId to productId via DB lookup. */ fun getProductIdByAppId(appId: Int): String? { return getAmazonGameByAppId(appId)?.productId 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 9a52f106e3..e016eb6813 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -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)) + } + } + } + suspend fun getInstalledExe(appId: Int): String { return getInstance()?.epicManager?.getInstalledExe(appId) ?: "" } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index e114509a87..003d8389f4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -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 { return getInstance()?.gogManager?.verifyInstallation(gameId) ?: Pair(false, "Service not available") diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index b8175a6ed3..f288a6a618 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -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( @@ -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(), ) 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 a003e38a19..95e77a8883 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 @@ -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") diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index daec754a6d..e2f465f1fd 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -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 -> {} + } + } + + 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 } @@ -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)) { diff --git a/app/src/main/java/app/gamenative/utils/StorageUtils.kt b/app/src/main/java/app/gamenative/utils/StorageUtils.kt index 4bade589e3..103290cc1f 100644 --- a/app/src/main/java/app/gamenative/utils/StorageUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StorageUtils.kt @@ -98,6 +98,55 @@ object StorageUtils { return result } + private const val PUBLIC_INSTALL_DIR_NAME = "GameNative" + + /** + * Maps an app-specific dir (/Android/data//files) to a public install root + * (/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 + 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 diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index a21237db97..23807b0f4c 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -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); diff --git a/app/src/modern/assets/libredirect-bionic-wx.so b/app/src/modern/assets/libredirect-bionic-wx.so index 5722cc6052..72e00db09b 100755 Binary files a/app/src/modern/assets/libredirect-bionic-wx.so and b/app/src/modern/assets/libredirect-bionic-wx.so differ