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 @@ -144,6 +144,24 @@ Fired when a new config block is loaded.
}
```

### unresolved_properties_updated

iOS only. Fired when the `unresolved_properties` block of an extended release config changed and
the new copy was cached. The extended payload is versioned independently of `config`, so this can
fire on a fetch where `config_updated` did not (and vice versa). `new_config_version` is the
version carried *inside* `unresolved_properties`, not `config.version`.

```typescript
{
category: "lifecycle",
subCategory: "hyperota",
level: "info",
label: "ota_update",
key: "unresolved_properties_updated",
value: { new_config_version: "7488203155491131392", app_update_id: "<UUID>" }
}
```

### package_update_result

Fired on completion of package download.
Expand Down
11 changes: 11 additions & 0 deletions airborne_docs/docs/react-native-sdk/reference/ios-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ init(releaseConfigURL: String, delegate: AirborneDelegate? = nil)
| `getFileContent` | `func getFileContent(_ filePath: String) -> String` | Reads the content of the file at `filePath` (relative to the package) and returns it as a string. |
| `getReleaseConfig` | `func getReleaseConfig() -> String` | Returns the current release config as a stringified JSON. |

:::info[`unresolved_properties` in the release config]
The SDK always requests the release config with `extended=true`. When the backend honours it, the
response carries an extra top-level key, `unresolved_properties`, alongside `config`, `package` and
`resources` — the unresolved targeting bundle, for clients that resolve releases locally.

`getReleaseConfig()` passes it through verbatim without interpreting it, and the SDK caches it
across launches so it stays available on the next boot. The key is **absent** (not `null`) when the
backend does not serve it, so treat it as optional. `config`, `package` and `resources` are
unchanged either way.
:::

## AirborneDelegate

The protocol you conform to (typically in an `AppDelegate` extension) to customize behavior and receive callbacks. **All methods are optional** — sensible defaults apply when a method is not implemented.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
get { collectionsLock.withLock { _package } }
set { collectionsLock.withLock { _package = newValue } }
}

/// The opaque `unresolved_properties` payload from the last extended release config, cached
/// across launches. Held apart from `config` because it is versioned independently — see
/// `updateUnresolvedProperties(_:)`.
private var _unresolvedProperties: NSDictionary?
public var unresolvedProperties: NSDictionary? {
get { collectionsLock.withLock { _unresolvedProperties } }
set { collectionsLock.withLock { _unresolvedProperties = newValue } }
}

private var _releaseConfigError: String?
public var releaseConfigError: String? {
Expand Down Expand Up @@ -309,16 +318,27 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
self.handleTempResourcesInstallation()

self.config = self.readApplicationConfig()

if self.package == nil || self.config == nil || self.resources == nil {
if let data = try? self.fileUtil.getFileDataFromBundle("release_config.json") {
if let manifest = try? AJPApplicationManifest(data: data as NSData) {
if self.config == nil { self.config = manifest.config }
if self.package == nil { self.package = manifest.package }
if self.resources == nil { self.resources = manifest.resources }

// Read at most once, and only if some component actually needs it.
var bundledManifest: AJPApplicationManifest?
var didReadBundledManifest = false
func readBundledManifest() -> AJPApplicationManifest? {
if !didReadBundledManifest {
didReadBundledManifest = true
if let data = try? self.fileUtil.getFileDataFromBundle("release_config.json") {
bundledManifest = try? AJPApplicationManifest(data: data as NSData)
}
}

return bundledManifest
}

if self.package == nil || self.config == nil || self.resources == nil {
if let manifest = readBundledManifest() {
if self.config == nil { self.config = manifest.config }
if self.package == nil { self.package = manifest.package }
if self.resources == nil { self.resources = manifest.resources }
}

if self.config == nil {
self.config = AJPApplicationConfig()
let logVal = NSMutableDictionary()
Expand Down Expand Up @@ -347,7 +367,13 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
logVal["release_config"] = "Read bundled release_config.json"
self.tracker.trackInfo("bundled_release_config", value: logVal)
}


// Resolved on its own, after the three above: it is optional, so a cache miss here must
// not drag the bundled release config in as a replacement for config/package/resources
// the way a miss on those does. Falling back to the bundled release config gives a host
// app that ships one a usable payload on the very first boot, before any fetch.
self.unresolvedProperties = self.readUnresolvedProperties() ?? readBundledManifest()?.unresolvedProperties

self.initializeLazyResourcesDownloadStatus()

collectionsLock.withLock {
Expand Down Expand Up @@ -517,13 +543,15 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E

@objc public func getCurrentApplicationManifest() -> Any? {
collectionsLock.withLock {
return AJPApplicationManifest(package: self.package, config: self.config, resources: self.resources)
return AJPApplicationManifest(package: _package, config: _config, resources: _resources, unresolvedProperties: _unresolvedProperties)
}
}

@objc public func getCurrentResult() -> AJPDownloadResult {
let manifest = AJPApplicationManifest(package: self.package, config: self.config, resources: self.resources)

let manifest = collectionsLock.withLock {
AJPApplicationManifest(package: _package, config: _config, resources: _resources, unresolvedProperties: _unresolvedProperties)
}

let releaseConfigStatus = self.releaseConfigDownloadStatus
let packageStatus = self.importantPackageDownloadStatus

Expand Down Expand Up @@ -685,6 +713,19 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
private func readApplicationConfig() -> AJPApplicationConfig? {
return try? fileUtil.getDecodedInstanceForClass(AJPApplicationConfig.self, withContentOfFileName: AJPApplicationConstants.APP_CONFIG_DATA_FILE_NAME, inFolder: AJPApplicationConstants.JUSPAY_MANIFEST_DIR) as? AJPApplicationConfig
}

/// Every class the opaque payload may contain. Secure decoding validates a collection's
/// elements as well as the collection itself, so the whole JSON class graph has to be listed.
/// `NSNull` is included because the payload is backend-controlled and a single null anywhere
/// inside it would otherwise fail the decode.
private static let unresolvedPropertiesClasses: [AnyClass] = [NSDictionary.self, NSArray.self, NSString.self, NSNumber.self, NSNull.self]

/// Reads back the cached `unresolved_properties`. Returns nil when nothing has been cached
/// yet, which is the normal state until the first extended response arrives.
private func readUnresolvedProperties() -> NSDictionary? {
let decoded = try? fileUtil.getDecodedInstanceForClasses(Self.unresolvedPropertiesClasses, withContentOfFileName: AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME, inFolder: AJPApplicationConstants.JUSPAY_MANIFEST_DIR)
return decoded as? NSDictionary
}

private func updatePackage(_ package: AJPApplicationPackage, didDownloadImportant: Bool, startTime: TimeInterval) {
let logVal = NSMutableDictionary()
Expand Down Expand Up @@ -770,7 +811,40 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
}
}
}


/**
* Caches the `unresolved_properties` of a freshly fetched release config.
*
* Deliberately triggered on its own rather than alongside `updateConfig(_:)`: the extended
* payload carries its own `config_version`, so it can change on a fetch where `config.version`
* did not. The comparison is therefore on content, not on any version field.
*
* A response without the key writes nothing and leaves the cached copy alone, so a backend
* that stops sending it does not wipe what was already stored. The consequence is that no
* path ever clears the cache — the same as `config` and `package`, which are only overwritten.
*/
private func updateUnresolvedProperties(_ unresolvedProperties: NSDictionary?) {
guard let unresolvedProperties = unresolvedProperties else { return }

guard !unresolvedProperties.isEqual(self.unresolvedProperties) else { return }

do {
try fileUtil.writeInstance(unresolvedProperties, fileName: AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME, inFolder: AJPApplicationConstants.JUSPAY_MANIFEST_DIR)

// Only promoted in memory once it is on disk, so the two copies cannot diverge.
self.unresolvedProperties = unresolvedProperties

let logData = NSMutableDictionary()
logData["new_config_version"] = unresolvedProperties["config_version"] as? String ?? ""
tracker.trackInfo("unresolved_properties_updated", value: logData)
} catch {
let logVal = NSMutableDictionary()
logVal["error"] = error.localizedDescription
logVal["file_name"] = AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME
tracker.trackError("release_config_write_failed", value: logVal)
}
}

// MARK: - Handlers & Sub-Loops

private func getReleaseConfigTimeout() -> NSNumber? {
Expand Down Expand Up @@ -855,6 +929,7 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
self.downloadedApplicationManifest = manifest
self.releaseConfigDownloadStatus = .completed
self.cleanUpUnwantedFiles()
self.updateUnresolvedProperties(manifest?.unresolvedProperties)

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

Persist unresolved properties when a timed-out request later succeeds.

Line 932 only updates app-unresolved-properties.dat when the fetch invokes startDownload completion. A valid response received after the timeout takes the utils.saveManifestToTemp(manifest) path instead. initializeDefaults() does not read that temp manifest for unresolved properties. The next launch can therefore return stale or missing data despite the completed response.

Update the standalone cache when saving the late manifest. Add a regression test for this timeout path.

Proposed fix
                 } else {
                     if let manifest = manifest, manifestError == nil {
                         self.tracker.trackInfo("release_config_fetch_after_timeout", value: NSMutableDictionary(dictionary: ["version": manifest.config.version]))
                         utils.saveManifestToTemp(manifest)
+                        self.updateUnresolvedProperties(manifest.unresolvedProperties)
                     }
                 }
📝 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
self.updateUnresolvedProperties(manifest?.unresolvedProperties)
} else {
if let manifest = manifest, manifestError == nil {
self.tracker.trackInfo("release_config_fetch_after_timeout", value: NSMutableDictionary(dictionary: ["version": manifest.config.version]))
utils.saveManifestToTemp(manifest)
self.updateUnresolvedProperties(manifest.unresolvedProperties)
}
}
🤖 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/AJPApplicationManager.swift`
at line 932, Persist unresolved properties in the late-success response path
that calls utils.saveManifestToTemp(manifest), not only in the startDownload
completion path invoking updateUnresolvedProperties. Ensure the standalone
unresolved-properties cache is updated from the successfully received manifest
before or alongside saving the temp manifest, and add a regression test covering
a timed-out request that later succeeds and verifies the persisted values.

if let config = manifest?.config {
self.updateConfig(config)
}
Expand All @@ -870,6 +945,7 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
if let manifest = manifest {
self.downloadedApplicationManifest = manifest
self.cleanUpUnwantedFiles()
self.updateUnresolvedProperties(manifest.unresolvedProperties)
self.updateConfig(manifest.config)
self.tryDownloadingUpdate()
} else {
Expand Down Expand Up @@ -1029,8 +1105,34 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E
}
}

/// Name of the query parameter that asks the backend for the extended release config.
private static let extendedQueryParamName = "extended"

/**
* The release config is always fetched in its extended form.
*
* Any `extended` already present on the configured URL is *overwritten* rather than appended
* to, so the flag cannot be turned off from wherever the URL is configured. Built through
* `URLComponents` so a URL that already carries a query string (`...release-config.json?toss=42`)
* keeps it, instead of growing a malformed second `?`.
*
* Falls back to the URL as configured when it cannot be decomposed into components, leaving
* the failure to the request itself rather than introducing one here.
*/
internal static func extendedReleaseConfigURL(from url: URL) -> URL {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}

var queryItems = (components.queryItems ?? []).filter { $0.name != extendedQueryParamName }
queryItems.append(URLQueryItem(name: extendedQueryParamName, value: "true"))
components.queryItems = queryItems

return components.url ?? url
}

private func fetchReleaseConfigWithCompletionHandler(_ completionHandler: @escaping AJPReleaseConfigCompletionHandler) {

var timeoutObserver: Any? = nil
timeoutObserver = NotificationCenter.default.addObserver(forName: AJPApplicationConstants.RELEASE_CONFIG_TIMEOUT_NOTIFICATION, object: nil, queue: OperationQueue()) { [weak self] note in
guard let self = self else { return }
Expand All @@ -1056,11 +1158,13 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E

self.startReleaseConfigTimeoutTimer()

guard let manifestUrl = URL(string: self.releaseConfigURL) else {
guard let configuredUrl = URL(string: self.releaseConfigURL) else {
completionHandler(nil, NSError(domain: "in.juspay.Airborne", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]), false)
return
}


let manifestUrl = Self.extendedReleaseConfigURL(from: configuredUrl)

var request = URLRequest(url: manifestUrl)
request.httpMethod = "GET"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,22 @@ import Foundation
/// - Throws: An `NSError` if the file cannot be read or if decoding fails.
/// - Returns: The fully decoded object. You will typically cast it to your expected type after receiving it.
@objc public func getDecodedInstanceForClass(_ className: AnyClass, withContentOfFileName fileName: String, inFolder folderName: String) throws -> Any {
return try getDecodedInstanceForClasses([className], withContentOfFileName: fileName, inFolder: folderName)
}

/// Reads and decodes an archived object whose class graph spans more than one type.
/// - Parameters:
/// - classes: Every class permitted anywhere in the decoded object graph. Secure decoding
/// validates contained objects too, so a collection needs the classes of its
/// elements listed alongside its own — an untyped JSON payload, for instance,
/// needs `NSDictionary`, `NSArray`, `NSString`, `NSNumber` and `NSNull`.
/// - fileName: The name of the file containing the archived data.
/// - folderName: The folder where the file is located within the workspace.
/// - Throws: An `NSError` if the file cannot be read or if decoding fails.
/// - Returns: The fully decoded object. You will typically cast it to your expected type after receiving it.
@objc public func getDecodedInstanceForClasses(_ classes: [AnyClass], withContentOfFileName fileName: String, inFolder folderName: String) throws -> Any {
let fileData = try getFileDataFromInternalStorage(fileName, inFolder: folderName)
guard let decoded = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [className], from: fileData) else {
guard let decoded = try NSKeyedUnarchiver.unarchivedObject(ofClasses: classes, from: fileData) else {
throw NSError(domain: "in.juspay.Airborne", code: 1003, userInfo: [NSLocalizedDescriptionKey: "Failed to decode object or object was nil"])
}
return decoded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import Foundation
public static let APP_OLD_RESOURCES_DATA_FILE_NAME = "app-resources-old.dat"
public static let APP_TEMP_RESOURCES_DATA_FILE_NAME = "app-resources-temp.dat"

public static let APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME = "app-unresolved-properties.dat"

// MARK: - Notification Names
public static let BOOT_TIMEOUT_NOTIFICATION = Notification.Name("AJPBootTimeoutNotification")
public static let PACKAGE_RESOURCE_NOTIFICATION = Notification.Name("AJPPackageResourceNotification")
Expand Down
Loading
Loading