@advenue/react-native 0.9.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
  6. package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +193 -0
  8. package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
  9. package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
  10. package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
  11. package/android/src/main/kotlin/io/advenue/core/Engine.kt +436 -0
  12. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
  13. package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
  14. package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
  15. package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
  16. package/android/src/main/kotlin/io/advenue/core/Limits.kt +50 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  19. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  20. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  21. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  22. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  23. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +163 -0
  24. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  26. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  27. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  28. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  30. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +96 -0
  31. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  32. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  38. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  39. package/dist/index.cjs +188 -675
  40. package/dist/index.d.cts +203 -336
  41. package/dist/index.d.ts +203 -336
  42. package/dist/index.js +187 -681
  43. package/ios/AdvenueIosModule.swift +143 -446
  44. package/ios/vendor/Advenue/Advenue.swift +632 -0
  45. package/ios/vendor/Advenue/AdvenueConfig.swift +89 -0
  46. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  47. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  48. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  49. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  50. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  51. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  52. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  53. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  54. package/ios/vendor/AdvenueCore/Engine.swift +607 -0
  55. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  56. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  57. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  58. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  59. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  60. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  61. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  62. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  63. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  64. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  65. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  66. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  67. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  68. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  69. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  70. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  71. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +77 -0
  72. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  73. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  74. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  75. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +155 -0
  76. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  77. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  78. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  79. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  80. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  81. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  82. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  83. package/package.json +13 -15
  84. package/scripts/check-dist.mjs +17 -0
  85. package/scripts/check-vendored-swift.mjs +148 -0
  86. package/scripts/vendor-natives.mjs +149 -0
  87. package/scripts/vendor-natives.test.mjs +112 -0
  88. package/src/deep-links.ts +19 -1
  89. package/src/index.ts +273 -830
  90. package/src/native-types.ts +73 -181
  91. package/src/native.ts +0 -23
  92. package/src/types.ts +86 -0
  93. package/src/aem.ts +0 -33
  94. package/src/mmkv-storage.ts +0 -21
  95. package/src/native-storage.ts +0 -63
  96. package/src/secure-store.ts +0 -26
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+
3
+ /// What the SDK POSTs. `apiKey` travels in the body, matching
4
+ /// `eventBatchSchema`.
5
+ struct EventBatch: Encodable {
6
+ let apiKey: String
7
+ let events: [ClientEvent]
8
+ }
9
+
10
+ public let DEFAULT_ENDPOINT = "https://ingest.advenue.io"
11
+ /// The schema caps a batch at 100 events.
12
+
13
+ /// Batched ingest over URLSession.
14
+ ///
15
+ /// Throws `IngestError` on a non-2xx so the caller can distinguish a transient
16
+ /// failure from a poison payload; a transport failure maps to 408, matching
17
+ /// sdk-core so both SDKs retry exactly the same cases.
18
+ public struct HttpTransport: EventTransport, Sendable {
19
+ private let endpoint: String
20
+ private let apiKey: String
21
+ private let signingSecret: String?
22
+ private let signer: any Signer
23
+ private let clock: any Clock
24
+ private let session: URLSession
25
+ /// Receives the app the server reported for an ACCEPTED batch. Purely
26
+ /// diagnostic: it runs after the batch is already accepted, so nothing it
27
+ /// does can turn a successful ingest into a failure.
28
+ private let onAccepted: @Sendable (String) -> Void
29
+
30
+ public init(
31
+ endpoint: String = DEFAULT_ENDPOINT,
32
+ apiKey: String,
33
+ signingSecret: String? = nil,
34
+ signer: any Signer = CryptoKitSigner(),
35
+ clock: any Clock = SystemClock(),
36
+ session: URLSession = .shared,
37
+ onAccepted: @escaping @Sendable (String) -> Void = { _ in }
38
+ ) {
39
+ self.onAccepted = onAccepted
40
+ self.endpoint = endpoint
41
+ self.apiKey = apiKey
42
+ self.signingSecret = signingSecret
43
+ self.signer = signer
44
+ self.clock = clock
45
+ self.session = session
46
+ }
47
+
48
+ /// The exact request this transport would send. Public so a test can assert
49
+ /// the wire shape — endpoint, headers, body, signature — without a network
50
+ /// round trip, which is the part that has to be right.
51
+ public func buildRequest(_ events: [ClientEvent]) throws -> URLRequest {
52
+ let body = try EventEncoding.canonicalEncoder()
53
+ .encode(EventBatch(apiKey: apiKey, events: events))
54
+ var request = URLRequest(url: URL(string: "\(endpoint)/v1/events")!)
55
+ request.httpMethod = "POST"
56
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
57
+ request.httpBody = body
58
+ request.timeoutInterval = 15
59
+
60
+ if let secret = signingSecret {
61
+ let timestamp = String(clock.nowMs())
62
+ request.setValue(timestamp, forHTTPHeaderField: "X-Advenue-Timestamp")
63
+ request.setValue(
64
+ signRequest(
65
+ signer, secret: secret, timestamp: timestamp,
66
+ body: String(decoding: body, as: UTF8.self)),
67
+ forHTTPHeaderField: "X-Advenue-Signature")
68
+ }
69
+ return request
70
+ }
71
+
72
+ public func send(_ events: [ClientEvent]) async throws {
73
+ let request = try buildRequest(events)
74
+ let response: URLResponse
75
+ let payload: Data
76
+ do {
77
+ (payload, response) = try await session.data(for: request)
78
+ } catch {
79
+ // No status to reason about. 408 marks it retryable, which is what
80
+ // sdk-core does for the same case.
81
+ throw IngestError(status: 408)
82
+ }
83
+ guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
84
+ guard (200..<300).contains(http.statusCode) else {
85
+ throw IngestError(status: http.statusCode)
86
+ }
87
+
88
+ // Diagnostics only — the batch is already accepted at this point, so a
89
+ // missing or unparseable body must change nothing.
90
+ if let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
91
+ let appId = body["appId"] as? String, !appId.isEmpty
92
+ {
93
+ onAccepted(appId)
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,52 @@
1
+ import Foundation
2
+
3
+ /// Survives uninstall — Keychain.
4
+ public let DEVICE_ID_KEY = "advenue.device_id"
5
+ /// Dies with the app — UserDefaults.
6
+ public let INSTALLATION_ID_KEY = "advenue.installation_id"
7
+
8
+ public enum IdentityResolution: Equatable, Sendable {
9
+ case resolved(deviceId: String, installationId: String)
10
+ /// The Keychain could not be read. The caller waits for
11
+ /// `protectedDataDidBecomeAvailable` rather than inventing an identity.
12
+ case deferred
13
+ }
14
+
15
+ /// Resolves the pair the SDK stamps on every event.
16
+ ///
17
+ /// The asymmetry is the design: `device_id` lives in the Keychain and survives
18
+ /// uninstall, `installation_id` lives in UserDefaults and does not. Reinstall
19
+ /// detection rests entirely on that difference, so neither half may quietly
20
+ /// move to the other store.
21
+ ///
22
+ /// A locked Keychain returns `.deferred` and mints nothing. Treating it as
23
+ /// absent is what would stamp a whole background launch with a device id
24
+ /// belonging to no real device — reachable since #26 introduced silent push,
25
+ /// which can start the app before the first unlock after a reboot.
26
+ public func resolveIdentity(
27
+ secure: any SecureStore,
28
+ store: any KeyValueStore,
29
+ uuid: any UUIDSource
30
+ ) -> IdentityResolution {
31
+ let deviceId: String
32
+ switch secure.read(DEVICE_ID_KEY) {
33
+ case .found(let existing):
34
+ deviceId = existing
35
+ case .unavailable:
36
+ return .deferred
37
+ case .absent:
38
+ let minted = uuid.next()
39
+ secure.write(minted, forKey: DEVICE_ID_KEY)
40
+ deviceId = minted
41
+ }
42
+
43
+ let installationId: String
44
+ if let existing = store.string(forKey: INSTALLATION_ID_KEY) {
45
+ installationId = existing
46
+ } else {
47
+ installationId = uuid.next()
48
+ store.set(installationId, forKey: INSTALLATION_ID_KEY)
49
+ }
50
+
51
+ return .resolved(deviceId: deviceId, installationId: installationId)
52
+ }
@@ -0,0 +1,155 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /// Spec §5: the install waits this long for enrichment and no longer. Matches
5
+ /// the deadlines the RN SDK already uses (SKAN config 3000 ms).
6
+ public let INSTALL_WINDOW_MS = 3_000
7
+
8
+ /// What the install event carries beyond the envelope every event has.
9
+ public struct Enrichment: Sendable, Equatable {
10
+ public var attestation: AttestationResult?
11
+ public var attestationChallenge: String?
12
+ public var idfa: String?
13
+ public var vendorId: String?
14
+ public var appInstanceId: String?
15
+ public var adservicesToken: String?
16
+ /// B2: reklam takibi sınırlama bayrağı — düşürülüyordu, artık olaya işlenir.
17
+ public var limitAdTracking: Bool
18
+
19
+ public init(
20
+ idfa: String? = nil, vendorId: String? = nil,
21
+ appInstanceId: String? = nil, adservicesToken: String? = nil,
22
+ attestation: AttestationResult? = nil, attestationChallenge: String? = nil,
23
+ limitAdTracking: Bool = false
24
+ ) {
25
+ self.attestation = attestation
26
+ self.attestationChallenge = attestationChallenge
27
+ self.idfa = idfa
28
+ self.vendorId = vendorId
29
+ self.appInstanceId = appInstanceId
30
+ self.adservicesToken = adservicesToken
31
+ self.limitAdTracking = limitAdTracking
32
+ }
33
+ }
34
+
35
+ /// The capabilities the collector draws on, injected so the deadline behaviour
36
+ /// is testable without ATT, AdServices or Firebase — none of which a simulator
37
+ /// can exercise.
38
+ public struct EnrichmentSources: Sendable {
39
+ public var searchAdsToken: @Sendable () async -> String?
40
+ public var advertisingId: @Sendable () -> (idfa: String?, vendorId: String?)
41
+ /// B2: ATT sınırlama bayrağı. Ayrı kapanış — advertisingId demetini
42
+ /// değiştirmek mevcut sahte kaynakları kırardı.
43
+ public var limitAdTracking: @Sendable () -> Bool
44
+ public var appInstanceId: @Sendable () async -> String?
45
+ /// Returns the attested challenge and its result, or nil. Every failure path
46
+ /// — unsupported device, challenge fetch, attest — yields nil, because an
47
+ /// install held for attestation is an install lost.
48
+ public var attestation: @Sendable () async -> (challenge: String, result: AttestationResult)?
49
+
50
+ public init(
51
+ searchAdsToken: @escaping @Sendable () async -> String?,
52
+ advertisingId: @escaping @Sendable () -> (idfa: String?, vendorId: String?),
53
+ appInstanceId: @escaping @Sendable () async -> String?,
54
+ attestation: @escaping @Sendable () async -> (challenge: String, result: AttestationResult)? = {
55
+ nil
56
+ },
57
+ limitAdTracking: @escaping @Sendable () -> Bool = { false }
58
+ ) {
59
+ self.searchAdsToken = searchAdsToken
60
+ self.advertisingId = advertisingId
61
+ self.appInstanceId = appInstanceId
62
+ self.attestation = attestation
63
+ self.limitAdTracking = limitAdTracking
64
+ }
65
+
66
+ /// Production wiring. `AdvertisingIdentity.advertisingId` already returns nil
67
+ /// unless ATT is authorized, so the SDK never ships the zero UUID — which
68
+ /// would be worse than nothing, because it looks like an identifier.
69
+ public static func system(
70
+ appInstanceIdProvider: (@Sendable () async -> String?)? = nil,
71
+ attestation: (@Sendable () async -> (challenge: String, result: AttestationResult)?)? = nil
72
+ ) -> EnrichmentSources {
73
+ EnrichmentSources(
74
+ searchAdsToken: { await SearchAdsTokenFetcher().token() },
75
+ advertisingId: {
76
+ let identity = AdvertisingIdentity()
77
+ return (idfa: identity.advertisingId, vendorId: identity.vendorId)
78
+ },
79
+ appInstanceId: { await appInstanceIdProvider?() },
80
+ attestation: { await attestation?() },
81
+ limitAdTracking: { AdvertisingIdentity().limitAdTracking })
82
+ }
83
+ }
84
+
85
+ /// Collects what is ready before `deadlineMs` and returns; a source still
86
+ /// running when the deadline expires contributes nothing.
87
+ ///
88
+ /// The deadline is hard by design (spec §5). The AdServices retry can still be
89
+ /// in flight when it expires, and the install is sent regardless: a token that
90
+ /// arrives afterwards rides the next event, whereas a delayed install shifts
91
+ /// every attribution window behind it.
92
+ public func collectEnrichment(
93
+ _ sources: EnrichmentSources,
94
+ deadlineMs: Int
95
+ ) async -> Enrichment {
96
+ // Synchronous reads; no reason to race them.
97
+ let ids = sources.advertisingId()
98
+ var result = Enrichment(
99
+ idfa: ids.idfa, vendorId: ids.vendorId,
100
+ limitAdTracking: sources.limitAdTracking())
101
+
102
+ await withTaskGroup(of: (String, String?).self) { group in
103
+ group.addTask { ("adservices", await sources.searchAdsToken()) }
104
+ group.addTask { ("appInstance", await sources.appInstanceId()) }
105
+ group.addTask {
106
+ try? await Task.sleep(nanoseconds: UInt64(deadlineMs) * 1_000_000)
107
+ return ("deadline", nil)
108
+ }
109
+
110
+ var settled = 0
111
+ for await (key, value) in group {
112
+ if key == "deadline" { break }
113
+ if key == "adservices" { result.adservicesToken = value }
114
+ if key == "appInstance" { result.appInstanceId = value }
115
+ settled += 1
116
+ if settled == 2 { break }
117
+ }
118
+ group.cancelAll()
119
+ }
120
+
121
+ // Attestation is two round trips — challenge, then attest — and is raced by
122
+ // the same deadline as everything else. An `async let` awaited here would
123
+ // NOT be bounded: it would hold the install for as long as Apple took, which
124
+ // is the one thing enrichment must never do.
125
+ if let attested = await withDeadline(ms: deadlineMs, { await sources.attestation() }) {
126
+ result.attestation = attested?.result
127
+ result.attestationChallenge = attested?.challenge
128
+ }
129
+
130
+ return result
131
+ }
132
+
133
+ /// Runs `work` and gives up at the deadline. Returns nil when the deadline won,
134
+ /// which the caller treats the same as "the source had nothing".
135
+ func withDeadline<T: Sendable>(
136
+ ms: Int, _ work: @escaping @Sendable () async -> T
137
+ ) async -> T? {
138
+ await withTaskGroup(of: Optional<T>.self) { group in
139
+ group.addTask { await work() }
140
+ group.addTask {
141
+ try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
142
+ return nil
143
+ }
144
+ let first = await group.next() ?? nil
145
+ group.cancelAll()
146
+ return first
147
+ }
148
+ }
149
+
150
+
151
+ /// Lowercase hex SHA-256. Used for the Meta AEM `sourceUrlHash`, which both the
152
+ /// SDK and the server dedup on, so the two must agree byte for byte.
153
+ public func sha256Hex(_ value: String) -> String {
154
+ SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
155
+ }
@@ -0,0 +1,95 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /// The answer to a secure read. The three cases are genuinely different, and
5
+ /// collapsing `unavailable` into `absent` is the bug this type exists to
6
+ /// prevent.
7
+ public enum SecureReadResult: Equatable, Sendable {
8
+ /// A value is stored and readable.
9
+ case found(String)
10
+ /// No such item. A caller may mint a new identity.
11
+ case absent
12
+ /// The item exists or may exist, but it cannot be read —
13
+ /// `errSecInteractionNotAllowed` on an `AfterFirstUnlock` item before the
14
+ /// first unlock after a reboot. A caller MUST NOT mint a new identity:
15
+ /// silent push can background-launch the app in exactly this window, and a
16
+ /// fresh device id there means a phantom device and a phantom install.
17
+ case unavailable
18
+ }
19
+
20
+ /// Storage that survives app uninstall. `device_id` lives here; the
21
+ /// installation id and install flag live in UserDefaults precisely because
22
+ /// they must NOT survive — reinstall detection rests on that asymmetry.
23
+ public protocol SecureStore: Sendable {
24
+ func read(_ key: String) -> SecureReadResult
25
+ func write(_ value: String, forKey key: String)
26
+ func delete(_ key: String)
27
+ }
28
+
29
+ public struct KeychainStore: SecureStore {
30
+ /// Shared with the App Attest key id under a different account, matching the
31
+ /// RN module. Same service, distinct accounts, so neither can read the
32
+ /// other's entry.
33
+ public static let service = "io.advenue.sdk"
34
+
35
+ public init() {}
36
+
37
+ private func baseQuery(_ key: String) -> [String: Any] {
38
+ [
39
+ kSecClass as String: kSecClassGenericPassword,
40
+ kSecAttrService as String: Self.service,
41
+ kSecAttrAccount as String: key,
42
+ ]
43
+ }
44
+
45
+ /// Maps a Keychain result to the three answers the SDK distinguishes.
46
+ ///
47
+ /// Split out as a pure function because it is the part that decides whether
48
+ /// a device id gets invented, and it is testable without Keychain access —
49
+ /// which an unsigned XCTest bundle in the simulator does not have
50
+ /// (`errSecMissingEntitlement`). The round-trip needs a signed host app or a
51
+ /// real device; this decision does not, and it is where the bug was.
52
+ public static func interpret(status: OSStatus, data: Data?) -> SecureReadResult {
53
+ switch status {
54
+ case errSecSuccess:
55
+ guard let data, let value = String(data: data, encoding: .utf8) else { return .absent }
56
+ return .found(value)
57
+ case errSecItemNotFound:
58
+ return .absent
59
+ default:
60
+ // errSecInteractionNotAllowed (locked), errSecMissingEntitlement, and
61
+ // every other error land here. Treating any of them as "no value" is
62
+ // what would invent a device.
63
+ return .unavailable
64
+ }
65
+ }
66
+
67
+ public func read(_ key: String) -> SecureReadResult {
68
+ var query = baseQuery(key)
69
+ query[kSecReturnData as String] = true
70
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
71
+ var result: AnyObject?
72
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
73
+ return Self.interpret(status: status, data: result as? Data)
74
+ }
75
+
76
+ public func write(_ value: String, forKey key: String) {
77
+ guard let data = value.data(using: .utf8) else { return }
78
+ let base = baseQuery(key)
79
+ let status = SecItemUpdate(
80
+ base as CFDictionary, [kSecValueData as String: data] as CFDictionary)
81
+ if status == errSecItemNotFound {
82
+ var add = base
83
+ add[kSecValueData as String] = data
84
+ // AfterFirstUnlock so a background launch after reboot can read it;
85
+ // ThisDeviceOnly so an iCloud restore cannot put one device's identity
86
+ // on another, which would merge two devices into one.
87
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
88
+ SecItemAdd(add as CFDictionary, nil)
89
+ }
90
+ }
91
+
92
+ public func delete(_ key: String) {
93
+ SecItemDelete(baseQuery(key) as CFDictionary)
94
+ }
95
+ }
@@ -0,0 +1,76 @@
1
+ import Foundation
2
+
3
+ #if canImport(AdServices)
4
+ import AdServices
5
+ #endif
6
+
7
+ public enum SearchAdsError: Error, Sendable {
8
+ /// Retrying may succeed — the token is often unavailable for a few seconds
9
+ /// after install.
10
+ case transient
11
+ /// Retrying cannot help.
12
+ case unsupported
13
+ }
14
+
15
+ /// Fetches the Apple Search Ads attribution token, retrying transient
16
+ /// failures.
17
+ ///
18
+ /// `AAAttribution.attributionToken()` can fail immediately after install;
19
+ /// field reports put the transient internal error at roughly 8% of users. The
20
+ /// RN module calls it once and rejects on any throw, so those installs
21
+ /// silently become organic and Apple Search Ads spend goes unattributed.
22
+ ///
23
+ /// The retry deliberately outlives the caller's first-open deadline: the
24
+ /// install is never held for it, and a token obtained on a later attempt
25
+ /// travels on the next event. Holding the install instead would shift
26
+ /// attribution windows, which is worse than a less enriched install.
27
+ public struct SearchAdsTokenFetcher: Sendable {
28
+ private let attempts: Int
29
+ private let delayMs: Int
30
+ private let fetch: @Sendable () throws -> String
31
+
32
+ public init(
33
+ attempts: Int = 3,
34
+ delayMs: Int = 1000,
35
+ fetch: (@Sendable () throws -> String)? = nil
36
+ ) {
37
+ self.attempts = attempts
38
+ self.delayMs = delayMs
39
+ self.fetch = fetch ?? Self.systemFetch
40
+ }
41
+
42
+ @Sendable
43
+ private static func systemFetch() throws -> String {
44
+ #if canImport(AdServices)
45
+ if #available(iOS 14.3, *) {
46
+ do {
47
+ return try AAAttribution.attributionToken()
48
+ } catch {
49
+ throw SearchAdsError.transient
50
+ }
51
+ }
52
+ throw SearchAdsError.unsupported
53
+ #else
54
+ throw SearchAdsError.unsupported
55
+ #endif
56
+ }
57
+
58
+ public func token() async -> String? {
59
+ for attempt in 1...attempts {
60
+ do {
61
+ return try fetch()
62
+ } catch SearchAdsError.unsupported {
63
+ return nil
64
+ } catch {
65
+ guard attempt < attempts else { return nil }
66
+ // 1 s, 2 s, 4 s — long enough for the system to settle, bounded so a
67
+ // permanently broken device does not retry forever.
68
+ let backoff = delayMs * (1 << (attempt - 1))
69
+ if backoff > 0 {
70
+ try? await Task.sleep(nanoseconds: UInt64(backoff) * 1_000_000)
71
+ }
72
+ }
73
+ }
74
+ return nil
75
+ }
76
+ }
@@ -0,0 +1,70 @@
1
+ import Foundation
2
+
3
+ /// Matches the deadline the RN SDK uses. The install does not wait for this
4
+ /// beyond it — a config that arrives late applies to the next event.
5
+ public let SKAN_CONFIG_TIMEOUT_MS = 3_000
6
+
7
+ /// Fetches the conversion-value config the dashboard serves.
8
+ ///
9
+ /// This is what makes SKAN configurable at all: a conversion schema is tuned
10
+ /// constantly and an app release cycle is weeks, so a config that can only be
11
+ /// changed by shipping a new binary is a config nobody changes.
12
+ public struct HttpSkanConfigFetcher: SkanConfigSource {
13
+ private let endpoint: String
14
+ private let apiKey: String
15
+ private let session: URLSession
16
+
17
+ public init(
18
+ endpoint: String = DEFAULT_ENDPOINT, apiKey: String, session: URLSession = .shared
19
+ ) {
20
+ self.endpoint = endpoint
21
+ self.apiKey = apiKey
22
+ self.session = session
23
+ }
24
+
25
+ public func fetch(etag: String?) async throws -> SkanConfigResult {
26
+ var request = URLRequest(url: URL(string: "\(endpoint)/v1/sdk-config")!)
27
+ request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
28
+ if let etag { request.setValue(etag, forHTTPHeaderField: "if-none-match") }
29
+ request.timeoutInterval = Double(SKAN_CONFIG_TIMEOUT_MS) / 1000
30
+
31
+ let (payload, response) = try await session.data(for: request)
32
+ guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
33
+ if http.statusCode == 304 { return .notModified }
34
+ guard (200..<300).contains(http.statusCode) else {
35
+ throw IngestError(status: http.statusCode)
36
+ }
37
+
38
+ guard let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
39
+ let version = body["version"] as? Int, version >= 0,
40
+ let skan = body["skan"] as? [String: Any]
41
+ else { throw IngestError(status: 422) }
42
+
43
+ return .fetched(
44
+ RemoteSkanConfig(
45
+ version: version,
46
+ rules: try decodeRules(skan),
47
+ etag: http.value(forHTTPHeaderField: "etag")))
48
+ }
49
+
50
+ /// Decoded by hand rather than through Codable: the server's shape is a JSON
51
+ /// object the dashboard writes, and a decoding failure must be an ordinary
52
+ /// rejected config rather than an exception nobody catches.
53
+ private func decodeRules(_ raw: [String: Any]) throws -> ConversionValueConfig {
54
+ let rawRules = raw["rules"] as? [[String: Any]] ?? []
55
+ let rules: [ConversionValueRule] = rawRules.compactMap { rule in
56
+ guard let fine = rule["fineValue"] as? Int else { return nil }
57
+ return ConversionValueRule(
58
+ fineValue: fine,
59
+ coarseValue: (rule["coarseValue"] as? String).flatMap(CoarseValue.init(rawValue:)),
60
+ minRevenueMicros: rule["minRevenueMicros"] as? String,
61
+ events: rule["events"] as? [String])
62
+ }
63
+ guard rules.count == rawRules.count else { throw IngestError(status: 422) }
64
+
65
+ return ConversionValueConfig(
66
+ rules: rules,
67
+ defaultCoarse: (raw["defaultCoarse"] as? String).flatMap(CoarseValue.init(rawValue:)),
68
+ revenueCurrency: raw["revenueCurrency"] as? String)
69
+ }
70
+ }
@@ -0,0 +1,140 @@
1
+ import Foundation
2
+
3
+ #if os(iOS)
4
+ import StoreKit
5
+
6
+ #if canImport(AdAttributionKit)
7
+ import AdAttributionKit
8
+ #endif
9
+ #endif
10
+
11
+ /// SKAdNetwork through StoreKit.
12
+ ///
13
+ /// Three API generations, and an OS with none of them must do nothing rather
14
+ /// than trap: an SDK that crashes on an old device takes the host app down with
15
+ /// it, and SKAN is the least important thing it does.
16
+ ///
17
+ /// On macOS — where the host test suite runs — SKAdNetwork does not exist at
18
+ /// all, so this degrades to nothing. `canImport(StoreKit)` is the wrong gate
19
+ /// and compiles happily before failing on the symbol: StoreKit is there, SKAN
20
+ /// is not.
21
+ ///
22
+ /// **What a simulator does here, measured rather than assumed:** it does NOT
23
+ /// silently succeed. `updatePostbackConversionValue` answers
24
+ /// `SKANErrorDomain 10` when there is no ad impression to attach a value to,
25
+ /// which is also what a real device with no impression does. So the simulator
26
+ /// exercises the SDK's degradation path for real — the caller abandons the
27
+ /// value and reports it — and that is worth more than the no-op this comment
28
+ /// originally claimed.
29
+ ///
30
+ /// What it still cannot settle is the success path: that a postback arrives
31
+ /// carrying the value we sent needs a device with a signed impression, and it
32
+ /// is on the checklist rather than implied here.
33
+ public struct StoreKitSkanReporter: SkanReporter {
34
+ private let onError: @Sendable (String, any Error) -> Void
35
+ private let adAttributionUpdate: @Sendable (Int, CoarseValue, Bool) async throws -> Void
36
+
37
+ public init(
38
+ onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
39
+ adAttributionUpdate: (@Sendable (Int, CoarseValue, Bool) async throws -> Void)? = nil
40
+ ) {
41
+ self.onError = onError
42
+ self.adAttributionUpdate = adAttributionUpdate ?? Self.systemAdAttributionUpdate
43
+ }
44
+
45
+ /// AdAttributionKit, iOS 17.4+. An impression may be signed for either rail,
46
+ /// so both are updated rather than one chosen.
47
+ ///
48
+ /// Injectable because the invariant that matters cannot otherwise be tested:
49
+ /// a failure here is **expected** whenever no AAK-signed impression exists,
50
+ /// and it must not fail the SKAN update. If it did, a device with only a
51
+ /// SKAN-signed impression would never confirm its value and that window would
52
+ /// report nothing for the rest of its life.
53
+ @Sendable
54
+ private static func systemAdAttributionUpdate(
55
+ fine: Int, coarse: CoarseValue, lockWindow: Bool
56
+ ) async throws {
57
+ #if os(iOS) && canImport(AdAttributionKit)
58
+ if #available(iOS 17.4, *) {
59
+ try await Postback.updateConversionValue(
60
+ fine, coarseConversionValue: coarse.adAttributionValue,
61
+ lockPostback: lockWindow)
62
+ }
63
+ #endif
64
+ }
65
+
66
+ public func register() {
67
+ #if os(iOS)
68
+ if #available(iOS 16.1, *) {
69
+ // The modern form registers implicitly on the first conversion update,
70
+ // but calling it explicitly is what starts the window for an install
71
+ // that never converts — which is most of them.
72
+ SKAdNetwork.updatePostbackConversionValue(0) { error in
73
+ if let error { onError("skan.register", error) }
74
+ }
75
+ } else if #available(iOS 14.0, *) {
76
+ SKAdNetwork.registerAppForAdNetworkAttribution()
77
+ }
78
+ #endif
79
+ }
80
+
81
+ public func update(fine: Int, coarse: CoarseValue, lockWindow: Bool) async throws {
82
+ // AdAttributionKit first, and its failure is swallowed on purpose: it is
83
+ // the expected answer when no AAK-signed impression exists. Letting it
84
+ // propagate would abandon a perfectly good SKAN value.
85
+ do {
86
+ try await adAttributionUpdate(fine, coarse, lockWindow)
87
+ } catch {
88
+ onError("skan.adAttributionKit", error)
89
+ }
90
+
91
+ #if os(iOS)
92
+ if #available(iOS 16.1, *) {
93
+ try await SKAdNetwork.updatePostbackConversionValue(
94
+ fine, coarseValue: coarse.storeKitValue, lockWindow: lockWindow)
95
+ return
96
+ }
97
+ if #available(iOS 15.4, *) {
98
+ // No coarse value before 16.1. Sending the fine value alone is still
99
+ // worth doing: v3-signed ads read it.
100
+ try await SKAdNetwork.updatePostbackConversionValue(fine)
101
+ return
102
+ }
103
+ if #available(iOS 14.0, *) {
104
+ SKAdNetwork.updateConversionValue(fine)
105
+ return
106
+ }
107
+ #endif
108
+ // No SKAN on this OS. Not an error worth surfacing — there is nothing the
109
+ // app or the SDK could do about it.
110
+ }
111
+ }
112
+
113
+ #if os(iOS) && canImport(AdAttributionKit)
114
+ extension CoarseValue {
115
+ /// AdAttributionKit's own enum, distinct from StoreKit's.
116
+ @available(iOS 17.4, *)
117
+ var adAttributionValue: AdAttributionKit.CoarseConversionValue {
118
+ switch self {
119
+ case .low: return .low
120
+ case .medium: return .medium
121
+ case .high: return .high
122
+ }
123
+ }
124
+ }
125
+ #endif
126
+
127
+ #if os(iOS)
128
+ extension CoarseValue {
129
+ /// Apple's own enum. Mapping this wrong silently files every postback in
130
+ /// the wrong bucket, and nothing downstream can detect it.
131
+ @available(iOS 16.1, *)
132
+ var storeKitValue: SKAdNetwork.CoarseConversionValue {
133
+ switch self {
134
+ case .low: return .low
135
+ case .medium: return .medium
136
+ case .high: return .high
137
+ }
138
+ }
139
+ }
140
+ #endif