From dd61b30d236e664300efbc8b7ec9b29d3efb14da Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 26 Jun 2026 01:03:18 +0300 Subject: [PATCH 01/19] feat: enrich targeting api call --- Source/Core/EdgeAPI.swift | 27 +++++++++- Source/Misc/Bundle++.swift | 15 ++++++ Source/OptableSDK.swift | 16 +++--- .../Public/ObjCSupport/OptableSDK+ObjC.swift | 8 +-- Source/Public/OptableIdentifier.swift | 23 ++++++++ Tests/Integration/OptableSDKTests.swift | 2 +- Tests/Unit/EdgeAPITests.swift | 33 +++++++++--- Tests/Unit/OptableIdentifiersTests.swift | 53 +++++++++++++++++++ 8 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 Source/Misc/Bundle++.swift diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index f456e54..485e3bf 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -63,13 +63,36 @@ final class EdgeAPI { return request } - func targeting(ids: [OptableIdentifier]) throws -> URLRequest? { + func targeting(ids: [OptableIdentifier], hids: [OptableIdentifier]) throws -> URLRequest? { guard var url = buildEdgeAPIURL(endpoint: "targeting") else { return nil } - let queryItems = ids + var queryItems = ids .map({ $0.extendedIdentifier }) .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "id", value: $0) }) + + let hids = hids.hids + .compactMap({ $0.extendedIdentifier }) + .compactMap({ URLQueryItem(name: "hid", value: $0) }) + + queryItems.append(contentsOf: hids) + + if let bundle = Bundle.main.bundleIdentifier { + queryItems.append(URLQueryItem(name: "bundle", value: bundle)) + } + + if let ver = Bundle.main.appVersionString { + queryItems.append(URLQueryItem(name: "ver", value: ver)) + } + + if let userAgent { + queryItems.append(URLQueryItem(name: "ua", value: userAgent)) + } + + if let targeting = storage.getTargeting(), let id5Signature = targeting.targetingData["id5_signature"] as? String { + queryItems.append(URLQueryItem(name: "id5_signature", value: id5Signature)) + } + url.compatAppend(queryItems: queryItems) let request = try buildRequest(.GET, url: url, headers: resolveHeaders()) diff --git a/Source/Misc/Bundle++.swift b/Source/Misc/Bundle++.swift new file mode 100644 index 0000000..60d23f1 --- /dev/null +++ b/Source/Misc/Bundle++.swift @@ -0,0 +1,15 @@ +// +// Bundle++.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +import Foundation + +extension Bundle { + /// The app's release version (`CFBundleShortVersionString`), if present. + var appVersionString: String? { + infoDictionary?["CFBundleShortVersionString"] as? String + } +} diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index de1c91c..ff367e5 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -116,7 +116,7 @@ public extension OptableSDK { // MARK: - Targeting public extension OptableSDK { /** - targeting(ids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data + targeting(ids?, hids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data for the current user/device/app. You may optionally supply identifiers to enrich the request. On completion, the handler receives: @@ -126,8 +126,8 @@ public extension OptableSDK { On success, the result is cached in client storage. You can read it using targetingFromCache() and clear it using targetingClearCache(). */ - func targeting(_ ids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { - try _targeting(ids: ids, completion: completion) + func targeting(_ ids: [OptableIdentifier]? = nil, _ hids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { + try _targeting(ids: ids, hids: hids, completion: completion) } /// targetingFromCache() returns the previously cached targeting data, if any. @@ -149,10 +149,10 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) - func targeting(_ ids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { + func targeting(_ ids: [OptableIdentifier]? = nil, _ hids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { - try self._targeting(ids: ids, completion: { continuation.resume(with: $0) }) + try self._targeting(ids: ids, hids: hids, completion: { continuation.resume(with: $0) }) } catch { continuation.resume(throwing: error) } @@ -313,12 +313,14 @@ extension OptableSDK { }).resume() } - func _targeting(ids: [OptableIdentifier]?, completion: @escaping (Result) -> Void) throws { + func _targeting(ids: [OptableIdentifier]?, hids: [OptableIdentifier]?, completion: @escaping (Result) -> Void) throws { var ids = ids ?? [] + var hids = hids ?? [] enrichIfNeeded(ids: &ids) + enrichIfNeeded(ids: &hids) - guard let request = try api.targeting(ids: ids) else { + guard let request = try api.targeting(ids: ids, hids: hids) else { throw OptableError.targeting("Failed to create targeting request") } diff --git a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift index 5665f35..710f282 100644 --- a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift +++ b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift @@ -30,14 +30,16 @@ public extension OptableSDK { } /** - This is the Objective-C compatible version of the `targeting(completion)` API. + This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API. Instead of completion callbacks, delegate methods are called. */ @objc - func targeting(_ ids: [OptableSDKIdentifier]) throws { + func targeting(_ ids: [OptableSDKIdentifier], _ hids: [OptableSDKIdentifier]) throws { let bridgedIds = ids.compactMap({ OptableIdentifier(objc: $0) }) - try self._targeting(ids: bridgedIds, completion: { result in + let bridgedHIds = hids.compactMap({ OptableIdentifier(objc: $0) }) + + try self._targeting(ids: bridgedIds, hids: bridgedHIds, completion: { result in switch result { case let .success(optableTargeting): self.delegate?.targetingOk(optableTargeting) diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 4bc5e51..f42d7b1 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -90,6 +90,13 @@ extension OptableIdentifier: Encodable { } } +// MARK: - Equatable +extension OptableIdentifier: Equatable { + public static func == (lhs: OptableIdentifier, rhs: OptableIdentifier) -> Bool { + lhs.extendedIdentifier == rhs.extendedIdentifier + } +} + // MARK: - Init with ExtendedIdentifier public extension OptableIdentifier { /// Hash-based types (`e`, `p`) resolve to their hashed cases so re-encoding does not hash twice. @@ -133,3 +140,19 @@ public extension OptableIdentifier { } } } + +// MARK: - HIDs +extension Array where Element == OptableIdentifier { + /// https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app + var hids: [OptableIdentifier] { + filter { + switch $0 { + case .ipv6Address(_), .emailAddress(_), .phoneNumber(_), + .appleIDFA(_), .googleGAID(_), .custom(_, _): + return true + default: + return false + } + } + } +} diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index ea6c494..62cd5fb 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -73,7 +73,7 @@ class OptableSDKTests: XCTestCase { } func test_target_delegate() throws { - try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)]) + try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)], [OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)]) wait(for: [targetExpectation], timeout: 10) } diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 602ec0a..986684e 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -173,18 +173,39 @@ class EdgeAPITests: XCTestCase { For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/targeting) */ func test_targeting_request_generation() throws { - let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345"), .phoneNumber("54321")]) - + // `id5_signature` is a resolver-specific parameter sourced from the stored targeting data. + sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["id5_signature": "id5-sig-abc123"])) + + let email: OptableIdentifier = .emailAddress("12345") + let phone: OptableIdentifier = .phoneNumber("54321") + let urlRequest = try sdk.api.targeting(ids: [email, phone], hids: [email, phone]) + // Method XCTAssertEqual(urlRequest?.httpMethod, HTTPMethod.GET.rawValue) // Path let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)! XCTAssert(urlComponents.path.contains("targeting")) - - // Query - XCTAssert(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == "e:12345" }) != nil) - XCTAssert(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == "p:54321" }) != nil) + + // Query: every identifier is emitted as an `id` param (email/phone are SHA-256 hashed) + XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == email.extendedIdentifier }) ?? false) + XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == phone.extendedIdentifier }) ?? false) + + // HIDs: email and phone are part of the HID set, so they are also emitted as `hid` params + XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == email.extendedIdentifier }) ?? false) + XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == phone.extendedIdentifier }) ?? false) + + // Resolver-specific parameters + XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "ua" })?.value, T.api.userAgent) + XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })?.value, "id5-sig-abc123") + + if let bundle = Bundle.main.bundleIdentifier { + XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "bundle" })?.value, bundle) + } + + if let ver = Bundle.main.appVersionString { + XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "ver" })?.value, ver) + } } /** diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index a839baf..04784bd 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -115,4 +115,57 @@ class OptableIdentifiersTests: XCTestCase { // Types that are never hashed are unaffected. XCTAssertEqual("c9:custom-9-id", try eid("c9:custom-9-id")) } + + // MARK: - hids + func test_hids_keepsOnlyHIDCases() throws { + let hidOnly: [OptableIdentifier] = [ + .emailAddress("foo@bar.com"), + .phoneNumber("+15123465890"), + .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), + .googleGAID("64873d9f-d5af-4770-8bcb-167a220eb17d"), + .custom(nil, "d29c551097b9dd0b82423827f65161232efaf7fc"), + .custom(1, "AaaZza.dh012"), + ] + + // Every element is a HID case, so contents and order are preserved. + XCTAssertEqual(hidOnly.hids.map(\.extendedIdentifier), hidOnly.map(\.extendedIdentifier)) + } + + func test_hids_dropsNonHIDCases() throws { + let nonHIDs: [OptableIdentifier] = [ + .postalCode("M5V 3L9"), + .ipv4Address("8.8.8.8"), + .rokuRIDA("0b179df0-6cd5-49f1-be21-425d002e0d22"), + .samsungTIFA("e0ef86a8-6ebf-4c9d-9127-e69407fe748d"), + .amazonFireAFAI("6e853799-ef31-4a30-8706-9742be254d38"), + .netID("_YV2v2Uhx3vqeH47Rrhzgr-4c3VNsxis4M1WY9qn--QTbVapax5VM2HJykoGAyWcwS5lKQ"), + .id5("ID5*UDWnp3JOtWV0ky-bHvEeU4xOVHXCmYeg24YigF8iAymUHplfYSElM3fy79h8p-Fg"), + .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), + .optableVID("v-value"), + ] + + XCTAssertTrue(nonHIDs.hids.isEmpty) + } + + func test_hids_filtersMixedArrayPreservingOrder() throws { + let mixed: [OptableIdentifier] = [ + .postalCode("M5V 3L9"), + .emailAddress("foo@bar.com"), + .ipv4Address("8.8.8.8"), + .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), + .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), + .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + .custom(2, "ppid"), + ] + + let expected: [OptableIdentifier] = [ + .emailAddress("foo@bar.com"), + .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), + .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + .custom(2, "ppid"), + ] + + XCTAssertEqual(mixed.hids.map(\.extendedIdentifier), expected.map(\.extendedIdentifier)) + } } From 08569add774b6c91bfd14f22a402d70da7f0da41 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Fri, 26 Jun 2026 14:49:30 +0300 Subject: [PATCH 02/19] fix: objc demo app --- demo-ios-objc/demo-ios-objc/GAMBannerViewController.m | 1 + demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m | 1 + 2 files changed, 2 insertions(+) diff --git a/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m b/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m index dbdc66f..c0456d3 100644 --- a/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m +++ b/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m @@ -61,6 +61,7 @@ - (IBAction)loadBannerWithTargeting:(id)sender { [OPTABLE targeting: @[ [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] ] + :@[] error:&error]; [OPTABLE witnessWithEvent: @"GAMBannerViewController.loadBannerClicked" properties: @{ @"example": @"value" } diff --git a/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m b/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m index 73842ce..ca4ead3 100644 --- a/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m +++ b/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m @@ -70,6 +70,7 @@ - (IBAction)loadBannerWithTargeting:(id)sender { [OPTABLE targeting: @[ [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] ] + :@[] error:&error]; [OPTABLE witnessWithEvent: @"PrebidBannerViewController.loadBannerClicked" properties: @{ @"example": @"value" } From b5417b23031b0190e3d4304f700c4be7108e7815 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 11:42:29 +0300 Subject: [PATCH 03/19] fix: add separately-named targeting overload --- Source/Public/ObjCSupport/OptableSDK+ObjC.swift | 16 +++++++++++++--- .../demo-ios-objc/GAMBannerViewController.m | 1 - .../demo-ios-objc/PrebidBannerViewController.m | 1 - 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift index 710f282..41fd036 100644 --- a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift +++ b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift @@ -30,15 +30,25 @@ public extension OptableSDK { } /** - This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API. + This is the Objective-C compatible version of the `targeting(ids, completion)` API. Instead of completion callbacks, delegate methods are called. */ @objc - func targeting(_ ids: [OptableSDKIdentifier], _ hids: [OptableSDKIdentifier]) throws { + func targeting(_ ids: [OptableSDKIdentifier]) throws { + try targeting(ids, hids: []) + } + + /** + This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API. + + Instead of completion callbacks, delegate methods are called. + */ + @objc(targetingWithIds:hids:error:) + func targeting(_ ids: [OptableSDKIdentifier], hids: [OptableSDKIdentifier]) throws { let bridgedIds = ids.compactMap({ OptableIdentifier(objc: $0) }) let bridgedHIds = hids.compactMap({ OptableIdentifier(objc: $0) }) - + try self._targeting(ids: bridgedIds, hids: bridgedHIds, completion: { result in switch result { case let .success(optableTargeting): diff --git a/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m b/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m index c0456d3..dbdc66f 100644 --- a/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m +++ b/demo-ios-objc/demo-ios-objc/GAMBannerViewController.m @@ -61,7 +61,6 @@ - (IBAction)loadBannerWithTargeting:(id)sender { [OPTABLE targeting: @[ [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] ] - :@[] error:&error]; [OPTABLE witnessWithEvent: @"GAMBannerViewController.loadBannerClicked" properties: @{ @"example": @"value" } diff --git a/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m b/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m index ca4ead3..73842ce 100644 --- a/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m +++ b/demo-ios-objc/demo-ios-objc/PrebidBannerViewController.m @@ -70,7 +70,6 @@ - (IBAction)loadBannerWithTargeting:(id)sender { [OPTABLE targeting: @[ [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] ] - :@[] error:&error]; [OPTABLE witnessWithEvent: @"PrebidBannerViewController.loadBannerClicked" properties: @{ @"example": @"value" } From 64f6d330d6ef8f1da460a71a3ce39c906a7bd5e3 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:19:23 +0300 Subject: [PATCH 04/19] fix: cleanup storage in tests --- Tests/Integration/OptableSDKTests.swift | 2 +- Tests/Unit/EdgeAPITests.swift | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/Integration/OptableSDKTests.swift b/Tests/Integration/OptableSDKTests.swift index 62cd5fb..6dcb627 100644 --- a/Tests/Integration/OptableSDKTests.swift +++ b/Tests/Integration/OptableSDKTests.swift @@ -73,7 +73,7 @@ class OptableSDKTests: XCTestCase { } func test_target_delegate() throws { - try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)], [OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)]) + try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)], hids: [OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)]) wait(for: [targetExpectation], timeout: 10) } diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 986684e..70d31ff 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -17,6 +17,11 @@ class EdgeAPITests: XCTestCase { ) lazy var sdk = OptableSDK(config: config) + override func tearDown() { + sdk.api.storage.clearTargeting() + super.tearDown() + } + // MARK: URL-s /** Expected output: From 4cb7c4bacb8b8030836e67fe5463374703c304af Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:21:18 +0300 Subject: [PATCH 05/19] fix: add note regarding custom case ids --- Source/OptableSDK.swift | 4 ++++ Source/Public/OptableIdentifier.swift | 3 +++ 2 files changed, 7 insertions(+) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index ff367e5..1828027 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -119,6 +119,10 @@ public extension OptableSDK { targeting(ids?, hids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data for the current user/device/app. You may optionally supply identifiers to enrich the request. + Identifiers passed as `hids` are forwarded as resolver-specific `hid` parameters (e.g. ID5 Mobile In-App). + Only email address, phone number, IPv6 address, Apple IDFA, Google GAID and custom identifiers are sent; + custom (`cN`) prefixes must be configured on the DCN — unconfigured ones are ignored server-side. + On completion, the handler receives: - .success(OptableTargeting) on success - .failure(Error) on failure diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index f42d7b1..6154939 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -144,6 +144,9 @@ public extension OptableIdentifier { // MARK: - HIDs extension Array where Element == OptableIdentifier { /// https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app + /// + /// Note: all `.custom` identifiers are passed through, but only the custom (`cN`) prefixes + /// configured on the DCN are valid resolver hints — unconfigured ones are ignored server-side. var hids: [OptableIdentifier] { filter { switch $0 { From e2b61fce63d7f3918009fd4594c29755771437c5 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:24:15 +0300 Subject: [PATCH 06/19] fix: add hids label to parameter declaration --- Source/OptableSDK.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 1828027..0060771 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -130,7 +130,7 @@ public extension OptableSDK { On success, the result is cached in client storage. You can read it using targetingFromCache() and clear it using targetingClearCache(). */ - func targeting(_ ids: [OptableIdentifier]? = nil, _ hids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { + func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil, completion: @escaping (Result) -> Void) throws { try _targeting(ids: ids, hids: hids, completion: completion) } @@ -153,7 +153,7 @@ public extension OptableSDK { Instead of completion callbacks, results are returned via async/await. */ @available(iOS 13.0, *) - func targeting(_ ids: [OptableIdentifier]? = nil, _ hids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { + func targeting(_ ids: [OptableIdentifier]? = nil, hids: [OptableIdentifier]? = nil) async throws -> OptableTargeting { return try await withCheckedThrowingContinuation({ [unowned self] continuation in do { try self._targeting(ids: ids, hids: hids, completion: { continuation.resume(with: $0) }) From 466d928828bf2ac069bbfe1383e265c3f47a7ae7 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:32:55 +0300 Subject: [PATCH 07/19] fix: explain parameters in codedoc --- Source/OptableSDK.swift | 32 +++++++++++++------ .../Public/ObjCSupport/OptableSDK+ObjC.swift | 6 +++- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 0060771..70a64a1 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -117,15 +117,24 @@ public extension OptableSDK { public extension OptableSDK { /** targeting(ids?, hids?, completion) calls the Optable Sandbox Targeting API and returns key-value targeting data - for the current user/device/app. You may optionally supply identifiers to enrich the request. - - Identifiers passed as `hids` are forwarded as resolver-specific `hid` parameters (e.g. ID5 Mobile In-App). - Only email address, phone number, IPv6 address, Apple IDFA, Google GAID and custom identifiers are sent; - custom (`cN`) prefixes must be configured on the DCN — unconfigured ones are ignored server-side. - - On completion, the handler receives: - - .success(OptableTargeting) on success - - .failure(Error) on failure + for the current user/device/app. + + - Parameters: + - ids: one or more identifiers to resolve, sent as repeated `id` query parameters. The DCN evaluates them + in the order they are listed and returns the profile of the first successful match (querying the + first-party graph before any third-party graphs). When provided, they take precedence over any + identifier in the passport. + - hids: hint identifiers, sent as `hid` query parameters in addition to `ids`. Hints drive resolver-specific + identity resolution on the DCN, such as ID5 Mobile In-App. Only the identifier types valid as hints are + forwarded: email address, phone number, IPv6 address, Apple IDFA, Google GAID and custom IDs — any other + type in `hids` is dropped client-side. Custom (`cN`) prefixes must be configured on the DCN; unconfigured + ones are ignored server-side. + - completion: on completion, the handler receives: + - .success(OptableTargeting) on success + - .failure(Error) on failure + + Unless `skipAdvertisingIdDetection` is set in the config, the device IDFA is automatically prepended to both + lists when ad tracking is authorized. On success, the result is cached in client storage. You can read it using targetingFromCache() and clear it using targetingClearCache(). @@ -148,7 +157,10 @@ public extension OptableSDK { // MARK: Async/Await support /** - This is the Swift Concurrency compatible version of the `targeting(completion)` API. + This is the Swift Concurrency compatible version of the `targeting(ids, hids, completion)` API: + `ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific + identity resolution such as ID5 Mobile In-App — see `targeting(_:hids:completion:)` for details on which + identifier types are valid hints. Instead of completion callbacks, results are returned via async/await. */ diff --git a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift index 41fd036..93e779a 100644 --- a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift +++ b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift @@ -40,7 +40,11 @@ public extension OptableSDK { } /** - This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API. + This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API: + `ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific + identity resolution such as ID5 Mobile In-App. Only email address, phone number, IPv6 address, Apple IDFA, + Google GAID and custom identifiers are valid hints — any other type in `hids` is dropped client-side, and + custom (`cN`) prefixes not configured on the DCN are ignored server-side. Instead of completion callbacks, delegate methods are called. */ From 9f9a53dba9539961c1f17638ae315a9c2f7b3fb9 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:34:42 +0300 Subject: [PATCH 08/19] fix: remove unused Equatable conformance --- Source/Public/OptableIdentifier.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 6154939..dce1ed3 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -90,13 +90,6 @@ extension OptableIdentifier: Encodable { } } -// MARK: - Equatable -extension OptableIdentifier: Equatable { - public static func == (lhs: OptableIdentifier, rhs: OptableIdentifier) -> Bool { - lhs.extendedIdentifier == rhs.extendedIdentifier - } -} - // MARK: - Init with ExtendedIdentifier public extension OptableIdentifier { /// Hash-based types (`e`, `p`) resolve to their hashed cases so re-encoding does not hash twice. From ec399c7b32f52c212f53e7586fe5e7a247901c3b Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:36:43 +0300 Subject: [PATCH 09/19] fix: rename local variable --- Source/Core/EdgeAPI.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 485e3bf..140d180 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -71,11 +71,11 @@ final class EdgeAPI { .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "id", value: $0) }) - let hids = hids.hids + let hidQueryItems = hids.hids .compactMap({ $0.extendedIdentifier }) .compactMap({ URLQueryItem(name: "hid", value: $0) }) - - queryItems.append(contentsOf: hids) + + queryItems.append(contentsOf: hidQueryItems) if let bundle = Bundle.main.bundleIdentifier { queryItems.append(URLQueryItem(name: "bundle", value: bundle)) From 53aa5c99a0eff9c796a279cfdb198d375f63ed71 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Mon, 13 Jul 2026 13:43:35 +0300 Subject: [PATCH 10/19] fix: add usage docs --- docs/usage-objc.md | 19 ++++++++++++++++++- docs/usage-swift.md | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/usage-objc.md b/docs/usage-objc.md index 5ed211b..3a1d43a 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -117,10 +117,27 @@ To get the targeting key values associated by the configured DCN with the device @import OptableSDK; ... NSError *error = nil; -[OPTABLE targetingWithIds: @[@"c:1"] // NULL-able +[OPTABLE targeting: @[ + [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] +] + error: &error]; +``` + +You may optionally supply hint identifiers (`hids`) which are forwarded as resolver-specific `hid` parameters, used by integrations such as ID5 Mobile In-App: + +```objective-c +[OPTABLE targetingWithIds: @[ + [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_EmailAddress value:@"test@test.test"] +] + hids: @[ + [OptableSDKIdentifier identifierWithType:OptableSDKIdentifierType_PhoneNumber value:@"+1234567890"] +] error: &error]; ``` +> :information_source: For more details on `hid` parameters, including the supported identifier types, check: +> [Optable Real-Time API Integrations Guide > Resolver Specific Parameters > ID5 Mobile In-App](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app) + #### Caching Targeting Data The `targetingAndReturnError` method will automatically cache resulting key value data in client storage on success. You can subsequently retrieve the cached key value data as follows: diff --git a/docs/usage-swift.md b/docs/usage-swift.md index 715e10f..d93364a 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -138,6 +138,24 @@ do { On success, the resulting key values are typically sent as part of a subsequent ad call. Therefore we recommend that you either call `targeting()` before each ad call, or in parallel periodically, caching the resulting key values which you then provide in ad calls. +You may optionally supply identifiers to enrich the targeting request: `ids` are used to match the user, while `hids` are hint identifiers forwarded as resolver-specific `hid` parameters, used by integrations such as ID5 Mobile In-App: + +```swift +let ids: [OptableIdentifier] = [ + .emailAddress("test@test.test") +] +let hids: [OptableIdentifier] = [ + .phoneNumber("+1234567890") +] + +try OPTABLE!.targeting(ids, hids: hids) { result in + // ... +} +``` + +> :information_source: For more details on `hid` parameters, including the supported identifier types, check: +> [Optable Real-Time API Integrations Guide > Resolver Specific Parameters > ID5 Mobile In-App](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app) + #### Caching Targeting Data The `targeting` API will automatically cache resulting key value data in client storage on success. You can subsequently retrieve the cached key value data as follows: From d216b2ff351929c4b3d75597cb3481cdb9a91ff8 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 16 Jul 2026 12:41:31 +0300 Subject: [PATCH 11/19] feat: append cached id5 signature to request --- Source/Core/EdgeAPI.swift | 2 +- Source/Public/OptableTargeting.swift | 40 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 140d180..36af678 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -89,7 +89,7 @@ final class EdgeAPI { queryItems.append(URLQueryItem(name: "ua", value: userAgent)) } - if let targeting = storage.getTargeting(), let id5Signature = targeting.targetingData["id5_signature"] as? String { + if let targeting = storage.getTargeting(), let id5Signature = targeting.id5Signature { queryItems.append(URLQueryItem(name: "id5_signature", value: id5Signature)) } diff --git a/Source/Public/OptableTargeting.swift b/Source/Public/OptableTargeting.swift index 5710fff..f342c6c 100644 --- a/Source/Public/OptableTargeting.swift +++ b/Source/Public/OptableTargeting.swift @@ -32,3 +32,43 @@ public class OptableTargeting: NSObject { return desc } } + +// MARK: - Helpers + +extension OptableTargeting { + + var id5Signature: String? { + guard let ortb2 = targetingData["ortb2"] as? [String: Any], + let user = ortb2["user"] as? [String: Any], + let eids = user["eids"] as? [[String: Any]] else { + return nil + } + + guard let rootRefs = targetingData["refs"] as? [String: Any] else { return nil } + + for eid in eids { + guard let source = eid["source"] as? String, source.range(of: "id5", options: [.caseInsensitive]) != nil else { + continue + } + + guard let uids = eid["uids"] as? [[String: Any]] else { continue } + + for uid in uids { + guard let ext = uid["ext"] as? [String: Any], + let optable = ext["optable"] as? [String: Any], + let uidRef = optable["ref"] as? String else { + continue + } + + guard let ref = rootRefs[uidRef] as? [String: Any] else { continue } + + if let id5Signature = ref["signature"] as? String, + id5Signature.trimmingCharacters(in: .whitespaces).isEmpty == false { + return id5Signature + } + } + } + + return nil + } +} From 421dfc4f45c3719c41ff51357fb49a8c2a6fe520 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 16 Jul 2026 13:11:48 +0300 Subject: [PATCH 12/19] feat: store id5 signature separately --- Source/Core/EdgeAPI.swift | 2 +- Source/Core/LocalStorage.swift | 11 +++++++++++ Source/OptableSDK.swift | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 36af678..37247c6 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -89,7 +89,7 @@ final class EdgeAPI { queryItems.append(URLQueryItem(name: "ua", value: userAgent)) } - if let targeting = storage.getTargeting(), let id5Signature = targeting.id5Signature { + if let id5Signature = storage.getID5Signature() { queryItems.append(URLQueryItem(name: "id5_signature", value: id5Signature)) } diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 84ff17f..0fe1df0 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -15,6 +15,7 @@ final class LocalStorage: NSObject { private let targetingDataKey: String private let gamTargetingKeywordsKey: String private let ortb2Key: String + private let id5SignatureKey: String let keyPfx: String = "OPTABLE" var passportKey: String @@ -33,6 +34,7 @@ final class LocalStorage: NSObject { self.targetingDataKey = targetingKey + "_targetingData" self.gamTargetingKeywordsKey = targetingKey + "_gamTargetingKeywords" self.ortb2Key = targetingKey + "_ortb2" + self.id5SignatureKey = targetingKey + "_id5Signature" } func getPassport() -> String? { @@ -68,5 +70,14 @@ final class LocalStorage: NSObject { UserDefaults.standard.removeObject(forKey: targetingDataKey) UserDefaults.standard.removeObject(forKey: gamTargetingKeywordsKey) UserDefaults.standard.removeObject(forKey: ortb2Key) + UserDefaults.standard.removeObject(forKey: id5SignatureKey) + } + + func getID5Signature() -> String? { + return UserDefaults.standard.string(forKey: id5SignatureKey) + } + + func setID5Signature(_ signature: String) { + UserDefaults.standard.set(signature, forKey: id5SignatureKey) } } diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 70a64a1..6e2e45f 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -360,6 +360,10 @@ extension OptableSDK { /// We cache the latest targeting result in client storage for targetingFromCache() users: self.api.storage.setTargeting(optableTargeting) + + if let id5Signature = optableTargeting.id5Signature { + self.api.storage.setID5Signature(id5Signature) + } completion(.success(optableTargeting)) } catch { From 6488eba513fa972d10acbf0260ef5603746aa369 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 16 Jul 2026 13:13:45 +0300 Subject: [PATCH 13/19] tests: tests for id5Signature --- OptableSDK.xcodeproj/project.pbxproj | 1 + Tests/Unit/EdgeAPITests.swift | 26 +++++++- Tests/Unit/LocalStorageTests.swift | 8 +++ Tests/Unit/OptableTargetingTests.swift | 86 ++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 Tests/Unit/OptableTargetingTests.swift diff --git a/OptableSDK.xcodeproj/project.pbxproj b/OptableSDK.xcodeproj/project.pbxproj index a05613f..fab643e 100644 --- a/OptableSDK.xcodeproj/project.pbxproj +++ b/OptableSDK.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ Unit/OptableIdentifiersTests.swift, Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift, Unit/OptableSDKHelpersTests.swift, + Unit/OptableTargetingTests.swift, ); target = 6352AB0324EAD403002E66EB /* OptableSDKTests */; }; diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 70d31ff..8188277 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -178,8 +178,10 @@ class EdgeAPITests: XCTestCase { For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/targeting) */ func test_targeting_request_generation() throws { - // `id5_signature` is a resolver-specific parameter sourced from the stored targeting data. - sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["id5_signature": "id5-sig-abc123"])) + // `id5_signature` is a resolver-specific parameter cached in storage from the latest targeting response, + // and only sent alongside a cached targeting result: + sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]])) + sdk.api.storage.setID5Signature("id5-sig-abc123") let email: OptableIdentifier = .emailAddress("12345") let phone: OptableIdentifier = .phoneNumber("54321") @@ -213,6 +215,26 @@ class EdgeAPITests: XCTestCase { } } + func test_targeting_request_omits_id5_signature_when_no_stored_targeting() throws { + // A cached signature alone is not enough — it is only sent alongside a cached targeting result: + sdk.api.storage.clearTargeting() + sdk.api.storage.setID5Signature("id5-sig-abc123") + + let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345")], hids: []) + let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)! + + XCTAssertNil(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })) + } + + func test_targeting_request_omits_id5_signature_when_no_cached_signature() throws { + sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]])) + + let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345")], hids: []) + let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)! + + XCTAssertNil(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })) + } + /** For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/profile) */ diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index 28b8aa3..a1df6c8 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -68,6 +68,14 @@ class LocalStorageTests: XCTestCase { XCTAssert(readTargeting!.ortb2 == nil) } + func testID5SignatureStoring() { + localStorage.setID5Signature("id5-sig-abc123") + XCTAssertEqual(localStorage.getID5Signature(), "id5-sig-abc123") + + localStorage.clearTargeting() + XCTAssertNil(localStorage.getID5Signature()) + } + func testClearOptableTargeting() { let optableTargetingFull = OptableTargeting( optableTargeting: kOptableTargeting as! [String : Any], diff --git a/Tests/Unit/OptableTargetingTests.swift b/Tests/Unit/OptableTargetingTests.swift new file mode 100644 index 0000000..ebc274b --- /dev/null +++ b/Tests/Unit/OptableTargetingTests.swift @@ -0,0 +1,86 @@ +// +// OptableTargetingTests.swift +// OptableSDK +// +// Copyright © 2026 Optable Technologies, Inc. All rights reserved. +// + +@testable import OptableSDK +import XCTest + +class OptableTargetingTests: XCTestCase { + /// Targeting data in the shape returned by the edge targeting endpoint: + /// `ortb2.user.eids[].uids[].ext.optable.ref` points into `refs`, where the signature lives. + private func targetingData(source: String = "id5-sync.com", signature: Any = "id5-sig-abc123") -> [String: Any] { + return [ + "ortb2": [ + "user": [ + "eids": [ + [ + "source": source, + "uids": [ + ["id": "ID5*uid", "ext": ["optable": ["ref": "0"]]], + ], + ], + ], + ], + ], + "refs": ["0": ["signature": signature]], + ] + } + + func test_id5Signature_extracted_from_valid_targeting_data() { + let targeting = OptableTargeting(optableTargeting: targetingData()) + XCTAssertEqual(targeting.id5Signature, "id5-sig-abc123") + } + + func test_id5Signature_source_matching_is_case_insensitive() { + let targeting = OptableTargeting(optableTargeting: targetingData(source: "ID5-Sync.com")) + XCTAssertEqual(targeting.id5Signature, "id5-sig-abc123") + } + + func test_id5Signature_nil_when_source_is_not_id5() { + let targeting = OptableTargeting(optableTargeting: targetingData(source: "liveramp.com")) + XCTAssertNil(targeting.id5Signature) + } + + func test_id5Signature_nil_when_signature_empty_or_whitespace() { + XCTAssertNil(OptableTargeting(optableTargeting: targetingData(signature: "")).id5Signature) + XCTAssertNil(OptableTargeting(optableTargeting: targetingData(signature: " ")).id5Signature) + } + + func test_id5Signature_nil_when_signature_is_not_a_string() { + let targeting = OptableTargeting(optableTargeting: targetingData(signature: 123)) + XCTAssertNil(targeting.id5Signature) + } + + func test_id5Signature_nil_when_targeting_data_empty() { + XCTAssertNil(OptableTargeting(optableTargeting: [:]).id5Signature) + } + + func test_id5Signature_nil_when_refs_missing() { + var data = targetingData() + data["refs"] = nil + XCTAssertNil(OptableTargeting(optableTargeting: data).id5Signature) + } + + func test_id5Signature_nil_when_ref_not_found_in_refs() { + var data = targetingData() + data["refs"] = ["other-ref": ["signature": "id5-sig-abc123"]] + XCTAssertNil(OptableTargeting(optableTargeting: data).id5Signature) + } + + func test_id5Signature_nil_when_uid_has_no_optable_ref() { + var data = targetingData() + data["ortb2"] = ["user": ["eids": [["source": "id5-sync.com", "uids": [["id": "ID5*uid"]]]]]] + XCTAssertNil(OptableTargeting(optableTargeting: data).id5Signature) + } + + func test_id5Signature_found_among_multiple_eids() { + var data = targetingData() + var eids = ((data["ortb2"] as! [String: Any])["user"] as! [String: Any])["eids"] as! [[String: Any]] + eids.insert(["source": "liveramp.com", "uids": [["id": "ramp-uid"]]], at: 0) + data["ortb2"] = ["user": ["eids": eids]] + XCTAssertEqual(OptableTargeting(optableTargeting: data).id5Signature, "id5-sig-abc123") + } +} From f61ca87f50642a9e541cf4e64a62dd777e612e12 Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Thu, 16 Jul 2026 13:25:10 +0300 Subject: [PATCH 14/19] fix: edge api tests --- Tests/Unit/EdgeAPITests.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 8188277..6d0428c 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -178,9 +178,6 @@ class EdgeAPITests: XCTestCase { For more info check: [](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/optable-real-time-api-endpoints/targeting) */ func test_targeting_request_generation() throws { - // `id5_signature` is a resolver-specific parameter cached in storage from the latest targeting response, - // and only sent alongside a cached targeting result: - sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]])) sdk.api.storage.setID5Signature("id5-sig-abc123") let email: OptableIdentifier = .emailAddress("12345") @@ -215,17 +212,6 @@ class EdgeAPITests: XCTestCase { } } - func test_targeting_request_omits_id5_signature_when_no_stored_targeting() throws { - // A cached signature alone is not enough — it is only sent alongside a cached targeting result: - sdk.api.storage.clearTargeting() - sdk.api.storage.setID5Signature("id5-sig-abc123") - - let urlRequest = try sdk.api.targeting(ids: [.emailAddress("12345")], hids: []) - let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)! - - XCTAssertNil(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })) - } - func test_targeting_request_omits_id5_signature_when_no_cached_signature() throws { sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]])) From 099f991a35a6cde6592d10a524f60f6fede19156 Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Wed, 22 Jul 2026 23:14:38 +0200 Subject: [PATCH 15/19] fix: percent-encode '+' in query values URLComponents leaves '+' literal in the query, but the edge decodes '+' as a space, corrupting base64 values such as the ID5 signature. --- Source/Misc/URL+Compat.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Source/Misc/URL+Compat.swift b/Source/Misc/URL+Compat.swift index c69720a..f22bbbf 100644 --- a/Source/Misc/URL+Compat.swift +++ b/Source/Misc/URL+Compat.swift @@ -9,13 +9,12 @@ import Foundation extension URL { mutating func compatAppend(queryItems: [URLQueryItem]) { - if #available(iOS 16.0, *) { - append(queryItems: queryItems) - } else { - guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return } - components.queryItems?.append(contentsOf: queryItems) - guard let url = components.url else { return } - self = url - } + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return } + components.queryItems = (components.queryItems ?? []) + queryItems + // URLComponents leaves `+` literal in the query, but the edge decodes `+` as a space, + // which corrupts base64 values such as the ID5 signature. Encode it explicitly. + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + guard let url = components.url else { return } + self = url } } From e901a887dfc89e11a9b12d9e74c096eade6e25e8 Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Wed, 22 Jul 2026 23:14:38 +0200 Subject: [PATCH 16/19] refactor: forward all identifier types as hids Drop the client-side hid filter: any identifier type can be passed as a hint; which ones a resolver consumes is determined server-side. --- Source/Core/EdgeAPI.swift | 2 +- Source/OptableSDK.swift | 10 ++-- .../Public/ObjCSupport/OptableSDK+ObjC.swift | 6 +-- Source/Public/OptableIdentifier.swift | 19 ------- Tests/Unit/OptableIdentifiersTests.swift | 53 ------------------- 5 files changed, 8 insertions(+), 82 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 37247c6..f276e7c 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -71,7 +71,7 @@ final class EdgeAPI { .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "id", value: $0) }) - let hidQueryItems = hids.hids + let hidQueryItems = hids .compactMap({ $0.extendedIdentifier }) .compactMap({ URLQueryItem(name: "hid", value: $0) }) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 6e2e45f..c4169a7 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -125,10 +125,9 @@ public extension OptableSDK { first-party graph before any third-party graphs). When provided, they take precedence over any identifier in the passport. - hids: hint identifiers, sent as `hid` query parameters in addition to `ids`. Hints drive resolver-specific - identity resolution on the DCN, such as ID5 Mobile In-App. Only the identifier types valid as hints are - forwarded: email address, phone number, IPv6 address, Apple IDFA, Google GAID and custom IDs — any other - type in `hids` is dropped client-side. Custom (`cN`) prefixes must be configured on the DCN; unconfigured - ones are ignored server-side. + identity resolution on the DCN, such as ID5 Mobile In-App. All identifier types are forwarded as-is; + which ones a resolver consumes is determined server-side. Custom (`cN`) prefixes must be configured on + the DCN; unconfigured ones are ignored server-side. - completion: on completion, the handler receives: - .success(OptableTargeting) on success - .failure(Error) on failure @@ -159,8 +158,7 @@ public extension OptableSDK { /** This is the Swift Concurrency compatible version of the `targeting(ids, hids, completion)` API: `ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific - identity resolution such as ID5 Mobile In-App — see `targeting(_:hids:completion:)` for details on which - identifier types are valid hints. + identity resolution such as ID5 Mobile In-App - see `targeting(_:hids:completion:)` for details. Instead of completion callbacks, results are returned via async/await. */ diff --git a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift index 93e779a..14ca848 100644 --- a/Source/Public/ObjCSupport/OptableSDK+ObjC.swift +++ b/Source/Public/ObjCSupport/OptableSDK+ObjC.swift @@ -42,9 +42,9 @@ public extension OptableSDK { /** This is the Objective-C compatible version of the `targeting(ids, hids, completion)` API: `ids` match the user/device against the DCN, while `hids` are hint identifiers driving resolver-specific - identity resolution such as ID5 Mobile In-App. Only email address, phone number, IPv6 address, Apple IDFA, - Google GAID and custom identifiers are valid hints — any other type in `hids` is dropped client-side, and - custom (`cN`) prefixes not configured on the DCN are ignored server-side. + identity resolution such as ID5 Mobile In-App. All identifier types are forwarded as-is; which ones a + resolver consumes is determined server-side. Custom (`cN`) prefixes not configured on the DCN are + ignored server-side. Instead of completion callbacks, delegate methods are called. */ diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index dce1ed3..4bc5e51 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -133,22 +133,3 @@ public extension OptableIdentifier { } } } - -// MARK: - HIDs -extension Array where Element == OptableIdentifier { - /// https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app - /// - /// Note: all `.custom` identifiers are passed through, but only the custom (`cN`) prefixes - /// configured on the DCN are valid resolver hints — unconfigured ones are ignored server-side. - var hids: [OptableIdentifier] { - filter { - switch $0 { - case .ipv6Address(_), .emailAddress(_), .phoneNumber(_), - .appleIDFA(_), .googleGAID(_), .custom(_, _): - return true - default: - return false - } - } - } -} diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index 04784bd..a839baf 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -115,57 +115,4 @@ class OptableIdentifiersTests: XCTestCase { // Types that are never hashed are unaffected. XCTAssertEqual("c9:custom-9-id", try eid("c9:custom-9-id")) } - - // MARK: - hids - func test_hids_keepsOnlyHIDCases() throws { - let hidOnly: [OptableIdentifier] = [ - .emailAddress("foo@bar.com"), - .phoneNumber("+15123465890"), - .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), - .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), - .googleGAID("64873d9f-d5af-4770-8bcb-167a220eb17d"), - .custom(nil, "d29c551097b9dd0b82423827f65161232efaf7fc"), - .custom(1, "AaaZza.dh012"), - ] - - // Every element is a HID case, so contents and order are preserved. - XCTAssertEqual(hidOnly.hids.map(\.extendedIdentifier), hidOnly.map(\.extendedIdentifier)) - } - - func test_hids_dropsNonHIDCases() throws { - let nonHIDs: [OptableIdentifier] = [ - .postalCode("M5V 3L9"), - .ipv4Address("8.8.8.8"), - .rokuRIDA("0b179df0-6cd5-49f1-be21-425d002e0d22"), - .samsungTIFA("e0ef86a8-6ebf-4c9d-9127-e69407fe748d"), - .amazonFireAFAI("6e853799-ef31-4a30-8706-9742be254d38"), - .netID("_YV2v2Uhx3vqeH47Rrhzgr-4c3VNsxis4M1WY9qn--QTbVapax5VM2HJykoGAyWcwS5lKQ"), - .id5("ID5*UDWnp3JOtWV0ky-bHvEeU4xOVHXCmYeg24YigF8iAymUHplfYSElM3fy79h8p-Fg"), - .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), - .optableVID("v-value"), - ] - - XCTAssertTrue(nonHIDs.hids.isEmpty) - } - - func test_hids_filtersMixedArrayPreservingOrder() throws { - let mixed: [OptableIdentifier] = [ - .postalCode("M5V 3L9"), - .emailAddress("foo@bar.com"), - .ipv4Address("8.8.8.8"), - .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), - .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), - .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), - .custom(2, "ppid"), - ] - - let expected: [OptableIdentifier] = [ - .emailAddress("foo@bar.com"), - .appleIDFA("496f5db5-681f-4392-acd5-0d4f6e2f6b88"), - .ipv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), - .custom(2, "ppid"), - ] - - XCTAssertEqual(mixed.hids.map(\.extendedIdentifier), expected.map(\.extendedIdentifier)) - } } From de532e4a22b54887b81f45a0dded0da2e34b9a5a Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Wed, 22 Jul 2026 23:14:38 +0200 Subject: [PATCH 17/19] tests: cover '+' encoding and unfiltered hids in targeting request --- Tests/Unit/EdgeAPITests.swift | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Tests/Unit/EdgeAPITests.swift b/Tests/Unit/EdgeAPITests.swift index 6d0428c..d3fb4b8 100644 --- a/Tests/Unit/EdgeAPITests.swift +++ b/Tests/Unit/EdgeAPITests.swift @@ -182,7 +182,8 @@ class EdgeAPITests: XCTestCase { let email: OptableIdentifier = .emailAddress("12345") let phone: OptableIdentifier = .phoneNumber("54321") - let urlRequest = try sdk.api.targeting(ids: [email, phone], hids: [email, phone]) + let utiq: OptableIdentifier = .utiq("496f5db5-681f-4392-acd5-0d4f6e2f6b88") + let urlRequest = try sdk.api.targeting(ids: [email, phone], hids: [email, phone, utiq]) // Method XCTAssertEqual(urlRequest?.httpMethod, HTTPMethod.GET.rawValue) @@ -195,9 +196,10 @@ class EdgeAPITests: XCTestCase { XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == email.extendedIdentifier }) ?? false) XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "id" && $0.value == phone.extendedIdentifier }) ?? false) - // HIDs: email and phone are part of the HID set, so they are also emitted as `hid` params + // HIDs: every identifier passed as a hint is emitted as a repeated `hid` param, regardless of type XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == email.extendedIdentifier }) ?? false) XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == phone.extendedIdentifier }) ?? false) + XCTAssertTrue(urlComponents.queryItems?.contains(where: { $0.name == "hid" && $0.value == utiq.extendedIdentifier }) ?? false) // Resolver-specific parameters XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "ua" })?.value, T.api.userAgent) @@ -212,6 +214,22 @@ class EdgeAPITests: XCTestCase { } } + func test_targeting_request_percent_encodes_plus_in_query_values() throws { + // The edge decodes a literal `+` in the query as a space, which would corrupt + // base64 values such as the ID5 signature; it must go out as %2B on the wire. + sdk.api.storage.setID5Signature("sig+abc/123=") + + let urlRequest = try sdk.api.targeting(ids: [], hids: []) + let rawQuery = try XCTUnwrap(urlRequest?.url?.query) + + XCTAssertFalse(rawQuery.contains("+")) + XCTAssertTrue(rawQuery.contains("id5_signature=sig%2Babc/123")) + + // The decoded value round-trips unchanged + let urlComponents = URLComponents(url: urlRequest!.url!, resolvingAgainstBaseURL: false)! + XCTAssertEqual(urlComponents.queryItems?.first(where: { $0.name == "id5_signature" })?.value, "sig+abc/123=") + } + func test_targeting_request_omits_id5_signature_when_no_cached_signature() throws { sdk.api.storage.setTargeting(OptableTargeting(optableTargeting: ["resolved_ids": ["v:123"]])) From 524c89a03f07a3657941e9851466e79b32eb243c Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Wed, 22 Jul 2026 23:14:38 +0200 Subject: [PATCH 18/19] doc: document automatic resolver params and ID5 signature lifecycle Also fix the stale 'useragent' config parameter name (customUserAgent). --- docs/usage-objc.md | 15 ++++++++++++++- docs/usage-swift.md | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/usage-objc.md b/docs/usage-objc.md index 3a1d43a..248f2c7 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -73,7 +73,7 @@ OptableSDK *OPTABLE = nil; You can call various SDK APIs on the instance as shown in the examples below. It's also possible to configure multiple instances of `OptableSDK` in order to connect to other (e.g., partner) DCNs and/or reference other configured application slug IDs. Note that the `insecure` flag should always be set to `NO` unless you are testing a local instance of the DCN yourself. -You can disable user agent `WKWebView` based auto-detection and provide your own value by setting the `useragent` parameter to a string value, similar to the Swift example. +You can disable user agent `WKWebView` based auto-detection and provide your own value by setting the `customUserAgent` parameter to a string value, similar to the Swift example. ### Identify API @@ -138,6 +138,19 @@ You may optionally supply hint identifiers (`hids`) which are forwarded as resol > :information_source: For more details on `hid` parameters, including the supported identifier types, check: > [Optable Real-Time API Integrations Guide > Resolver Specific Parameters > ID5 Mobile In-App](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app) +#### Resolver-Specific Parameters + +On every targeting call the SDK automatically attaches the parameters required by resolver-specific integrations such as ID5 Mobile In-App: + +- `bundle`: the application's bundle identifier. +- `ver`: the application's version (`CFBundleShortVersionString`). +- `ua`: the user agent of the device's default browser, detected asynchronously via `WKWebView` at SDK initialization. If a targeting call happens before detection completes, the parameter is omitted; set the `customUserAgent` configuration parameter to guarantee it is always present. +- `id5_signature`: the ID5 signature cached from a previous targeting response, when available. + +When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache`. + +In addition, unless `skipAdvertisingIdDetection` is set in the configuration, the device IDFA is automatically added to both `ids` and `hids` when ad tracking is authorized by the user. + #### Caching Targeting Data The `targetingAndReturnError` method will automatically cache resulting key value data in client storage on success. You can subsequently retrieve the cached key value data as follows: diff --git a/docs/usage-swift.md b/docs/usage-swift.md index d93364a..df990dc 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -42,14 +42,14 @@ OPTABLE = OptableSDK(config: config) Note that production DCNs only listen to TLS traffic. The `insecure: true` option is meant to be used by Optable developers running the DCN locally for testing. -By default, the SDK detects the application user agent by sniffing `navigator.userAgent` from a `WKWebView`. The resulting user agent string is sent to your DCN for analytics purposes. To disable this behavior, you can provide an optional string parameter, `useragent`, which allows you to set whatever user agent string you would like to send instead. For example: +By default, the SDK detects the application user agent by sniffing `navigator.userAgent` from a `WKWebView`. The resulting user agent string is sent to your DCN for analytics purposes. To disable this behavior, you can provide an optional string parameter, `customUserAgent`, which allows you to set whatever user agent string you would like to send instead. For example: ```swift -let config = OptableConfig(..., useragent: "custom-ua") +let config = OptableConfig(..., customUserAgent: "custom-ua") OPTABLE = OptableSDK(config: config) ``` -The default value of `nil` for the `useragent` parameter enables the `WKWebView` auto-detection behavior. +The default value of `nil` for the `customUserAgent` parameter enables the `WKWebView` auto-detection behavior. ### Identify API @@ -156,6 +156,19 @@ try OPTABLE!.targeting(ids, hids: hids) { result in > :information_source: For more details on `hid` parameters, including the supported identifier types, check: > [Optable Real-Time API Integrations Guide > Resolver Specific Parameters > ID5 Mobile In-App](https://docs.optable.co/optable-documentation/guides/real-time-api-integrations-guide/resolver-specific-parameters#id5-mobile-in-app) +#### Resolver-Specific Parameters + +On every `targeting()` call the SDK automatically attaches the parameters required by resolver-specific integrations such as ID5 Mobile In-App: + +- `bundle`: the application's bundle identifier. +- `ver`: the application's version (`CFBundleShortVersionString`). +- `ua`: the user agent of the device's default browser, detected asynchronously via `WKWebView` at SDK initialization. If a targeting call happens before detection completes, the parameter is omitted; set `customUserAgent` in `OptableConfig` to guarantee it is always present. +- `id5_signature`: the ID5 signature cached from a previous targeting response, when available. + +When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache()`. + +In addition, unless `skipAdvertisingIdDetection` is set in `OptableConfig`, the device IDFA is automatically added to both `ids` and `hids` when ad tracking is authorized by the user. + #### Caching Targeting Data The `targeting` API will automatically cache resulting key value data in client storage on success. You can subsequently retrieve the cached key value data as follows: From 30268d286c8c2e7e75e7d35bfe7697e62ffa044e Mon Sep 17 00:00:00 2001 From: Olena Stepaniuk Date: Wed, 2 Sep 2026 11:32:34 +0300 Subject: [PATCH 19/19] fix: remove stale signature --- Source/Core/LocalStorage.swift | 2 +- Source/OptableSDK.swift | 5 +---- Tests/Unit/LocalStorageTests.swift | 8 ++++++++ docs/usage-objc.md | 2 +- docs/usage-swift.md | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Source/Core/LocalStorage.swift b/Source/Core/LocalStorage.swift index 0fe1df0..2b3016a 100644 --- a/Source/Core/LocalStorage.swift +++ b/Source/Core/LocalStorage.swift @@ -77,7 +77,7 @@ final class LocalStorage: NSObject { return UserDefaults.standard.string(forKey: id5SignatureKey) } - func setID5Signature(_ signature: String) { + func setID5Signature(_ signature: String?) { UserDefaults.standard.set(signature, forKey: id5SignatureKey) } } diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index c4169a7..c8625c5 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -358,10 +358,7 @@ extension OptableSDK { /// We cache the latest targeting result in client storage for targetingFromCache() users: self.api.storage.setTargeting(optableTargeting) - - if let id5Signature = optableTargeting.id5Signature { - self.api.storage.setID5Signature(id5Signature) - } + self.api.storage.setID5Signature(optableTargeting.id5Signature) completion(.success(optableTargeting)) } catch { diff --git a/Tests/Unit/LocalStorageTests.swift b/Tests/Unit/LocalStorageTests.swift index a1df6c8..f8e89ba 100644 --- a/Tests/Unit/LocalStorageTests.swift +++ b/Tests/Unit/LocalStorageTests.swift @@ -76,6 +76,14 @@ class LocalStorageTests: XCTestCase { XCTAssertNil(localStorage.getID5Signature()) } + func testID5SignatureRemovedWhenSetToNil() { + localStorage.setID5Signature("id5-sig-abc123") + XCTAssertEqual(localStorage.getID5Signature(), "id5-sig-abc123") + + localStorage.setID5Signature(nil) + XCTAssertNil(localStorage.getID5Signature()) + } + func testClearOptableTargeting() { let optableTargetingFull = OptableTargeting( optableTargeting: kOptableTargeting as! [String : Any], diff --git a/docs/usage-objc.md b/docs/usage-objc.md index 248f2c7..5dcf2cb 100644 --- a/docs/usage-objc.md +++ b/docs/usage-objc.md @@ -147,7 +147,7 @@ On every targeting call the SDK automatically attaches the parameters required b - `ua`: the user agent of the device's default browser, detected asynchronously via `WKWebView` at SDK initialization. If a targeting call happens before detection completes, the parameter is omitted; set the `customUserAgent` configuration parameter to guarantee it is always present. - `id5_signature`: the ID5 signature cached from a previous targeting response, when available. -When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache`. +When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache`. A targeting response that contains no ID5 signature removes any previously cached one, so the SDK never sends a stale signature. In addition, unless `skipAdvertisingIdDetection` is set in the configuration, the device IDFA is automatically added to both `ids` and `hids` when ad tracking is authorized by the user. diff --git a/docs/usage-swift.md b/docs/usage-swift.md index df990dc..3eccfb1 100644 --- a/docs/usage-swift.md +++ b/docs/usage-swift.md @@ -165,7 +165,7 @@ On every `targeting()` call the SDK automatically attaches the parameters requir - `ua`: the user agent of the device's default browser, detected asynchronously via `WKWebView` at SDK initialization. If a targeting call happens before detection completes, the parameter is omitted; set `customUserAgent` in `OptableConfig` to guarantee it is always present. - `id5_signature`: the ID5 signature cached from a previous targeting response, when available. -When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache()`. +When a targeting response contains an ID5 EID, the SDK extracts the associated signature and caches it in client storage. The cached signature persists across app restarts, is sent as `id5_signature` on subsequent targeting calls to improve ID5 resolve rates, and is removed by `targetingClearCache()`. A targeting response that contains no ID5 signature removes any previously cached one, so the SDK never sends a stale signature. In addition, unless `skipAdvertisingIdDetection` is set in `OptableConfig`, the device IDFA is automatically added to both `ids` and `hids` when ad tracking is authorized by the user.