-
-
Notifications
You must be signed in to change notification settings - Fork 404
Modern external storage #1768
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Modern external storage #1768
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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/<pkg>/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") | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The chunk staging directory is keyed only by Prompt for AI agents
Suggested change
|
||||||||||||||||||||
| .takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
+1834
to
+1836
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use directory-aware path containment.
Proposed fix val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
- .takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }
+ .takeIf {
+ val appPath = Paths.get(appDirPath).toAbsolutePath().normalize()
+ val internalPath = Paths.get(DownloadService.baseDataDirPath)
+ .toAbsolutePath().normalize()
+ !appPath.startsWith(internalPath)
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| 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") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A chunk rewritten after redirect space drops below 1 GiB is saved at the original path, but reads still select the stale redirected copy. Keep writing an already-redirected file to its existing location, or remove that copy before falling back. Prompt for AI agents
Suggested change
|
||||||||||||
| 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 | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Deleting a missing redirected chunk tree with Prompt for AI agents |
||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| super.delete(path, mustExist) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| override fun createDirectory(dir: Path, mustCreate: Boolean) { | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||||||||
| if (chunkRedirectTarget(dir) != null) { | ||||||||||||
| val target = chunkRedirectForWrite(dir) ?: dir | ||||||||||||
| delegate.createDirectories(target) | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| super.createDirectory(dir, mustCreate) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| override fun deleteRecursively(fileOrDirectory: Path, mustExist: Boolean) { | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||||||||
| 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 { | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The test depends on > 1 GiB free space on the temp partition (via the Prompt for AI agents |
||
| 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() | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use external-storage readiness for install-path selection.
getAppDirPath()still selectsexternalAppInstallPathwheneverPrefManager.useExternalStorageis true, even whenexternalStorageReadyis false because the volume is unavailable or blank.downloadApp()then persists and uses that invalid external path instead of falling back to internal storage. Gate the install-path decision onexternalStorageReady.🤖 Prompt for AI Agents