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
31 changes: 15 additions & 16 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines 452 to +453

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use external-storage readiness for install-path selection.

getAppDirPath() still selects externalAppInstallPath whenever PrefManager.useExternalStorage is true, even when externalStorageReady is 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 on externalStorageReady.

🤖 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/service/SteamService.kt` around lines 452 -
453, Update getAppDirPath() to select externalAppInstallPath only when both
PrefManager.useExternalStorage and externalStorageReady are true; otherwise fall
back to the internal install path so downloadApp() never persists an unavailable
or blank external location.


val externalAppInstallPath: String
get() = Paths.get(externalAppInstallRoot, "Steam", "steamapps", "common").pathString
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

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: The chunk staging directory is keyed only by appId, and deleteRecursively() runs after removeDownloadJob(appId) in invokeOnCompletion. Once the job is removed from the map, a retriggered download for the same app can start writing into the same depot_chunks/$appId directory that the old job's cleanup is about to wipe — potentially deleting active chunk files from the new attempt. Making the staging directory unique per attempt (e.g., appending a timestamp or attempt ID) would eliminate this race regardless of cleanup ordering.

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/SteamService.kt, line 1848:

<comment>The chunk staging directory is keyed only by `appId`, and `deleteRecursively()` runs *after* `removeDownloadJob(appId)` in `invokeOnCompletion`. Once the job is removed from the map, a retriggered download for the same app can start writing into the same `depot_chunks/$appId` directory that the old job's cleanup is about to wipe — potentially deleting active chunk files from the new attempt. Making the staging directory unique per attempt (e.g., appending a timestamp or attempt ID) would eliminate this race regardless of cleanup ordering.</comment>

<file context>
@@ -1841,9 +1837,17 @@ class SteamService : Service(), IChallengeUrlChanged {
 
+                // Installing to external storage: keep transient chunk files on
+                // internal storage so each chunk isn't written to the slow volume twice
+                val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
+                    .takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }
+
</file context>
Suggested change
val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/${appId}_${System.nanoTime()}")

.takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }

Comment on lines +1834 to +1836

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use directory-aware path containment.

startsWith compares raw characters, so a sibling such as /data/app2/... is treated as being under /data/app/.... That can disable the redirect and stage DepotDownloader chunks on external storage. Normalize both paths and use Path.startsWith (or canonical containment) instead.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
.takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }
val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
.takeIf {
val appPath = Paths.get(appDirPath).toAbsolutePath().normalize()
val internalPath = Paths.get(DownloadService.baseDataDirPath)
.toAbsolutePath().normalize()
!appPath.startsWith(internalPath)
}
🤖 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/service/SteamService.kt` around lines 1835 -
1837, Update the containment check in the chunkStagingRedirectDir expression to
normalize the base data directory and appDirPath, then compare them using
directory-aware Path.startsWith semantics rather than raw String.startsWith.
Preserve the existing takeIf behavior so redirection is disabled only when
appDirPath is actually within the base data directory.

val downloadJob = instance!!.scope.launch {
try {
// Get licenses from database
Expand All @@ -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!!,
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
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
Expand All @@ -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.
Expand All @@ -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

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: 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
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt, line 82:

<comment>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.</comment>

<file context>
@@ -43,6 +57,80 @@ class CaseInsensitiveFileSystem(
+
+    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)
</file context>
Suggested change
val target = chunkRedirectForWrite(file) ?: file
val target = chunkRedirectTarget(file)
?.takeIf { delegate.metadataOrNull(it) != null }
?: 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

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: Deleting a missing redirected chunk tree with mustExist = true silently succeeds, unlike every non-redirected path. Preserve the normal missing-path behavior when neither redirect nor original exists.

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/CaseInsensitiveFileSystem.kt, line 107:

<comment>Deleting a missing redirected chunk tree with `mustExist = true` silently succeeds, unlike every non-redirected path. Preserve the normal missing-path behavior when neither redirect nor original exists.</comment>

<file context>
@@ -43,6 +57,80 @@ class CaseInsensitiveFileSystem(
+        chunkRedirectTarget(path)?.let { target ->
+            if (delegate.metadataOrNull(target) != null) {
+                delegate.delete(target, mustExist)
+                return
+            }
+        }
</file context>

}
}
super.delete(path, mustExist)
}

override fun createDirectory(dir: Path, mustCreate: Boolean) {

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: createDirectory ignores the mustCreate parameter for redirected chunk paths. When the path maps to the redirect volume, the method calls delegate.createDirectories(target) instead of delegate.createDirectory(target, mustCreate). This means the caller's expectation about whether the directory should already exist is silently discarded — a call with mustCreate=true that expects an IOException on conflict will instead succeed. If the parent directories of the redirect target are guaranteed to exist, consider calling delegate.createDirectory(target, mustCreate) directly; otherwise use delegate.createDirectories(target.parent) followed by delegate.createDirectory(target, mustCreate).

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/CaseInsensitiveFileSystem.kt, line 113:

<comment>`createDirectory` ignores the `mustCreate` parameter for redirected chunk paths. When the path maps to the redirect volume, the method calls `delegate.createDirectories(target)` instead of `delegate.createDirectory(target, mustCreate)`. This means the caller's expectation about whether the directory should already exist is silently discarded — a call with `mustCreate=true` that expects an `IOException` on conflict will instead succeed. If the parent directories of the redirect target are guaranteed to exist, consider calling `delegate.createDirectory(target, mustCreate)` directly; otherwise use `delegate.createDirectories(target.parent)` followed by `delegate.createDirectory(target, mustCreate)`.</comment>

<file context>
@@ -43,6 +57,80 @@ class CaseInsensitiveFileSystem(
+        super.delete(path, mustExist)
+    }
+
+    override fun createDirectory(dir: Path, mustCreate: Boolean) {
+        if (chunkRedirectTarget(dir) != null) {
+            val target = chunkRedirectForWrite(dir) ?: dir
</file context>

if (chunkRedirectTarget(dir) != null) {
val target = chunkRedirectForWrite(dir) ?: dir
delegate.createDirectories(target)
return
}
super.createDirectory(dir, mustCreate)
}

override fun deleteRecursively(fileOrDirectory: Path, mustExist: Boolean) {

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: deleteRecursively ignores the mustExist parameter for redirected chunk paths. The mustExist contract from the caller is not forwarded: delegate.deleteRecursively(target) uses the single-argument default (true), and if neither the redirect nor the original path exists when mustExist=true, the method returns silently instead of throwing the expected IOException. Forward mustExist explicitly: delegate.deleteRecursively(target, mustExist) (and for the original path) so the semantics are preserved, and keep the silent return only when mustExist=false.

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/CaseInsensitiveFileSystem.kt, line 122:

<comment>`deleteRecursively` ignores the `mustExist` parameter for redirected chunk paths. The `mustExist` contract from the caller is not forwarded: `delegate.deleteRecursively(target)` uses the single-argument default (`true`), and if neither the redirect nor the original path exists when `mustExist=true`, the method returns silently instead of throwing the expected `IOException`. Forward `mustExist` explicitly: `delegate.deleteRecursively(target, mustExist)` (and for the original path) so the semantics are preserved, and keep the silent return only when `mustExist=false`.</comment>

<file context>
@@ -43,6 +57,80 @@ class CaseInsensitiveFileSystem(
+        super.createDirectory(dir, mustCreate)
+    }
+
+    override fun deleteRecursively(fileOrDirectory: Path, mustExist: Boolean) {
+        val target = chunkRedirectTarget(fileOrDirectory)
+        if (target != null) {
</file context>

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(

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: The test depends on > 1 GiB free space on the temp partition (via the REDIRECT_MIN_FREE_BYTES check in chunkRedirectForWrite). When free space is below threshold during CI runs, the redirect silently falls through to the original path and assertions like File(redirectDir, "file1/0_abc.chunk").exists() will fail, making the test flaky in constrained environments.

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

<comment>The test depends on > 1 GiB free space on the temp partition (via the `REDIRECT_MIN_FREE_BYTES` check in `chunkRedirectForWrite`). When free space is below threshold during CI runs, the redirect silently falls through to the original path and assertions like `File(redirectDir, "file1/0_abc.chunk").exists()` will fail, making the test flaky in constrained environments.</comment>

<file context>
@@ -120,4 +120,44 @@ class CaseInsensitiveFileSystemTest {
+    fun `chunk staging paths are redirected and cleaned up on both sides`() {
+        val redirectDir = createTempDir("chunk_redirect")
+        try {
+            val redirectFs = CaseInsensitiveFileSystem(
+                chunkStagingRedirect = redirectDir.toOkioPath(),
+            )
</file context>

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()
}
}
}
Loading