Skip to content

Modern external storage - #1768

Merged
utkarshdalal merged 2 commits into
masterfrom
modern-external-storage
Jul 24, 2026
Merged

Modern external storage#1768
utkarshdalal merged 2 commits into
masterfrom
modern-external-storage

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Description

Make external storage work on the modern build

Recording

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Fixes external installs on modern Android by using the user-selected app-scoped volume. Speeds up SD/USB installs by staging depot chunk temps in the internal cache during external installs.

  • Bug Fixes

    • Install to the selected external volume on modern builds; fall back to internal when the chosen path isn’t available.
    • Use external paths only when ready for staging/install.
    • Redirect .DepotDownloader/staging/chunks to internal cache via CaseInsensitiveFileSystem with low-space fallback; create and clean per-app staging dirs at start/end.
    • Throttle external progress writes in DownloadInfo (2s) and force-save on cancel/error; use temp+rename for resilience.
  • New Features

    • Show “Preparing download…” before the first progress update.

Written for commit aea9cce. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Improved external app install root resolution to consistently use the configured external storage path.
    • Enhanced download staging behavior by redirecting chunk staging to a dedicated per-app cache location when needed, ensuring smoother installs and better handling of storage constraints.
    • Added reliable post-download cleanup of redirected staging data.
  • Tests

    • Added coverage verifying chunk staging path redirection, correct read/write behavior, and proper deletion/cleanup on both original and redirected locations.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

SteamService now resolves the external install root from PrefManager and creates a temporary per-app chunk staging redirect for installs outside the base data directory. CaseInsensitiveFileSystem redirects eligible chunk I/O and cleans up redirected and original paths.

Changes

External storage download flow

Layer / File(s) Summary
Chunk staging filesystem redirect
app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt, app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
Adds optional chunk-path redirection with free-space checks, redirected reads and writes, metadata handling, directory creation, deletion, and coverage for cleanup and non-chunk paths.
External download path and lifecycle integration
app/src/main/java/app/gamenative/service/SteamService.kt
Uses PrefManager.externalStoragePath, configures per-app cache-based chunk staging for external installs, and removes it after the download job completes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SteamService
  participant DepotDownloader
  participant CaseInsensitiveFileSystem
  participant RedirectRoot
  SteamService->>CaseInsensitiveFileSystem: configure chunk staging redirect
  SteamService->>DepotDownloader: start app download
  DepotDownloader->>CaseInsensitiveFileSystem: write and read depot chunks
  CaseInsensitiveFileSystem->>RedirectRoot: redirect eligible chunk I/O
  SteamService->>RedirectRoot: delete staging directory on completion
Loading

Possibly related PRs

Suggested reviewers: vincebt, phobos665

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately reflects the main change to modern external storage support.
Description check ✅ Passed The PR description includes the required sections and most checklist items, with only the recording still left as a placeholder.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch modern-external-storage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/data/DownloadInfo.kt (1)

113-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for external persistence.

Extend app/src/test/java/app/gamenative/data/DownloadInfoTest.kt to cover throttling, forced persistence under contention, sibling-path classification, and failed temporary-file replacement.

Also applies to: 309-315

🤖 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/data/DownloadInfo.kt` around lines 113 -
133, Extend DownloadInfoTest to cover external-storage persistence behavior:
verify repeated non-forced calls are throttled while internal paths persist each
time, forced persistence succeeds under concurrent contention, sibling paths are
not misclassified as internal by isOnExternalStorage, and failures during
temporary-file replacement are handled without corrupting the existing snapshot.
app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt (1)

113-134: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

mustCreate/mustExist are silently ignored on the redirect path.

When a chunk redirect applies, createDirectory always calls delegate.createDirectories(target) regardless of mustCreate, and deleteRecursively never checks mustExist before returning. Per Okio's FileSystem contract, mustCreate=true should throw if the directory already exists, and mustExist=true should throw if neither the target nor the original path exists. Silently swallowing these flags for chunk paths could mask a "directory already exists"/"path missing" signal that DepotDownloader relies on internally.

🔧 Proposed fix to honor the contract
 override fun createDirectory(dir: Path, mustCreate: Boolean) {
     if (chunkRedirectTarget(dir) != null) {
         val target = chunkRedirectForWrite(dir) ?: dir
+        if (mustCreate && delegate.metadataOrNull(target) != null) {
+            throw IOException("$dir already exists.")
+        }
         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) {
+        val targetExists = delegate.metadataOrNull(target) != null
+        val originalExists = delegate.metadataOrNull(fileOrDirectory) != null
+        if (mustExist && !targetExists && !originalExists) {
+            throw IOException("$fileOrDirectory does not exist.")
+        }
+        if (targetExists) {
             delegate.deleteRecursively(target)
         }
-        if (delegate.metadataOrNull(fileOrDirectory) != null) {
+        if (originalExists) {
             delegate.deleteRecursively(fileOrDirectory)
         }
         return
     }
     super.deleteRecursively(fileOrDirectory, mustExist)
 }
🤖 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/CaseInsensitiveFileSystem.kt` around
lines 113 - 134, Update the redirect branches in createDirectory and
deleteRecursively to honor their mustCreate and mustExist parameters. In
createDirectory, preserve the existing-target check and throw the same
already-exists error required by the FileSystem contract when mustCreate is
true, otherwise create the redirected target as appropriate. In
deleteRecursively, track whether either the redirected target or original path
exists and throw the contract-required missing-path error when mustExist is true
and neither exists; retain current deletion behavior when the flags do not
require an error.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/src/main/java/app/gamenative/data/DownloadInfo.kt`:
- Around line 131-134: Update isOnExternalStorage to normalize or canonicalize
both path and DownloadService.baseDataDirPath, then determine containment using
path-segment-aware path APIs instead of startsWith. Preserve the blank
internalRoot behavior while ensuring sibling paths such as files2 are classified
as external.
- Line 49: Update persistProgressSnapshot and all listed call sites so the
throttle check and snapshot file write are serialized under one shared lock.
Ensure force = true calls proceed without being rejected by another caller’s CAS
result, while preserving throttling for non-forced calls and preventing
overlapping writes to the shared .tmp file.
- Around line 309-315: Update the external-storage branch in the DownloadInfo
persistence flow to check the Boolean result of tmp.renameTo(file). When the
rename fails, delete the temporary file and explicitly handle the failure by
throwing or catching and logging it, or replace the operation with an API that
reports replacement errors; preserve the direct write path for non-external
storage.

In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 1846-1849: Make chunkStagingRedirectDir unique for each download
attempt instead of deriving it solely from appId, using an attempt-specific
identifier while preserving the existing external-storage conditional behavior.
Update all related staging-directory usage and cleanup in downloadApp, including
the exception handler and invokeOnCompletion paths, so each job deletes only its
own directory.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/data/DownloadInfo.kt`:
- Around line 113-133: Extend DownloadInfoTest to cover external-storage
persistence behavior: verify repeated non-forced calls are throttled while
internal paths persist each time, forced persistence succeeds under concurrent
contention, sibling paths are not misclassified as internal by
isOnExternalStorage, and failures during temporary-file replacement are handled
without corrupting the existing snapshot.

In `@app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt`:
- Around line 113-134: Update the redirect branches in createDirectory and
deleteRecursively to honor their mustCreate and mustExist parameters. In
createDirectory, preserve the existing-target check and throw the same
already-exists error required by the FileSystem contract when mustCreate is
true, otherwise create the redirected target as appropriate. In
deleteRecursively, track whether either the redirected target or original path
exists and throw the contract-required missing-path error when mustExist is true
and neither exists; retain current deletion behavior when the flags do not
require an error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b401c7f3-705f-475b-a436-e6988b3a43f0

📥 Commits

Reviewing files that changed from the base of the PR and between 509c349 and e77ad4a.

📒 Files selected for processing (5)
  • app/src/main/java/app/gamenative/data/DownloadInfo.kt
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt

fun cancel(message: String) {
// Persist the most recent progress so a resume can pick up where it left off.
persistProgressSnapshot()
persistProgressSnapshot(force = true)

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 | ⚡ Quick win

Serialize persistence calls so forced snapshots cannot be dropped.

A concurrent caller can update lastPersistMs between get() and compareAndSet(), causing a force = true cancellation/failure snapshot to return without writing. Calls can also overlap while sharing the same .tmp file, producing stale or out-of-order snapshots. Serialize the throttle decision and the actual file write with one lock; forced calls should not fail merely because another caller won the CAS.

Also applies to: 120-128, 292-295, 309-312

🤖 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/data/DownloadInfo.kt` at line 49, Update
persistProgressSnapshot and all listed call sites so the throttle check and
snapshot file write are serialized under one shared lock. Ensure force = true
calls proceed without being rejected by another caller’s CAS result, while
preserving throttling for non-forced calls and preventing overlapping writes to
the shared .tmp file.

Comment on lines 131 to 134
private fun isOnExternalStorage(path: String): Boolean {
val internalRoot = DownloadService.baseDataDirPath
return internalRoot.isNotBlank() && !path.startsWith(internalRoot)
}

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 | 🟡 Minor | ⚡ Quick win

Use path-segment containment for storage classification.

path.startsWith(internalRoot) treats sibling paths such as /.../files2 as internal, disabling external-volume throttling and atomic persistence for them. Normalize/canonicalize the paths and compare path segments rather than a raw string prefix.

🤖 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/data/DownloadInfo.kt` around lines 131 -
134, Update isOnExternalStorage to normalize or canonicalize both path and
DownloadService.baseDataDirPath, then determine containment using
path-segment-aware path APIs instead of startsWith. Preserve the blank
internalRoot behavior while ensuring sibling paths such as files2 are classified
as external.

Comment on lines +309 to +315
if (isOnExternalStorage(appDirPath)) {
val tmp = File(dir, "$PERSISTENCE_FILE.tmp")
tmp.writeText(bytesDownloaded.toString())
tmp.renameTo(file)
} else {
file.writeText(bytesDownloaded.toString())
}

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching DownloadInfo.kt:\n'
fd -a 'DownloadInfo.kt$' . || true

printf '\nRelevant lines/context:\n'
file="$(fd 'DownloadInfo.kt$' . | head -n 1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '260,340p' "$file" | nl -ba -v260
fi

printf '\nOccurrences of isOnExternalStorage and persistence save/load methods:\n'
rg -n "isOnExternalStorage|PERSISTENCE_FILE|writeText|renameTo|load|save|delete" "$file" || true

printf '\nProject API/version hints:\n'
for f in app/build.gradle.kts app/build.gradle minSdkVersion build.gradle settings.gradle settings.gradle.kts gradle/libs.versions.toml; do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n "minSdk|targetSdk|compileSdk|android\.minSdk|targetSdkVersion|android\.targetSdk|compileSdkVersion|androidNamespace|composeOptions|kotlinOptions" "$f" || true
  fi
done

Repository: utkarshdalal/GameNative

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'DownloadInfo.kt$' . | head -n 1)"
printf 'file=%s\n' "$file"

printf '\nRelevant lines 260-340:\n'
sed -n '260,340p' "$file" | while IFS= read -r line; do
  printf '%s\n' "$line"
done

printf '\nOccurrences:\n'
rg -n "isOnExternalStorage|PERSISTENCE_FILE|writeText|renameTo|delete|Load|load|Save|save" "$file" || true

printf '\nProject API/version hints:\n'
for f in app/build.gradle.kts app/build.gradle minSdkVersion build.gradle settings.gradle settings.gradle.kts gradle/libs.versions.toml; do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n "minSdk|targetSdk|compileSdk|android\.minSdk|targetSdkVersion|android\.targetSdk|compileSdkVersion|androidNamespace|composeOptions|kotlinOptions" "$f" || true
  fi
done

if command -v git >/dev/null 2>&1; then
  printf '\nGit diff stat/name-status:\n'
  git diff --stat || true
  git diff --numstat || true
fi

Repository: utkarshdalal/GameNative

Length of output: 7101


🌐 Web query:

Java File renameTo returns false documentation Android

💡 Result:

In Android, the File.renameTo(File dest) method returns false if the operation fails, and it does not throw an IOException to explain why [1][2][3]. Because the method provides no specific error information, you must always check the returned boolean value to verify success [1][4][3]. According to the official Android documentation, common reasons for failure include [1][2][3]: - Mount Point Restrictions: The source and destination paths must reside on the same mount point [1][2]. A frequent issue in Android is attempting to move files between internal storage and an SD card, which are typically separate file systems [1][5]. - Permissions: Write permission is required on the directories containing both the source and the destination files [1][2][3]. - Search Permissions: Search (execute) permission is required for all parent directories of both the source and destination paths [1][2][3]. Additional practical considerations that may cause renameTo to return false include: - The destination file already exists (depending on the implementation of the underlying file system) [4][6]. - The source file is currently open or in use by another process [6]. - Insufficient storage space [4]. - The file system is mounted as read-only or is corrupted [4]. If renameTo fails, the standard practice is to copy the source file to the destination location and then delete the original file, as this handles cases where a simple rename is not supported [5]. The Android documentation also notes that the java.nio.file.Files.move method can be used for platform-independent file moves [1][2][3].

Citations:


Handle failed renameTo results.

File.renameTo() returns false on failure without throwing. Since this path leaves the original progress file untouched on failure, the load path may return stale progress. Check the result; if it fails, delete the temp file and either throw/catch-and-log the failure or use an explicit replacement/move API for the external storage write.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 309-309: A File / FileInputStream / FileReader / FileOutputStream / FileWriter is constructed from a path built with string concatenation ("baseDir" + input) or string interpolation ("$baseDir/$input"). If any segment is attacker-controlled, a value such as "../../etc/passwd" escapes the intended directory (path traversal). Validate and canonicalize the resolved path and confirm it stays under the intended base directory (e.g. compare File(baseDir, name).canonicalFile against baseDir.canonicalFile), or reject inputs containing path separators and "..".
Context: File(dir, "$PERSISTENCE_FILE.tmp")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(path-traversal-file-concat-kotlin)

🤖 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/data/DownloadInfo.kt` around lines 309 -
315, Update the external-storage branch in the DownloadInfo persistence flow to
check the Boolean result of tmp.renameTo(file). When the rename fails, delete
the temporary file and explicitly handle the failure by throwing or catching and
logging it, or replace the operation with an API that reports replacement
errors; preserve the direct write path for non-external storage.

Comment on lines +1846 to +1849
// 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) }

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Chunk staging dir is keyed only by appId, so a retriggered download can race the previous job's cleanup.

chunkStagingRedirectDir cleanup (deleteRecursively() at line 2131) always runs after removeDownloadJob(appId) — either the one inline in the exception handler (line 2122, unchanged) or the one in invokeOnCompletion (line 2130). Once removeDownloadJob fires, a new downloadApp() call for the same appId passes the downloadJobs.contains(appId) guard (line 1729) and can start writing fresh chunks into the very same depot_chunks/$appId directory that the old job's cleanup is about to wipe — an active sink could be deleted out from under the new attempt, causing spurious IOExceptions/failed downloads. Reordering cleanup-before-removeDownloadJob only closes the invokeOnCompletion path, not the inline exception-handler path at line 2122.

Making the staging directory unique per attempt removes the collision regardless of call ordering:

🔒 Proposed fix — make the staging dir unique per attempt
-                val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
+                val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId-${System.nanoTime()}")
                     .takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }

Also applies to: 1870-1888, 2125-2131

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 1847-1847: A File / FileInputStream / FileReader / FileOutputStream / FileWriter is constructed from a path built with string concatenation ("baseDir" + input) or string interpolation ("$baseDir/$input"). If any segment is attacker-controlled, a value such as "../../etc/passwd" escapes the intended directory (path traversal). Validate and canonicalize the resolved path and confirm it stays under the intended base directory (e.g. compare File(baseDir, name).canonicalFile against baseDir.canonicalFile), or reject inputs containing path separators and "..".
Context: File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(path-traversal-file-concat-kotlin)

🤖 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 1846 -
1849, Make chunkStagingRedirectDir unique for each download attempt instead of
deriving it solely from appId, using an attempt-specific identifier while
preserving the existing external-storage conditional behavior. Update all
related staging-directory usage and cleanup in downloadApp, including the
exception handler and invokeOnCompletion paths, so each job deletes only its own
directory.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

11 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt">

<violation number="1" location="app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:82">
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.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:107">
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.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:113">
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)`.</violation>

<violation number="4" location="app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt:122">
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`.</violation>
</file>

<file name="app/src/main/java/app/gamenative/data/DownloadInfo.kt">

<violation number="1" location="app/src/main/java/app/gamenative/data/DownloadInfo.kt:120">
P3: This persistence behavior has no focused regression coverage. Add tests for external vs. internal paths, interval/force behavior, and a failed rename so future changes do not silently lose resume progress.

(Based on your team's feedback about tests for complex logic.) [FEEDBACK_USED]</violation>

<violation number="2" location="app/src/main/java/app/gamenative/data/DownloadInfo.kt:125">
P2: Cancellation or service teardown within two seconds of a chunk snapshot can now leave the resume file behind the downloaded data, because these terminal callers still use the throttled default. Pass `force = true` at terminal persistence sites (or otherwise distinguish terminal snapshots) so resume state is not intentionally dropped.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/data/DownloadInfo.kt:126">
P2: A forced final snapshot can be dropped when another persistence callback updates `lastPersistMs` between `get()` and this CAS. Handle `force` outside the CAS gate (and serialize writes if needed) so `cancel()`/failure persistence retains its force guarantee.</violation>

<violation number="4" location="app/src/main/java/app/gamenative/data/DownloadInfo.kt:133">
P2: `path.startsWith(internalRoot)` is a raw string-prefix check. If `internalRoot` doesn't end with a path separator, a sibling directory sharing the same prefix (e.g., `<root>2/...`) would incorrectly match and be classified as internal storage, disabling external-volume throttling and atomic persistence for that path. Adding a trailing separator to the comparison (or comparing canonicalized path segments) would make this robust.</violation>

<violation number="5" location="app/src/main/java/app/gamenative/data/DownloadInfo.kt:312">
P2: `persistBytesDownloaded` ignores the return value of `tmp.renameTo(file)` for the external-storage path. If the rename fails (e.g., due to a locked file or volume limitation), the progress snapshot is silently dropped — the original file retains stale data, the temp file is abandoned, and no error surfaces. Consider checking the return value and falling back to a direct write (losing atomicity but still persisting) or logging the failure so it doesn't go unnoticed.</violation>
</file>

<file name="app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt">

<violation number="1" location="app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt:128">
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.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/SteamService.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/SteamService.kt:1848">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


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

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>

val now = System.currentTimeMillis()
val last = lastPersistMs.get()
if (!force && now - last < PERSIST_INTERVAL_MS) return
if (!lastPersistMs.compareAndSet(last, now)) 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: A forced final snapshot can be dropped when another persistence callback updates lastPersistMs between get() and this CAS. Handle force outside the CAS gate (and serialize writes if needed) so cancel()/failure persistence retains its force guarantee.

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/data/DownloadInfo.kt, line 126:

<comment>A forced final snapshot can be dropped when another persistence callback updates `lastPersistMs` between `get()` and this CAS. Handle `force` outside the CAS gate (and serialize writes if needed) so `cancel()`/failure persistence retains its force guarantee.</comment>

<file context>
@@ -109,8 +110,27 @@ data class DownloadInfo(
+            val now = System.currentTimeMillis()
+            val last = lastPersistMs.get()
+            if (!force && now - last < PERSIST_INTERVAL_MS) return
+            if (!lastPersistMs.compareAndSet(last, now)) return
+        }
+        persistBytesDownloaded(path)
</file context>

if (isOnExternalStorage(path)) {
val now = System.currentTimeMillis()
val last = lastPersistMs.get()
if (!force && now - last < PERSIST_INTERVAL_MS) 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: Cancellation or service teardown within two seconds of a chunk snapshot can now leave the resume file behind the downloaded data, because these terminal callers still use the throttled default. Pass force = true at terminal persistence sites (or otherwise distinguish terminal snapshots) so resume state is not intentionally dropped.

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/data/DownloadInfo.kt, line 125:

<comment>Cancellation or service teardown within two seconds of a chunk snapshot can now leave the resume file behind the downloaded data, because these terminal callers still use the throttled default. Pass `force = true` at terminal persistence sites (or otherwise distinguish terminal snapshots) so resume state is not intentionally dropped.</comment>

<file context>
@@ -109,8 +110,27 @@ data class DownloadInfo(
+        if (isOnExternalStorage(path)) {
+            val now = System.currentTimeMillis()
+            val last = lastPersistMs.get()
+            if (!force && now - last < PERSIST_INTERVAL_MS) return
+            if (!lastPersistMs.compareAndSet(last, now)) return
+        }
</file context>

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>

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>

if (isOnExternalStorage(appDirPath)) {
val tmp = File(dir, "$PERSISTENCE_FILE.tmp")
tmp.writeText(bytesDownloaded.toString())
tmp.renameTo(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.

P2: persistBytesDownloaded ignores the return value of tmp.renameTo(file) for the external-storage path. If the rename fails (e.g., due to a locked file or volume limitation), the progress snapshot is silently dropped — the original file retains stale data, the temp file is abandoned, and no error surfaces. Consider checking the return value and falling back to a direct write (losing atomicity but still persisting) or logging the failure so it doesn't go unnoticed.

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/data/DownloadInfo.kt, line 312:

<comment>`persistBytesDownloaded` ignores the return value of `tmp.renameTo(file)` for the external-storage path. If the rename fails (e.g., due to a locked file or volume limitation), the progress snapshot is silently dropped — the original file retains stale data, the temp file is abandoned, and no error surfaces. Consider checking the return value and falling back to a direct write (losing atomicity but still persisting) or logging the failure so it doesn't go unnoticed.</comment>

<file context>
@@ -281,7 +306,13 @@ data class DownloadInfo(
+            if (isOnExternalStorage(appDirPath)) {
+                val tmp = File(dir, "$PERSISTENCE_FILE.tmp")
+                tmp.writeText(bytesDownloaded.toString())
+                tmp.renameTo(file)
+            } else {
+                file.writeText(bytesDownloaded.toString())
</file context>


private fun isOnExternalStorage(path: String): Boolean {
val internalRoot = DownloadService.baseDataDirPath
return internalRoot.isNotBlank() && !path.startsWith(internalRoot)

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: path.startsWith(internalRoot) is a raw string-prefix check. If internalRoot doesn't end with a path separator, a sibling directory sharing the same prefix (e.g., <root>2/...) would incorrectly match and be classified as internal storage, disabling external-volume throttling and atomic persistence for that path. Adding a trailing separator to the comparison (or comparing canonicalized path segments) would make this robust.

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/data/DownloadInfo.kt, line 133:

<comment>`path.startsWith(internalRoot)` is a raw string-prefix check. If `internalRoot` doesn't end with a path separator, a sibling directory sharing the same prefix (e.g., `<root>2/...`) would incorrectly match and be classified as internal storage, disabling external-volume throttling and atomic persistence for that path. Adding a trailing separator to the comparison (or comparing canonicalized path segments) would make this robust.</comment>

<file context>
@@ -109,8 +110,27 @@ data class DownloadInfo(
+
+    private fun isOnExternalStorage(path: String): Boolean {
+        val internalRoot = DownloadService.baseDataDirPath
+        return internalRoot.isNotBlank() && !path.startsWith(internalRoot)
     }
 
</file context>
Suggested change
return internalRoot.isNotBlank() && !path.startsWith(internalRoot)
val normalizedRoot = if (internalRoot.endsWith(File.separator)) internalRoot else internalRoot + File.separator
return internalRoot.isNotBlank() && !path.startsWith(normalizedRoot) && path != internalRoot


// 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")

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

* synchronous writes, saturating FUSE badly enough to ANR the whole app.
* Internal-storage downloads keep the original persist-every-call behavior.
*/
fun persistProgressSnapshot(force: Boolean = 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.

P3: This persistence behavior has no focused regression coverage. Add tests for external vs. internal paths, interval/force behavior, and a failed rename so future changes do not silently lose resume progress.

(Based on your team's feedback about tests for complex logic.)

View Feedback

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/data/DownloadInfo.kt, line 120:

<comment>This persistence behavior has no focused regression coverage. Add tests for external vs. internal paths, interval/force behavior, and a failed rename so future changes do not silently lose resume progress.

(Based on your team's feedback about tests for complex logic.) </comment>

<file context>
@@ -109,8 +110,27 @@ data class DownloadInfo(
+     * synchronous writes, saturating FUSE badly enough to ANR the whole app.
+     * Internal-storage downloads keep the original persist-every-call behavior.
+     */
+    fun persistProgressSnapshot(force: Boolean = false) {
+        val path = persistencePath ?: return
+        if (isOnExternalStorage(path)) {
</file context>

@utkarshdalal
utkarshdalal force-pushed the modern-external-storage branch from e77ad4a to 123f8c7 Compare July 24, 2026 05:04

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fff57c7c-9a1a-4cce-99b2-780e9f1b08f2

📥 Commits

Reviewing files that changed from the base of the PR and between e77ad4a and 123f8c7.

📒 Files selected for processing (3)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt

Comment on lines +1835 to +1837
val chunkStagingRedirectDir = File(DownloadService.baseCacheDirPath, "depot_chunks/$appId")
.takeIf { !appDirPath.startsWith(DownloadService.baseDataDirPath) }

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.

Utkarsh Dalal added 2 commits July 23, 2026 22:25
…ilds

The modern override forced the external install root to the primary
emulated volume (internal flash). App-scoped Android/data dirs on
SD/USB volumes are equally permission-free and are all the settings
picker ever stores, so use the picked volume like legacy and the other
stores already do.
…nstalls

Each chunk was written to the install volume twice (compressed temp,
then decompressed final) plus a create/delete pair, roughly halving
install throughput on SD cards. Redirect the transient chunk dir to
the internal cache dir via the injected filesystem, with low-space
fallback to the old location, and wipe it when the download job ends.
@utkarshdalal
utkarshdalal force-pushed the modern-external-storage branch from 123f8c7 to aea9cce Compare July 24, 2026 05:27

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ecc6ea73-5a16-430e-bb30-51ce5b611296

📥 Commits

Reviewing files that changed from the base of the PR and between 123f8c7 and aea9cce.

📒 Files selected for processing (3)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/src/test/java/app/gamenative/utils/CaseInsensitiveFileSystemTest.kt
  • app/src/main/java/app/gamenative/utils/CaseInsensitiveFileSystem.kt

Comment on lines 452 to +453
private val externalAppInstallRoot: String
get() = if (BuildConfig.MODERN_ANDROID && DownloadService.baseExternalAppDirPath.isNotBlank()) {
DownloadService.baseExternalAppDirPath + "/files"
} else {
PrefManager.externalStoragePath
}
get() = PrefManager.externalStoragePath

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.

@utkarshdalal
utkarshdalal merged commit d067d4f into master Jul 24, 2026
3 checks passed
@utkarshdalal
utkarshdalal deleted the modern-external-storage branch July 24, 2026 05:44
This was referenced Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant