From 530608876d339bdea3497415d5c8cc3c60d24b44 Mon Sep 17 00:00:00 2001 From: yuvrajjsingh0 Date: Mon, 27 Jul 2026 18:59:43 +0530 Subject: [PATCH] fix: install nested package splits without wiping the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../AirborneSwift/AJPApplicationManager.swift | 2 +- .../AJPApplicationManagerUtils.swift | 59 +++++++- .../AJPApplicationManagerUtilsTests.swift | 139 ++++++++++++++++++ 3 files changed, 191 insertions(+), 9 deletions(-) diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift index 7eba8ecf..54b399f4 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift @@ -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 diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift index 42c064f4..ce1fc60b 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerUtils.swift @@ -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() + 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)) } } @@ -212,19 +239,28 @@ 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 } catch { let map = NSMutableDictionary() map["file"] = fileName @@ -232,6 +268,13 @@ class AJPApplicationManagerUtils { 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) { diff --git a/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift b/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift index 3e0afb5f..22a0f90a 100644 --- a/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift +++ b/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPApplicationManagerUtilsTests.swift @@ -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() { @@ -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") + } }