Skip to content

fix(ios): stop package install from wiping unchanged nested assets - #373

Open
yuvrajjsingh0 wants to merge 1 commit into
mainfrom
fix/ios-nested-split-install
Open

yuvrajjsingh0 wants to merge 1 commit into
mainfrom
fix/ios-nested-split-install

Conversation

@yuvrajjsingh0

@yuvrajjsingh0 yuvrajjsingh0 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Symptom

An OTA update downloads successfully, then fails to install, and the app stays pinned to its old package version — permanently. From a production device:

init_with_local_config_versions   | package_version: 87
important_package_download_result | result: SUCCESS, time_taken: 1288 ms
file_moved_to_main                | file: main.jsbundle
file_moved_to_main                | file: assets
package_install_failed            | file_missing: assets/assets/images/chat-bg-light.png
package_update_result             | result: FAILED, reason: package copy failed

Root cause

Two pieces that contradict each other:

  • Downloads are a delta. getResourcesFrom only queues splits whose URL or checksum changed. Unchanged files are expected to already be on disk.
  • The install replaced whole top-level entries. moveAllPackagesFromTempToMain listed temp with contentsOfDirectory (non-recursive), so for a package whose splits live in subdirectories it handed movePackageFromTempToMain a directory — and that call deletes the destination before moving. main/assets was replaced wholesale by whatever the delta happened to stage, deleting every unchanged file under it.

isAppInstalled then failed on the first missing file, so the manifest was never updated — but the new index bundle had already been moved into place. The app booted the new bundle against a half-deleted asset tree while the SDK believed it was still on the old version, and since the delta filter never consults the filesystem, the deleted files were never re-fetched. Every later launch repeated the same failure.

This is not a regression — the shape is unchanged since the original iOS import (ff00b45); the Swift migration (92b7ed0) was a faithful port. What changed recently is the app-version wipe added in 9ec317b/5a9b1b4, which resets state on every app update and so hides the bug until the first delta OTA after it.

Fix

  1. Enumerate temp recursively so every entry moved is a file and the move merges instead of replacing. This is what handleTempPackageInstallation already does on the boot-timeout path, and what the Android SDK does via TempWriter.copyToMain. Intermediate directories are created by fullPathInStorageForFilePath, so nested paths need nothing further.
  2. Make the download filter self-healing — an important split missing from main/ is queued even when both manifests agree on it. Without this, devices already broken stay broken. Lazy splits keep the manifest-only comparison (absence from disk is their normal state), via a defaulted requiringPresenceInMain parameter passed only at the important-splits call site.

Behavior change to be aware of

file_moved_to_main is now emitted once with a count rather than once per file — a package can carry hundreds of nested assets and this runs on the boot path. Any log query keyed on its file field needs updating. Move failures are still logged per file.

Testing

Unit — 11 tests added to AJPApplicationManagerUtilsTests (6 for the move, 5 for the presence filter). The core regression test was verified as a genuine negative control: it fails against the old enumeration. Full suite otherwise green apart from three failures that pre-exist on a clean tree (two network-dependent suites plus testGetCurrentApplicationManifest_configBootTimeout).

End-to-end — driven through a real RN app on the simulator against a mock release-config server, two important splits nested at assets/assets/images/, rev=a original content and rev=b updated:

# Case This PR Before
S1 none updated, in timeout v2 · a, a v2 · a, a
S2 one updated, in timeout v2 · a, b v1 stuck · bg MISSING, logo b
S3 both updated, in timeout v2 · b, b v2 · b, b
S4 none updated, after timeout v2 · a, a
S5 one updated, after timeout v2 · a, b v2 · a, b
S6 both updated, after timeout v2 · b, b
S7 file missing + new release v2 · a, a (healed) v2 · bg still MISSING
S8 file missing + same version v1 · bg still MISSING

The pre-fix run reproduced the production log line for line, including file_moved_to_main | file: assets and the same file_missing path. Two notes from the matrix:

  • S5/S3 pass even before this PR — the after-timeout path already merged correctly, and a release where every file in the directory changes is harmless. Only partial updates break, which is why this stayed latent.
  • S7 before this PR was worse than "stuck": with nothing changed in the manifest, the toDownload.isEmpty early return skips the install gate entirely, so the version bumped to v2 with the file still missing — silent corruption, nothing logged.

Not addressed

  • S8: when a file is missing and the release config version is unchanged, tryDownloadingUpdate short-circuits before the filter runs, so nothing heals. Recovery still needs a new release or an app-version bump.
  • A failed install still leaves the new main.jsbundle in place rather than staging the package atomically.
  • .zip splits are saved but never extracted; .jsa splits are written under their raw name while the install gate looks for the .js form.

No docs impact: no public API, CLI, env-var, or dashboard surface, and these tracker events are not referenced in airborne_docs/.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved package downloads by identifying important resources that are missing from the main installation.
    • Recognized equivalent .js and .jsa resource files when checking installed content.
    • Preserved nested folder structures and existing files during package installation.
    • Safely handled empty or unavailable temporary package directories.
    • Consolidated installation progress reporting while retaining details for failed file moves.

`moveAllPackagesFromTempToMain` listed the temp directory non-recursively and
handed each top-level entry to `movePackageFromTempToMain`, which deletes the
destination before moving. For a package whose splits live in subdirectories
that meant `main/assets` was replaced wholesale by whatever the update happened
to stage in temp — and since `getResourcesFrom` only queues splits whose URL or
checksum changed, every unchanged file in that directory was deleted and never
re-fetched. `isAppInstalled` then failed on the first missing file ("package
copy failed"), so the manifest was never updated even though the new index
bundle had already been moved into place: the app booted the new bundle against
a half-deleted asset tree, and stayed pinned to the old version on every
subsequent launch.

Enumerate temp recursively so every entry moved is a file, which is what
`handleTempPackageInstallation` already does when installing a package staged
after a boot timeout, and what the Android SDK does via `TempWriter.copyToMain`.
Intermediate directories are created by `fullPathInStorageForFilePath`, so
nested paths need nothing further.

Also make the download filter self-healing: an important split missing from
`main/` is queued even when both manifests agree on it. Without this, devices
already in the broken state stay stuck indefinitely, because the manifest diff
never consults the filesystem. Lazy splits keep the manifest-only comparison —
absence from disk is their normal state until they are downloaded.

`file_moved_to_main` is now emitted once with a count rather than once per file,
since a package can carry hundreds of nested assets and this runs on the boot
path. Move failures are still logged individually.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@semanticdiff-com

semanticdiff-com Bot commented Jul 27, 2026

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

OTA resource selection now optionally verifies files in main, while temporary package installation handles recursive contents, safe directory checks, aggregated success reporting, and expanded regression coverage.

Changes

OTA resource and package updates

Layer / File(s) Summary
Resource presence filtering
airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift, airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift, airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift
getResourcesFrom can require matching files in main, including nested paths and .js/.jsa variants; important-package downloads enable the new filtering behavior and tests cover the cases.
Recursive temporary package moves
airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift, airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift
Temporary package moves validate directory availability, process nested entries, aggregate successful move reporting, retain failure logging, and test preservation, overwrites, cleanup, and no-op cases.

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

Suggested reviewers: yash02rajput

Poem

I’m a bunny with packages tucked in a row,
Through nested paths the fresh files go.
Main checks each split, .js joins .jsa,
Temp files hop cleanly away.
One happy count reports the flight—
And tests keep every burrow right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing package installs from overwriting unchanged nested assets.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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 fix/ios-nested-split-install

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift (1)

308-317: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Also test .jsa stored as its mapped .js filename.

The current test covers filesInMain.contains(filePath) only; add a bundle.jsa split with bundle.js on disk to protect the jsFileName(for:) branch.

🤖 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
`@airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift`
around lines 308 - 317, Extend
testGetResourcesFrom_requiringPresence_jsaSplitStoredUnderRawName_skips to also
create the split resource on disk under its mapped bundle.js filename, then
assert the same installed-resource behavior through the jsFileName(for:) branch.
Preserve the existing raw-name case and empty-result expectation.
🤖 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
`@airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift`:
- Around line 250-263: Update movePackageFromTempToMain to create the
destination parent directory with intermediate directories before replacing or
moving the package file. Derive the parent from the destination path so nested
entries such as main/assets/... succeed even when absent, while preserving the
existing replacement and move behavior.

---

Nitpick comments:
In
`@airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift`:
- Around line 308-317: Extend
testGetResourcesFrom_requiringPresence_jsaSplitStoredUnderRawName_skips to also
create the split resource on disk under its mapped bundle.js filename, then
assert the same installed-resource behavior through the jsFileName(for:) branch.
Preserve the existing raw-name case and empty-result expectation.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fa7ea651-5be8-40d1-8b49-aee59ee8fa00

📥 Commits

Reviewing files that changed from the base of the PR and between a706e7f and 5306088.

📒 Files selected for processing (3)
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift
  • airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift

Comment on lines +250 to +263
// Enumerated recursively so every entry handed to `movePackageFromTempToMain` is a file.
// Moving a top-level entry instead would hand it a *directory*, and that call deletes the
// destination before moving: `main/assets` would lose every file this update's delta did
// not re-download. This is the enumeration `handleTempPackageInstallation` already uses
// when installing a package staged after a boot timeout.
let tempFiles = getAllFilesInDirectory(AJPApplicationConstants.JUSPAY_PACKAGE_DIR,
subFolder: AJPApplicationConstants.JUSPAY_TEMP_DIR,
includeSubfolders: true)

var movedCount = 0
for fileName in tempFiles {
do {
try movePackageFromTempToMain(fileName)
let map = NSMutableDictionary()
map["file"] = fileName
tracker.trackInfo("file_moved_to_main", value: map)
movedCount += 1

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift \
  --match movePackageFromTempToMain --view expanded
rg -n -C3 'moveItem\(atPath:.*toPath:|createDirectory\(atPath:' \
  airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift

Repository: juspay/airborne

Length of output: 935


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift"

echo "## relevant outline"
ast-grep outline "$file" \
  --match movePackageFromTempToMain --view expanded || true

echo "## inspect function and surrounding call site"
sed -n '200,320p' "$file" | cat -n

echo "## constants and directory helper usages"
rg -n 'AJPApplicationConstants\.JUSPAY_(PACKAGE_DIR|TEMP_DIR)|movePackageFromTempToMain|getAllFilesInDirectory|main/assets|assets' "$file"

Repository: juspay/airborne

Length of output: 9070


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## moveAllPackagesFromTempToMain with line numbers"
file="airborne_sdk_i iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift"
sed -n '50,85p' "$file" | cat -n

echo "## directory and file manager helpers"
rg -n 'func removeItem(atPath:|func createDirectory|createDirectory\(atPath:|fullPathInStorageForFilePath|cleanupEntireDirectory|getAllFilesInDirectory' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift

echo "## behavioral probe for swift FileManager.moveItem without intermediate parent"
python3 - <<'PY'
import os, tempfile
with tempfile.TemporaryDirectory() as d:
    root = os.path.join(d, "root")
    os.makedirs(root)
    src_parent = os.path.join(root, "src")
    os.makedirs(src_parent)
    src = os.path.join(src_parent, "file.txt")
    with open(src, "w") as f: f.write("x")
    dst = os.path.join(root, "nested", "assets", "file.txt")
    with open(src, "w") as f: f.write("x")
    for attempt in range(2):
        print("attempt", attempt, "exists_parent", os.path.exists(os.path.join(root, "nested")))
        try:
            os.rename(src, dst)
            print("successful")
            break
        except FileNotFoundError as e:
            print("FileNotFoundError", e)
PY

Repository: juspay/airborne

Length of output: 332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift"

echo "## inspect movePackageFromTempToMain"
sed -n '210,235p' "$file" | cat -n

echo "## inspect moveAllPackagesFromTempToMain"
sed -n '242,278p' "$file" | cat -n

echo "## directory and filesystem helpers"
rg -n 'func removeItem\(atPath:|func createDirectory|createDirectory\(atPath:|fullPathInStorageForFilePath|cleanupEntireDirectory|getAllFilesInDirectory' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift || true

echo "## behavioral probe for moveItem without intermediate parent"
python3 - <<'PY'
import os, tempfile
with tempfile.TemporaryDirectory() as d:
    root = os.path.join(d, "root")
    os.makedirs(root)
    os.makedirs(os.path.join(root, "src"))
    src = os.path.join(root, "src", "file.txt")
    with open(src, "w") as f: f.write("x")
    dst = os.path.join(root, "nested", "assets", "file.txt")
    print("exists_parent", os.path.exists(os.path.join(root, "nested")))
    try:
        os.rename(src, dst)
        print("moved")
    except FileNotFoundError as e:
        print("FileNotFoundError", repr(str(e)))
PY

Repository: juspay/airborne

Length of output: 10373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## cleanupEntireDirectory implementation"
rg -n -C5 'func cleanupEntireDirectory' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift

echo "## full move operations candidates"
rg -n -C4 'func move|let fileManager = FileManager.default|createDirectory|removeItem|moveItem' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift

Repository: juspay/airborne

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## fileUtil references"
rg -n -C3 '\.fileUtil|fileUtil\s+:' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift || true

echo "## cleanupEntireDirectory implementation with file extension expansion"
rg -n -C5 'cleanupEntireDirectory' airborne_sdk_iOS || true

echo "## full move operation area from Utils"
sed -n '370,455p' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift | cat -n

Repository: juspay/airborne

Length of output: 14410


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## AJPFileUtil.cleanupEntireDirectory implementation"
sed -n '250,340p' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift | cat -n

echo "## AJPFileUtil.fileUtil storage path constructor"
rg -n -C5 'func fullPathInStorageForFilePath|fullPathInStorageForFilePath' airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift | head -n 120

echo "## constants JUSPAY_MAIN_DIR and JUSPAY_TEMP_DIR"
rg -n -C3 'JUSPAY_(MAIN_DIR|TEMP_DIR|PACKAGE_DIR)' airborne_sdk_iOS

Repository: juspay/airborne

Length of output: 50371


Create destination parent directories before moving nested package files.

movePackageFromTempToMain() removes mainPath only when it already exists, then runs moveItem(atPath:toPath:) without creating JuspayPackages/main/assets/... first, so a new first-time nested asset fails this path. Add createDirectory(atPath:withIntermediateDirectories:attributes:) for the destination directory before the replace/move.

🤖 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
`@airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift`
around lines 250 - 263, Update movePackageFromTempToMain to create the
destination parent directory with intermediate directories before replacing or
moving the package file. Derive the parent from the destination path so nested
entries such as main/assets/... succeed even when absent, while preserving the
existing replacement and move behavior.

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