diff --git a/airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md b/airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md index 8b498a70..0c599442 100644 --- a/airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md +++ b/airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md @@ -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: "" } +} +``` + ### package_update_result Fired on completion of package download. diff --git a/airborne_docs/docs/react-native-sdk/reference/ios-api.md b/airborne_docs/docs/react-native-sdk/reference/ios-api.md index 901e6314..96be2ba2 100644 --- a/airborne_docs/docs/react-native-sdk/reference/ios-api.md +++ b/airborne_docs/docs/react-native-sdk/reference/ios-api.md @@ -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. diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift index 7eba8ecf..086ca9cd 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift @@ -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? { @@ -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() @@ -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 { @@ -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 @@ -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() @@ -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? { @@ -855,6 +929,7 @@ public typealias AJPReleaseConfigCompletionHandler = (AJPApplicationManifest?, E self.downloadedApplicationManifest = manifest self.releaseConfigDownloadStatus = .completed self.cleanUpUnwantedFiles() + self.updateUnresolvedProperties(manifest?.unresolvedProperties) if let config = manifest?.config { self.updateConfig(config) } @@ -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 { @@ -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 } @@ -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" diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift index fb89ffde..91f185cf 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPFileUtil.swift @@ -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 diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationConstants.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationConstants.swift index 829f86ef..44e6c2cf 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationConstants.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationConstants.swift @@ -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") diff --git a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationManifest.swift b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationManifest.swift index 39873279..5ab4a2a6 100644 --- a/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationManifest.swift +++ b/airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftModel/AJPApplicationManifest.swift @@ -23,6 +23,14 @@ import Foundation /// The remote resource map keyed by filePath. public var resources: AJPApplicationResources + /// The unresolved Superposition bundle, present only when the release config was served + /// with `extended=true`. A top-level sibling of `config`/`package`/`resources`, not a wrapper. + /// + /// Backend-controlled and arbitrarily nested, so it is carried opaquely and never modelled: + /// the SDK only stores and forwards it. `nil` for responses that predate the flag, in which + /// case it is omitted from `toDictionary()` rather than emitted as null. + public var unresolvedProperties: NSDictionary? + // MARK: - Initialization /// Restores `NSObject.init()` for ObjC callers; creates an empty manifest. @@ -35,12 +43,22 @@ import Foundation /// Creates a manifest by composing already-parsed model objects. /// Used internally by `AJPApplicationManager` to snapshot the current state. + public convenience init(package: AJPApplicationPackage, + config: AJPApplicationConfig, + resources: AJPApplicationResources) { + self.init(package: package, config: config, resources: resources, unresolvedProperties: nil) + } + + /// Creates a manifest by composing already-parsed model objects, carrying the opaque + /// unresolved properties alongside them. public init(package: AJPApplicationPackage, config: AJPApplicationConfig, - resources: AJPApplicationResources) { + resources: AJPApplicationResources, + unresolvedProperties: NSDictionary?) { self.package = package self.config = config self.resources = resources + self.unresolvedProperties = unresolvedProperties super.init() } @@ -69,6 +87,9 @@ import Foundation self.resources = AJPApplicationResources() } + // Kept verbatim: the inner structure is backend-owned, so it is neither parsed nor validated. + self.unresolvedProperties = dict["unresolved_properties"] as? NSDictionary + super.init() } @@ -76,11 +97,16 @@ import Foundation /// Serializes the manifest back to a dictionary, mirroring the server JSON shape. public func toDictionary() -> NSDictionary { - return [ + var dict: [String: Any] = [ "config": config.toDictionary(), "package": package.toDictionary(), "resources": resources.toDictionary() ] + // Omitted entirely when absent — never serialized as null. + if let unresolvedProperties = unresolvedProperties { + dict["unresolved_properties"] = unresolvedProperties + } + return dict as NSDictionary } // MARK: - NSSecureCoding @@ -91,6 +117,10 @@ import Foundation self.config = coder.decodeObject(of: AJPApplicationConfig.self, forKey: "config") ?? AJPApplicationConfig() self.package = coder.decodeObject(of: AJPApplicationPackage.self, forKey: "package") ?? AJPApplicationPackage() self.resources = coder.decodeObject(of: AJPApplicationResources.self, forKey: "resources") ?? AJPApplicationResources() + // NSNull is allowed alongside the usual JSON classes: the payload is backend-controlled, + // and a single null anywhere inside it would otherwise fail the whole manifest decode. + let unresolvedClasses: [AnyClass] = [NSDictionary.self, NSArray.self, NSString.self, NSNumber.self, NSNull.self] + self.unresolvedProperties = coder.decodeObject(of: unresolvedClasses, forKey: "unresolved_properties") as? NSDictionary super.init() } @@ -98,5 +128,6 @@ import Foundation coder.encode(config, forKey: "config") coder.encode(package, forKey: "package") coder.encode(resources, forKey: "resources") + coder.encode(unresolvedProperties, forKey: "unresolved_properties") } } diff --git a/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigExtendedTests.swift b/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigExtendedTests.swift new file mode 100644 index 00000000..83339be8 --- /dev/null +++ b/airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigExtendedTests.swift @@ -0,0 +1,293 @@ +// +// AJPReleaseConfigExtendedTests.swift +// AirborneTestAppTests +// +// Covers the extended release config: the `extended=true` query parameter, and the opaque +// top-level `unresolved_properties` key it adds to the response. +// + +import XCTest +@testable import Airborne + +final class AJPReleaseConfigExtendedTests: XCTestCase { + + // MARK: - Helpers + + /// The `unresolved_properties` payload, deliberately containing every JSON shape the SDK + /// must carry without understanding: nested objects, arrays, numbers, bools and null. + private func makeUnresolvedProperties() -> [String: Any] { + return [ + "config": [ + "contexts": [ + ["id": "ctx-1", "condition": ["==": [["var": "os"], "ios"]], "priority": 10], + ["id": "ctx-2", "condition": ["in": [["var": "city"], ["blr", "del"]]], "priority": 20] + ], + "default_configs": ["package.version": "1", "enabled": true, "ratio": 0.25], + "dimensions": [ + "os": ["schema": ["type": "string", "enum": ["ios", "android"]], "position": 1], + "city": ["schema": ["type": "string"], "position": 2, "dependency": NSNull()] + ], + "overrides": ["ctx-1": ["package.version": "2"]] + ], + "config_version": "7488203155491131392", + "config_last_modified": "2026-07-29T12:05:56.359334Z", + "experiments": [], + "experiment_groups": [], + "experiments_last_modified": "2026-07-29T12:05:56.359334Z" + ] + } + + private func makeReleaseConfigJSON(includeUnresolved: Bool) -> Data { + var json: [String: Any] = [ + "version": "3", + "config": [ + "version": "cfg-1", + "boot_timeout": 3000, + "release_config_timeout": 2000, + "properties": ["env": "prod"] + ], + "package": [ + "name": "my-app", + "version": "2.0.0", + "index": ["url": "https://cdn.example.com/index.js", "file_path": "main/index.js"], + "important": [ + ["url": "https://cdn.example.com/vendor.js", "file_path": "main/vendor.js"] + ], + "lazy": [] + ], + "resources": [] + ] + if includeUnresolved { + json["unresolved_properties"] = makeUnresolvedProperties() + } + return try! JSONSerialization.data(withJSONObject: json) + } + + // MARK: - unresolved_properties: decode -> encode round trip + + func testUnresolvedPropertiesRoundTripsAsTopLevelKey() throws { + let manifest = try AJPApplicationManifest(data: makeReleaseConfigJSON(includeUnresolved: true) as NSData) + + let dict = manifest.toDictionary() + + // Top-level sibling of the existing keys, not a wrapper around them. + XCTAssertNotNil(dict["config"]) + XCTAssertNotNil(dict["package"]) + XCTAssertNotNil(dict["resources"]) + XCTAssertNotNil(dict["unresolved_properties"]) + + // Existing parsing is untouched by the new key. + XCTAssertEqual((dict["config"] as? NSDictionary)?["version"] as? String, "cfg-1") + XCTAssertEqual((dict["package"] as? NSDictionary)?["name"] as? String, "my-app") + + // The nested structure survives byte-for-byte in value terms. + XCTAssertEqual(dict["unresolved_properties"] as? NSDictionary, + makeUnresolvedProperties() as NSDictionary) + } + + func testUnresolvedPropertiesSurvivesFullJSONRoundTrip() throws { + let original = makeReleaseConfigJSON(includeUnresolved: true) + let manifest = try AJPApplicationManifest(data: original as NSData) + + // Re-serialise the way AirborneServices.getReleaseConfig() does. + let reEncoded = try JSONSerialization.data(withJSONObject: manifest.toDictionary()) + let reParsed = try XCTUnwrap(JSONSerialization.jsonObject(with: reEncoded) as? NSDictionary) + let originalParsed = try XCTUnwrap(JSONSerialization.jsonObject(with: original) as? NSDictionary) + + XCTAssertEqual(reParsed["unresolved_properties"] as? NSDictionary, + originalParsed["unresolved_properties"] as? NSDictionary) + + // Deep spot checks, so a failure points at the level that broke. + let unresolved = try XCTUnwrap(reParsed["unresolved_properties"] as? NSDictionary) + XCTAssertEqual(unresolved["config_version"] as? String, "7488203155491131392") + let config = try XCTUnwrap(unresolved["config"] as? NSDictionary) + let contexts = try XCTUnwrap(config["contexts"] as? NSArray) + XCTAssertEqual(contexts.count, 2) + XCTAssertEqual((contexts[0] as? NSDictionary)?["id"] as? String, "ctx-1") + let dimensions = try XCTUnwrap(config["dimensions"] as? NSDictionary) + XCTAssertTrue((dimensions["city"] as? NSDictionary)?["dependency"] is NSNull) + } + + // MARK: - unresolved_properties: absent stays absent + + func testResponseWithoutUnresolvedPropertiesDecodes() throws { + let manifest = try AJPApplicationManifest(data: makeReleaseConfigJSON(includeUnresolved: false) as NSData) + + XCTAssertNil(manifest.unresolvedProperties) + XCTAssertEqual(manifest.config.version, "cfg-1") + XCTAssertEqual(manifest.package.name, "my-app") + } + + func testAbsentUnresolvedPropertiesIsOmittedNotNull() throws { + let manifest = try AJPApplicationManifest(data: makeReleaseConfigJSON(includeUnresolved: false) as NSData) + + let dict = manifest.toDictionary() + XCTAssertFalse(dict.allKeys.contains { ($0 as? String) == "unresolved_properties" }) + + // And it must not reappear as an explicit null once serialised. + let encoded = try JSONSerialization.data(withJSONObject: dict) + let json = try XCTUnwrap(String(data: encoded, encoding: .utf8)) + XCTAssertFalse(json.contains("unresolved_properties")) + } + + // MARK: - unresolved_properties: NSSecureCoding (the temp-manifest cache path) + + func testUnresolvedPropertiesSurvivesSecureCodingRoundTrip() throws { + let manifest = try AJPApplicationManifest(data: makeReleaseConfigJSON(includeUnresolved: true) as NSData) + + let data = try NSKeyedArchiver.archivedData(withRootObject: manifest, requiringSecureCoding: true) + let decoded = try XCTUnwrap(NSKeyedUnarchiver.unarchivedObject(ofClass: AJPApplicationManifest.self, from: data)) + + XCTAssertEqual(decoded.unresolvedProperties, makeUnresolvedProperties() as NSDictionary) + XCTAssertEqual(decoded.config.version, "cfg-1") + XCTAssertEqual(decoded.package.name, "my-app") + } + + func testAbsentUnresolvedPropertiesSurvivesSecureCodingAsNil() throws { + let manifest = try AJPApplicationManifest(data: makeReleaseConfigJSON(includeUnresolved: false) as NSData) + + let data = try NSKeyedArchiver.archivedData(withRootObject: manifest, requiringSecureCoding: true) + let decoded = try XCTUnwrap(NSKeyedUnarchiver.unarchivedObject(ofClass: AJPApplicationManifest.self, from: data)) + + XCTAssertNil(decoded.unresolvedProperties) + } + + func testComposedInitWithoutUnresolvedPropertiesStillWorks() throws { + let config = try AJPApplicationConfig(dictionary: ["version": "1.0"]) + let package = try AJPApplicationPackage(dictionary: ["name": "app", "version": "1.0"]) + let resources = try AJPApplicationResources(resourcesArray: NSArray()) + + let manifest = AJPApplicationManifest(package: package, config: config, resources: resources) + + XCTAssertNil(manifest.unresolvedProperties) + XCTAssertFalse(manifest.toDictionary().allKeys.contains { ($0 as? String) == "unresolved_properties" }) + } + + // MARK: - The cached .dat component + + private static let jsonClasses: [AnyClass] = [NSDictionary.self, NSArray.self, NSString.self, NSNumber.self, NSNull.self] + + /// Drives the real file IO the SDK uses for `app-config.dat` and friends, with the payload + /// stored as itself rather than wrapped in a model type. + func testUnresolvedPropertiesRoundTripsThroughFileUtil() throws { + let fileUtil = AJPFileUtil(workspace: "test_unresolved_workspace", baseBundle: Bundle.main) + let fileName = AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME + let folder = AJPApplicationConstants.JUSPAY_MANIFEST_DIR + defer { try? fileUtil.deleteFile(fileName, inFolder: folder) } + + try fileUtil.writeInstance(makeUnresolvedProperties() as NSDictionary, fileName: fileName, inFolder: folder) + + let readBack = try XCTUnwrap( + fileUtil.getDecodedInstanceForClasses(Self.jsonClasses, withContentOfFileName: fileName, inFolder: folder) as? NSDictionary + ) + + XCTAssertEqual(readBack, makeUnresolvedProperties() as NSDictionary) + + // Nested shapes specifically, since these are what a narrow allowlist would reject. + let config = try XCTUnwrap(readBack["config"] as? NSDictionary) + XCTAssertEqual((config["contexts"] as? NSArray)?.count, 2) + XCTAssertTrue((config["dimensions"] as? NSDictionary) + .flatMap { $0["city"] as? NSDictionary }?["dependency"] is NSNull) + XCTAssertEqual((config["default_configs"] as? NSDictionary)?["ratio"] as? NSNumber, NSNumber(value: 0.25)) + } + + /// Documents why `getDecodedInstanceForClasses` exists: secure decoding validates a + /// collection's elements too, so the single-class `getDecodedInstanceForClass` cannot read + /// this payload back. Guards against "simplifying" the read path to the single-class variant. + func testSingleClassDecodeCannotReadTheNestedPayload() throws { + let fileUtil = AJPFileUtil(workspace: "test_unresolved_workspace", baseBundle: Bundle.main) + let fileName = AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME + let folder = AJPApplicationConstants.JUSPAY_MANIFEST_DIR + defer { try? fileUtil.deleteFile(fileName, inFolder: folder) } + + try fileUtil.writeInstance(makeUnresolvedProperties() as NSDictionary, fileName: fileName, inFolder: folder) + + XCTAssertThrowsError( + try fileUtil.getDecodedInstanceForClass(NSDictionary.self, withContentOfFileName: fileName, inFolder: folder) + ) + } + + func testCachedFileNameFollowsTheDatConvention() { + XCTAssertEqual(AJPApplicationConstants.APP_UNRESOLVED_PROPERTIES_DATA_FILE_NAME, "app-unresolved-properties.dat") + } + + // MARK: - extended=true query parameter + + private func queryItems(_ url: URL) -> [URLQueryItem] { + return URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + } + + func testExtendedParamIsAddedToURLWithoutQuery() { + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: "https://example.com/release/org/app")!) + + XCTAssertEqual(queryItems(url), [URLQueryItem(name: "extended", value: "true")]) + XCTAssertEqual(url.absoluteString, "https://example.com/release/org/app?extended=true") + } + + func testExtendedParamIsAppendedToAnExistingQueryString() { + // The HyperSDK-shaped URL, which always ends in ?toss=. Concatenation would produce a + // malformed "?toss=42?extended=true". + let configured = "https://beta.assets.juspay.in/hyper/bundles/in.juspay.merchants/app/android/1.0/release-config-v2.json?toss=42" + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: configured)!) + + XCTAssertEqual(queryItems(url), [ + URLQueryItem(name: "toss", value: "42"), + URLQueryItem(name: "extended", value: "true") + ]) + XCTAssertFalse(url.absoluteString.contains("?toss=42?")) + XCTAssertEqual(url.absoluteString.components(separatedBy: "?").count, 2) + } + + func testExistingExtendedParamIsOverwrittenNotDuplicated() { + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: "https://example.com/rc?extended=false")!) + + // The flag cannot be turned off from wherever the URL is configured. + XCTAssertEqual(queryItems(url), [URLQueryItem(name: "extended", value: "true")]) + } + + func testRepeatedExtendedParamsCollapseToASingleTrue() { + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: "https://example.com/rc?extended=false&toss=7&extended=maybe")!) + + XCTAssertEqual(queryItems(url), [ + URLQueryItem(name: "toss", value: "7"), + URLQueryItem(name: "extended", value: "true") + ]) + } + + func testBuildingIsIdempotent() { + let once = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: "https://example.com/rc?toss=1")!) + let twice = AJPApplicationManager.extendedReleaseConfigURL(from: once) + + XCTAssertEqual(once, twice) + } + + func testOtherURLComponentsArePreserved() { + let configured = "https://user@example.com:8443/a/b/release-config.json?toss=42&x=y#frag" + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: configured)!) + + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "example.com") + XCTAssertEqual(url.port, 8443) + XCTAssertEqual(url.user, "user") + XCTAssertEqual(url.path, "/a/b/release-config.json") + XCTAssertEqual(url.fragment, "frag") + XCTAssertEqual(queryItems(url), [ + URLQueryItem(name: "toss", value: "42"), + URLQueryItem(name: "x", value: "y"), + URLQueryItem(name: "extended", value: "true") + ]) + } + + func testEncodedQueryValuesAreNotCorrupted() { + let configured = "https://example.com/rc?dim=a%20b%26c&toss=9" + let url = AJPApplicationManager.extendedReleaseConfigURL(from: URL(string: configured)!) + + XCTAssertEqual(queryItems(url), [ + URLQueryItem(name: "dim", value: "a b&c"), + URLQueryItem(name: "toss", value: "9"), + URLQueryItem(name: "extended", value: "true") + ]) + // The literal ampersand inside the value must stay percent-encoded on the wire. + XCTAssertTrue(url.absoluteString.contains("dim=a%20b%26c")) + } +}