Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions OptableSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
Unit/LocalStorageTests.swift,
Unit/OptableIdentifierEncoderTests.swift,
Unit/OptableIdentifiersTests.swift,
Unit/OptableSDKHelpersIdentifiersEnrichmentTests.swift,
Unit/OptableSDKHelpersTests.swift,
);
target = 6352AB0324EAD403002E66EB /* OptableSDKTests */;
};
Expand Down Expand Up @@ -382,6 +384,7 @@
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Tests/OptableSDKTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
Expand Down
111 changes: 95 additions & 16 deletions Source/Misc/AppTrackingTransparency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,47 @@
import Foundation

enum ATT {
static var advertisingIdentifier: UUID {
ASIdentifierManager.shared().advertisingIdentifier
}
// MARK: advertisingIdentifier

@available(iOS, introduced: 6, deprecated: 14, message: "This has been replaced by functionality in AppTrackingTransparency's ATTrackingManager class.")
static var isAdvertisingTrackingEnabled: Bool {
ASIdentifierManager.shared().isAdvertisingTrackingEnabled
}
#if DEBUG
static var advertisingIdentifier_DebugOverride: UUID?
static var advertisingIdentifier: UUID {
advertisingIdentifier_DebugOverride ?? ASIdentifierManager.shared().advertisingIdentifier
}
#else
static var advertisingIdentifier: UUID {
ASIdentifierManager.shared().advertisingIdentifier
}
#endif

// MARK: isAdvertisingTrackingEnabled

#if DEBUG
@available(iOS, introduced: 6, deprecated: 14,
message: "Replaced by ATTrackingManager in AppTrackingTransparency.")
static var isAdvertisingTrackingEnabled_DebugOverride: Bool?
static var isAdvertisingTrackingEnabled: Bool {
isAdvertisingTrackingEnabled_DebugOverride ?? ASIdentifierManager.shared().isAdvertisingTrackingEnabled
}
#else
static var isAdvertisingTrackingEnabled: Bool {
ASIdentifierManager.shared().isAdvertisingTrackingEnabled
}
#endif

// MARK: advertisingIdentifierAvailable

#if DEBUG
static var advertisingIdentifierAvailable_DebugOverride: Bool?
#endif

static var advertisingIdentifierAvailable: Bool {
#if DEBUG
if let override = advertisingIdentifierAvailable_DebugOverride {
return override
}
#endif

#if canImport(AppTrackingTransparency)
if #available(iOS 14, *) {
return trackingStatus == .authorized
Expand All @@ -36,8 +67,20 @@
return isAdvertisingTrackingEnabled
#endif
}


// MARK: attAvailable

#if DEBUG
static var attAvailable_DebugOverride: Bool?
#endif

static var attAvailable: Bool {
#if DEBUG
if let override = attAvailable_DebugOverride {
return override
}
#endif

if #available(iOS 14, *) {
return true
} else {
Expand All @@ -47,38 +90,74 @@

#if canImport(AppTrackingTransparency)

// MARK: canAuthorize

#if DEBUG
@available(iOS 14, *)
static var canAuthorize_DebugOverride: Bool?
#endif

static var canAuthorize: Bool {
if #available(iOS 14, *) {
#if DEBUG
if let override = canAuthorize_DebugOverride {
return override
}
#endif

return ATTrackingManager.trackingAuthorizationStatus == .notDetermined
} else {
return false
}
}

// MARK: trackingStatus

#if DEBUG
@available(iOS 14, *)
static var trackingStatus_DebugOverride: ATTrackingManager.AuthorizationStatus?
#endif

@available(iOS 14, *)
static var trackingStatus: ATTrackingManager.AuthorizationStatus {
ATTrackingManager.trackingAuthorizationStatus
#if DEBUG
return trackingStatus_DebugOverride ?? ATTrackingManager.trackingAuthorizationStatus
#else
return ATTrackingManager.trackingAuthorizationStatus
#endif
}

// MARK: RequestAuthorization

@available(iOS 14, *)
static func requestATTAuthorization(completion: ((Bool) -> Void)? = nil) {
#if DEBUG
if let override = trackingStatus_DebugOverride {
completion?(override == .authorized)
return
}
#endif

ATTrackingManager.requestTrackingAuthorization { status in
switch status {
case .authorized: completion?(true)
case .denied, .notDetermined, .restricted: completion?(false)
@unknown default: completion?(true)
case .authorized:
completion?(true)
case .denied, .notDetermined, .restricted:
completion?(false)
@unknown default:
completion?(true)
}
}
}

@available(iOS 14, *)
@discardableResult
static func requestATTAuthorization() async -> Bool {
await withCheckedContinuation({ continuation in
requestATTAuthorization(completion: { isAuthorized in
await withCheckedContinuation { continuation in
requestATTAuthorization { isAuthorized in
continuation.resume(returning: isAuthorized)
})
})
}
}
}

#endif
Expand Down
22 changes: 22 additions & 0 deletions Source/Misc/RangeReplaceableCollection+Compat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// RangeReplaceableCollection+Compat.swift
// OptableSDK
//
// Copyright © 2026 Optable Technologies, Inc. All rights reserved.
//

import Foundation
import SwiftUI

extension RangeReplaceableCollection where Self: MutableCollection, Index == Int {
mutating func removeCompat(atOffsets offsets: IndexSet) {
if #available(iOS 13.0, *) {
remove(atOffsets: offsets)
} else {
// Remove from highest index to lowest to avoid shifting issues
for index in offsets.sorted(by: >) {
remove(at: index)
}
}
}
}
50 changes: 33 additions & 17 deletions Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ public extension OptableSDK {

It is asynchronous, and on completion it will call the specified completion handler, passing
it either the HTTPURLResponse on success, or an NSError on failure.

```swift
// Example
optableSDK.identify(.init(emailAddress: "example@example.com", phoneNumber: "1234567890"), completion)
optableSDK.identify([.emailAddress("example@example.com"), .phoneNumber("1234567890")], completion: completion)
```
*/
func identify(_ ids: [OptableIdentifier], _ completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) throws {
func identify(_ ids: [OptableIdentifier], completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) throws {
try _identify(ids, completion: completion)
}

Expand Down Expand Up @@ -141,6 +142,11 @@ public extension OptableSDK {

On success, this method will also cache the resulting targeting data in client storage, which can
be access using targetingFromCache(), and cleared using targetingClearCache().

```swift
// Example
optableSDK.targeting([.emailAddress("example@example.com"), .phoneNumber("1234567890")], completion: completion)
```
*/
func targeting(_ ids: [OptableIdentifier]? = nil, completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
try _targeting(ids: ids, completion: completion)
Expand Down Expand Up @@ -203,7 +209,7 @@ public extension OptableSDK {
The witness method is asynchronous, and on completion it will call the specified completion handler,
passing it either the HTTPURLResponse on success, or an NSError on failure.
*/
func witness(event: String, properties: NSDictionary, _ completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) throws {
func witness(event: String, properties: NSDictionary, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) throws {
try _witness(event: event, properties: properties, completion: completion)
}

Expand Down Expand Up @@ -251,7 +257,7 @@ public extension OptableSDK {
The specified NSDictionary 'traits' can be subsequently used for audience assembly.
The profile method is asynchronous, and on completion it will call the specified completion handler, passing it either the HTTPURLResponse on success, or an NSError on failure.
*/
func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil, _ completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
func profile(traits: NSDictionary, id: String? = nil, neighbors: [String]? = nil, completion: @escaping (Result<OptableTargeting, Error>) -> Void) throws {
try _profile(traits: traits, id: id, neighbors: neighbors, completion: completion)
}

Expand Down Expand Up @@ -312,8 +318,8 @@ public extension OptableSDK {
}
}

// MARK: - Private
private extension OptableSDK {
// MARK: - Internal
extension OptableSDK {
func _identify(_ ids: [OptableIdentifier], completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) throws {
var ids = ids

Expand Down Expand Up @@ -345,7 +351,7 @@ private extension OptableSDK {
var ids = ids ?? []

enrichIfNeeded(ids: &ids)

guard let request = try api.targeting(ids: ids) else {
throw OptableError.targeting("Failed to create targeting request")
}
Expand Down Expand Up @@ -433,19 +439,29 @@ private extension OptableSDK {
}
}).resume()
}
private func enrichIfNeeded(ids: inout [OptableIdentifier]) {

func enrichIfNeeded(ids: inout [OptableIdentifier]) {
// Enrich with Apple IDFA
if config.skipAdvertisingIdDetection == false,
ATT.advertisingIdentifierAvailable,
ATT.advertisingIdentifier != UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)),
ids.contains(where: { eid in
if case let .appleIDFA(value) = eid, value.isEmpty == false {
return true
}
return false
}) == false {
ids.append(.appleIDFA(ATT.advertisingIdentifier.uuidString))
ATT.advertisingIdentifier != UUID(uuid: uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) {
let systemIDFA = ATT.advertisingIdentifier.uuidString

var idfaMatchingSystemIdxs: [Int] = []

for idx in ids.indices {
if case let .appleIDFA(value) = ids[idx] {
if value == systemIDFA {
idfaMatchingSystemIdxs.append(idx)
}
}
}

// Remove all matching systemIDFA (deduplicate)
ids.removeCompat(atOffsets: IndexSet(idfaMatchingSystemIdxs))

// Prepend all identifiers with systemIDFA
ids.insert(.appleIDFA(systemIDFA), at: ids.startIndex)
}
}

Expand Down
2 changes: 0 additions & 2 deletions Source/Public/ObjCSupport/OptableSDKIdentifier.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@

#import <Foundation/Foundation.h>

//#import <OptableSDK/OptableSDKIdentifierType.h>

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) {
Expand Down
28 changes: 26 additions & 2 deletions Tests/Integration/OptableSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,30 @@ class OptableSDKTests: XCTestCase {
try sdk.targeting([OptableSDKIdentifier(type: .emailAddress, value: "test@test.com", customIdx: nil)])
wait(for: [targetExpectation], timeout: 10)
}

func test_targetingFromCache_and_targetingClearCache() {
let config = OptableConfig(tenant: T.api.tenant.prebidtest, originSlug: T.api.slug.iosSDK)
let sdk = OptableSDK(config: config)

// Seed storage directly
let expected = OptableTargeting(
optableTargeting: ["foo": "bar"],
gamTargetingKeywords: ["ks": "id1,id2"],
ortb2: "{\"user\":{}}"
)
sdk.api.storage.setTargeting(expected)

// Read through SDK wrapper
let fromCache = sdk.targetingFromCache()
XCTAssertNotNil(fromCache)
XCTAssertEqual(fromCache!.targetingData as? [String: String], ["foo": "bar"])
XCTAssertEqual(fromCache!.gamTargetingKeywords as? [String: String], ["ks": "id1,id2"])
XCTAssertEqual(fromCache!.ortb2, "{\"user\":{}}")

// Clear and verify empty
sdk.targetingClearCache()
XCTAssertNil(sdk.targetingFromCache())
}

// MARK: Witness
@available(iOS 13.0, *)
Expand All @@ -87,7 +111,7 @@ class OptableSDKTests: XCTestCase {

func test_witness_callbacks() throws {
let expectation = expectation(description: "witness-callback-expectation")
try sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"], { result in
try sdk.witness(event: "test", properties: ["integration-test-witness": "integration-test-witness-value"], completion: { result in
switch result {
case let .success(response):
XCTAssert(response.allHeaderFields.keys.contains("x-optable-visitor"))
Expand All @@ -114,7 +138,7 @@ class OptableSDKTests: XCTestCase {

func test_profile_callbacks() throws {
let expectation = expectation(description: "profile-callback-expectation")
try sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"], { result in
try sdk.profile(traits: ["integration-test-profile": "integration-test-profile-value"], completion: { result in
switch result {
case let .success(response):
XCTAssert(response.targetingData.allKeys.isEmpty == false)
Expand Down
Loading
Loading