Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -1183,7 +1183,7 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E

let currentSplits = currentManifest.allImportantSplits()
let newSplits = newManifest.allImportantSplits()
let toDownload = utils.getResourcesFrom(newSplits, filtering: currentSplits, isFirstRunAfterInstallation: AJPApplicationManager.isFirstRunAfterInstallation)
let toDownload = utils.getResourcesFrom(newSplits, filtering: currentSplits, isFirstRunAfterInstallation: AJPApplicationManager.isFirstRunAfterInstallation, requiringPresenceInMain: true)

self.tracker.trackInfo("important_package_download_started", value: NSMutableDictionary(dictionary: ["package_version": newManifest.version]))
let packageStartTime = Date().timeIntervalSince1970 * 1000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,46 @@ class AJPApplicationManagerUtils {

// MARK: - Resources and Strings

func getResourcesFrom(_ newSplits: [AJPResource], filtering currentSplits: [AJPResource], isFirstRunAfterInstallation: Bool) -> [AJPResource] {
/// Filters `newSplits` down to the ones that actually need downloading.
///
/// - Parameter requiringPresenceInMain: also queue a split whose file is missing from the
/// package's `main` directory. Pass `true` for important splits, whose installation is
/// gated on the file being present; leave it `false` for lazy splits, which are absent
/// from disk until they are downloaded and track that through `isDownloaded`.
func getResourcesFrom(_ newSplits: [AJPResource], filtering currentSplits: [AJPResource], isFirstRunAfterInstallation: Bool, requiringPresenceInMain: Bool = false) -> [AJPResource] {
if isFirstRunAfterInstallation {
return newSplits
}

var currentResourcesDict: [String: AJPResource] = [:]
for currentResource in currentSplits {
currentResourcesDict[currentResource.filePath] = currentResource
}


// Listed once up front rather than stat-ing per split. Comparing manifests cannot tell
// whether a file survived on disk, and a split that is missing from main/ will never be
// re-fetched by the diff alone — `isAppInstalled` then rejects the package on this and
// every later boot, pinning the app to its current version for good.
var filesInMain = Set<String>()
if requiringPresenceInMain {
filesInMain = Set(getAllFilesInDirectory(AJPApplicationConstants.JUSPAY_PACKAGE_DIR,
subFolder: AJPApplicationConstants.JUSPAY_MAIN_DIR,
includeSubfolders: true))
}

return newSplits.filter { newResource in
let currentResource = currentResourcesDict[newResource.filePath]
return shouldDownloadResource(newResource, existingResource: currentResource)
if shouldDownloadResource(newResource, existingResource: currentResource) {
return true
}

guard requiringPresenceInMain else { return false }

// Either name counts as present: the downloader writes `filePath` verbatim while
// `isAppInstalled` looks for the `.jsa` -> `.js` form, and honouring only one of them
// would re-download such a split on every single boot.
return !filesInMain.contains(newResource.filePath)
&& !filesInMain.contains(jsFileName(for: newResource.filePath))
}
}

Expand Down Expand Up @@ -212,26 +239,42 @@ class AJPApplicationManagerUtils {
func moveAllPackagesFromTempToMain() {
let tempDirPath = fileUtil.fullPathInStorageForFilePath(AJPApplicationConstants.JUSPAY_TEMP_DIR, inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR)

guard let tempFiles = try? FileManager.default.contentsOfDirectory(atPath: tempDirPath) else {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: tempDirPath, isDirectory: &isDirectory), isDirectory.boolValue else {
let map = NSMutableDictionary()
map["error"] = "Could not read temp directory"
tracker.trackError("temp_directory_read_failed", value: map)
return
}

// 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
Comment on lines +250 to +263

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.

} catch {
let map = NSMutableDictionary()
map["file"] = fileName
map["error"] = error.localizedDescription
tracker.trackError("file_move_failed", value: map)
}
}

// Reported once with a count rather than once per file: a package can carry hundreds of
// nested assets and this runs on the boot path. Failures are still logged individually.
let map = NSMutableDictionary()
map["count"] = NSNumber(value: movedCount)
map["total"] = NSNumber(value: tempFiles.count)
tracker.trackInfo("file_moved_to_main", value: map)
}

func moveResourceToMain(_ resource: AJPResource) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,57 @@ final class AJPApplicationManagerUtilsTests: XCTestCase {
XCTAssertEqual(result.first?.filePath, "b.js")
}

// MARK: - getResourcesFrom (requiringPresenceInMain)

func testGetResourcesFrom_requiringPresence_unchangedSplitMissingFromMain_downloads() {
let split = makeResource(url: "https://cdn.example.com/a.js", filePath: "a.js", checksum: "aaa")
let result = utils.getResourcesFrom([split], filtering: [split],
isFirstRunAfterInstallation: false,
requiringPresenceInMain: true)
XCTAssertEqual(result.count, 1,
"A split identical in both manifests must still be re-downloaded when its file is gone from main")
}

func testGetResourcesFrom_requiringPresence_unchangedSplitPresentInMain_skips() throws {
try writeFile("a.js", subFolder: AJPApplicationConstants.JUSPAY_MAIN_DIR,
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR)
let split = makeResource(url: "https://cdn.example.com/a.js", filePath: "a.js", checksum: "aaa")
let result = utils.getResourcesFrom([split], filtering: [split],
isFirstRunAfterInstallation: false,
requiringPresenceInMain: true)
XCTAssertTrue(result.isEmpty, "An unchanged split already on disk must not be re-downloaded")
}

func testGetResourcesFrom_requiringPresence_nestedSplitPresentInMain_skips() throws {
let path = "assets/assets/images/chat-bg-light.png"
try writeFile(path, subFolder: AJPApplicationConstants.JUSPAY_MAIN_DIR,
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR)
let split = makeResource(url: "https://cdn.example.com/bg.png", filePath: path, checksum: "ccc")
let result = utils.getResourcesFrom([split], filtering: [split],
isFirstRunAfterInstallation: false,
requiringPresenceInMain: true)
XCTAssertTrue(result.isEmpty, "Presence must be detected for splits nested in subdirectories")
}

func testGetResourcesFrom_requiringPresence_jsaSplitStoredUnderRawName_skips() throws {
try writeFile("bundle.jsa", subFolder: AJPApplicationConstants.JUSPAY_MAIN_DIR,
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR)
let split = makeResource(url: "https://cdn.example.com/bundle.jsa", filePath: "bundle.jsa", checksum: "ddd")
let result = utils.getResourcesFrom([split], filtering: [split],
isFirstRunAfterInstallation: false,
requiringPresenceInMain: true)
XCTAssertTrue(result.isEmpty,
"A .jsa split present under its raw name must count as installed, not re-download every boot")
}

func testGetResourcesFrom_withoutRequiringPresence_missingFromMain_stillSkips() {
let split = makeResource(url: "https://cdn.example.com/a.js", filePath: "a.js", checksum: "aaa")
let result = utils.getResourcesFrom([split], filtering: [split],
isFirstRunAfterInstallation: false)
XCTAssertTrue(result.isEmpty,
"Lazy splits keep the manifest-only comparison: absence from main is their normal state")
}

// MARK: - prepareTempDirectory / cleanupTempDirectory

func testPrepareTempDirectory_createsTempDirectory() {
Expand Down Expand Up @@ -410,4 +461,92 @@ final class AJPApplicationManagerUtilsTests: XCTestCase {
XCTAssertTrue(FileManager.default.fileExists(atPath: keep),
"deleteFile must only remove the targeted file")
}

// MARK: - moveAllPackagesFromTempToMain

/// Absolute path of `relativePath` inside the package's `main` directory.
private func mainPath(_ relativePath: String) -> String {
fileUtil.fullPathInStorageForFilePath(
"\(AJPApplicationConstants.JUSPAY_MAIN_DIR)/\(relativePath)",
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR)
}

private func contents(ofMain relativePath: String) throws -> String {
try String(contentsOfFile: mainPath(relativePath), encoding: .utf8)
}

private func stageInTemp(_ relativePath: String, content: String) throws {
try writeFile(relativePath, subFolder: AJPApplicationConstants.JUSPAY_TEMP_DIR,
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR, content: content)
}

private func installInMain(_ relativePath: String, content: String) throws {
try writeFile(relativePath, subFolder: AJPApplicationConstants.JUSPAY_MAIN_DIR,
inFolder: AJPApplicationConstants.JUSPAY_PACKAGE_DIR, content: content)
}

/// The regression this fix exists for: a delta update that re-downloads only part of a
/// directory must not take the rest of that directory down with it.
func testMoveAllPackages_partialUpdate_preservesUntouchedFilesInSameDirectory() throws {
try installInMain("assets/assets/images/chat-bg-light.png", content: "old-bg")
try installInMain("assets/assets/images/logo.png", content: "old-logo")

utils.prepareTempDirectory()
try stageInTemp("assets/assets/images/logo.png", content: "new-logo")

utils.moveAllPackagesFromTempToMain()

XCTAssertEqual(try contents(ofMain: "assets/assets/images/logo.png"), "new-logo",
"The re-downloaded file must be installed")
XCTAssertEqual(try contents(ofMain: "assets/assets/images/chat-bg-light.png"), "old-bg",
"A file the delta did not re-download must survive the install")
}

func testMoveAllPackages_movesNestedFilesPreservingStructure() throws {
utils.prepareTempDirectory()
try stageInTemp("main.jsbundle", content: "bundle")
try stageInTemp("assets/assets/images/chat-bg-light.png", content: "bg")

utils.moveAllPackagesFromTempToMain()

XCTAssertEqual(try contents(ofMain: "main.jsbundle"), "bundle")
XCTAssertEqual(try contents(ofMain: "assets/assets/images/chat-bg-light.png"), "bg")
}

func testMoveAllPackages_clearsMovedFilesFromTemp() throws {
utils.prepareTempDirectory()
try stageInTemp("assets/a.png", content: "a")

utils.moveAllPackagesFromTempToMain()

let leftovers = utils.getAllFilesInDirectory(AJPApplicationConstants.JUSPAY_PACKAGE_DIR,
subFolder: AJPApplicationConstants.JUSPAY_TEMP_DIR,
includeSubfolders: true)
XCTAssertTrue(leftovers.isEmpty, "Files must be moved out of temp, not copied")
}

func testMoveAllPackages_overwritesExistingDestinationFile() throws {
try installInMain("main.jsbundle", content: "old")
utils.prepareTempDirectory()
try stageInTemp("main.jsbundle", content: "new")

utils.moveAllPackagesFromTempToMain()

XCTAssertEqual(try contents(ofMain: "main.jsbundle"), "new")
}

func testMoveAllPackages_whenTempDirectoryAbsent_doesNotCrash() {
utils.cleanupTempDirectory()
XCTAssertFalse(FileManager.default.fileExists(atPath: tempDirPath))
XCTAssertNoThrow(utils.moveAllPackagesFromTempToMain())
}

func testMoveAllPackages_emptyTempDirectory_leavesMainUntouched() throws {
try installInMain("main.jsbundle", content: "installed")
utils.prepareTempDirectory()

utils.moveAllPackagesFromTempToMain()

XCTAssertEqual(try contents(ofMain: "main.jsbundle"), "installed")
}
}
Loading