diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 62872563cc..89cab0c91f 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -147,6 +147,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow +import okio.Path.Companion.toPath import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -448,19 +449,8 @@ class SteamService : Service(), IChallengeUrlChanged { val internalAppInstallPath: String get() = Paths.get(DownloadService.baseDataDirPath, "Steam", "steamapps", "common").pathString - /** - * Root used when "use external storage" is enabled. On legacy this is whatever the - * user picked in settings (SD card / USB). On modern we force the primary external - * app-scoped dir (/storage/emulated/0/Android/data//files) so no permission - * is needed. Falls back to the configured path if for some reason the primary - * external app dir isn't available yet (e.g. before populateDownloadService runs). - */ private val externalAppInstallRoot: String - get() = if (BuildConfig.MODERN_ANDROID && DownloadService.baseExternalAppDirPath.isNotBlank()) { - DownloadService.baseExternalAppDirPath + "/files" - } else { - PrefManager.externalStoragePath - } + get() = PrefManager.externalStoragePath val externalAppInstallPath: String get() = Paths.get(externalAppInstallRoot, "Steam", "steamapps", "common").pathString @@ -490,9 +480,6 @@ class SteamService : Service(), IChallengeUrlChanged { return Paths.get(externalAppInstallRoot, "Steam", "steamapps", "staging").pathString } - // True when "use external storage" is on AND the resolved external root is usable. - // Modern flavor always has a usable primary-external app-scoped root, so this is - // effectively just useExternalStorage on modern. private val externalStorageReady: Boolean get() = PrefManager.useExternalStorage && File(externalAppInstallRoot).let { it.path.isNotBlank() && it.exists() @@ -1844,6 +1831,9 @@ class SteamService : Service(), IChallengeUrlChanged { notifyDownloadStarted(appId) instance?.notifierOrNull?.trackDownload(di, getAppInfoOf(appId)?.name.orEmpty(), NotificationHelper.NOTIFICATION_ID_STEAM) + val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId") + .takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) } + val downloadJob = instance!!.scope.launch { try { // Get licenses from database @@ -1863,6 +1853,11 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("maxDownloads: $maxDownloads") Timber.i("maxDecompress: $maxDecompress") + chunkStagingRedirectDir?.apply { + deleteRecursively() + mkdirs() + } + // Create DepotDownloader instance val depotDownloader = DepotDownloader( instance!!.steamClient!!, @@ -1873,7 +1868,10 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, - filesystem = CaseInsensitiveFileSystem(showDebugLog = false), + filesystem = CaseInsensitiveFileSystem( + showDebugLog = false, + chunkStagingRedirect = chunkStagingRedirectDir?.absolutePath?.toPath(), + ), ) // Create listeners for DLC apps @@ -2116,6 +2114,7 @@ class SteamService : Service(), IChallengeUrlChanged { // handlers, and cancellations thrown out of suspension points. // second call is a no-op if the inline path already removed the entry. removeDownloadJob(appId) + chunkStagingRedirectDir?.deleteRecursively() if (throwable is kotlinx.coroutines.CancellationException) { Timber.d(throwable, "Download canceled for app $appId") } diff --git a/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt index 733c6a753a..482add8f2e 100644 --- a/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt +++ b/app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt @@ -1,8 +1,12 @@ package app.gamenative.utils import `in`.dragonbra.javasteam.depotdownloader.BaseCaseInsensitiveFileSystem +import `in`.dragonbra.javasteam.depotdownloader.DepotDownloader +import okio.FileMetadata import okio.FileSystem import okio.Path +import okio.Sink +import okio.Source import timber.log.Timber import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -27,6 +31,7 @@ import java.util.concurrent.ConcurrentHashMap class CaseInsensitiveFileSystem( delegate: FileSystem = SYSTEM, val showDebugLog: Boolean = false, + private val chunkStagingRedirect: Path? = null, ) : BaseCaseInsensitiveFileSystem(delegate) { // parent → (lowercase segment → resolved child). bounded by directory count. @@ -43,6 +48,78 @@ class CaseInsensitiveFileSystem( private companion object { val DIRECTORY_OPS = setOf("createDirectory", "createDirectories", "deleteRecursively") + val CHUNK_DIR_SEGMENTS = listOf(DepotDownloader.CONFIG_DIR, "staging", "chunks") + const val REDIRECT_MIN_FREE_BYTES = 1L shl 30 + } + + private fun chunkRedirectTarget(path: Path): Path? { + val redirectRoot = chunkStagingRedirect ?: return null + val segments = path.segments + val start = (0..segments.size - CHUNK_DIR_SEGMENTS.size).firstOrNull { i -> + CHUNK_DIR_SEGMENTS.indices.all { j -> segments[i + j] == CHUNK_DIR_SEGMENTS[j] } + } ?: return null + return segments.drop(start + CHUNK_DIR_SEGMENTS.size).fold(redirectRoot) { acc, segment -> acc / segment } + } + + private fun chunkRedirectForWrite(path: Path): Path? { + val target = chunkRedirectTarget(path) ?: return null + return target.takeIf { chunkStagingRedirect!!.toFile().usableSpace > REDIRECT_MIN_FREE_BYTES } + } + + override fun sink(file: Path, mustCreate: Boolean): Sink { + if (chunkRedirectTarget(file) != null) { + val target = chunkRedirectForWrite(file) ?: file + target.parent?.let { delegate.createDirectories(it) } + return delegate.sink(target, mustCreate) + } + return super.sink(file, mustCreate) + } + + override fun source(file: Path): Source { + chunkRedirectTarget(file)?.let { target -> + if (delegate.metadataOrNull(target) != null) return delegate.source(target) + } + return super.source(file) + } + + override fun metadataOrNull(path: Path): FileMetadata? { + chunkRedirectTarget(path)?.let { target -> + delegate.metadataOrNull(target)?.let { return it } + } + return super.metadataOrNull(path) + } + + override fun delete(path: Path, mustExist: Boolean) { + chunkRedirectTarget(path)?.let { target -> + if (delegate.metadataOrNull(target) != null) { + delegate.delete(target, mustExist) + return + } + } + super.delete(path, mustExist) + } + + override fun createDirectory(dir: Path, mustCreate: Boolean) { + if (chunkRedirectTarget(dir) != null) { + val target = chunkRedirectForWrite(dir) ?: dir + delegate.createDirectories(target) + return + } + super.createDirectory(dir, mustCreate) + } + + override fun deleteRecursively(fileOrDirectory: Path, mustExist: Boolean) { + val target = chunkRedirectTarget(fileOrDirectory) + if (target != null) { + if (delegate.metadataOrNull(target) != null) { + delegate.deleteRecursively(target) + } + if (delegate.metadataOrNull(fileOrDirectory) != null) { + delegate.deleteRecursively(fileOrDirectory) + } + return + } + super.deleteRecursively(fileOrDirectory, mustExist) } override fun onPathParameter(path: Path, functionName: String, parameterName: String): Path { diff --git a/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt b/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt index 0054bbb008..30ee07ff8d 100644 --- a/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt +++ b/app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt @@ -120,4 +120,43 @@ class CaseInsensitiveFileSystemTest { val files = profileDirs[0].listFiles()?.map { it.name }?.sorted() ?: emptyList() assertEquals(listOf("slot1.sav", "slot2.sav", "slot3.sav"), files) } + + @Test + fun `chunk staging paths are redirected and cleaned up on both sides`() { + val redirectDir = createTempDir("chunk_redirect") + try { + val redirectFs = CaseInsensitiveFileSystem( + chunkStagingRedirect = redirectDir.toOkioPath(), + ) + val installDir = tmpDir.toOkioPath() / "steamapps" / "common" / "MyGame" + val chunkDir = installDir / ".DepotDownloader" / "staging" / "chunks" / "file1" + val chunkPath = chunkDir / "0_abc.chunk" + + redirectFs.createDirectories(installDir / ".DepotDownloader" / "staging") + redirectFs.createDirectories(chunkDir) + redirectFs.write(chunkPath) { writeUtf8("chunkdata") } + + // written under the redirect root, not beside the install dir + assertTrue(File(redirectDir, "file1/0_abc.chunk").exists()) + assertFalse(File(tmpDir, "steamapps/common/MyGame/.DepotDownloader/staging/chunks/file1/0_abc.chunk").exists()) + + // read back through the original path + assertEquals("chunkdata", redirectFs.read(chunkPath) { readUtf8() }) + assertTrue(redirectFs.exists(chunkPath)) + + redirectFs.delete(chunkPath) + assertFalse(File(redirectDir, "file1/0_abc.chunk").exists()) + + // deleteRecursively clears the per-file dir on the redirect side + redirectFs.write(chunkPath) { writeUtf8("leftover") } + redirectFs.deleteRecursively(chunkDir) + assertFalse(File(redirectDir, "file1").exists()) + + // non-chunk paths are untouched by the redirect + redirectFs.write(installDir / "game.pak") { writeUtf8("pak") } + assertTrue(File(tmpDir, "steamapps/common/MyGame/game.pak").exists()) + } finally { + redirectDir.deleteRecursively() + } + } }