@advenue/react-native 0.8.0 → 1.0.0

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 (94) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -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 +186 -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 +414 -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 +47 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  19. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  20. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  21. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  22. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
  23. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  24. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  26. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  27. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  28. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
  30. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  31. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  32. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  38. package/dist/index.cjs +183 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  43. package/ios/vendor/Advenue/Advenue.swift +596 -0
  44. package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
  45. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  46. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  47. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  48. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  49. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  50. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  51. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  52. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  53. package/ios/vendor/AdvenueCore/Engine.swift +587 -0
  54. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  55. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  56. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  57. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  58. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  59. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  60. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  61. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  62. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  63. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  64. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  65. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  66. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  67. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  68. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  69. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
  70. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  71. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  72. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  73. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
  74. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  75. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  76. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  77. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  78. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  79. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  80. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  81. package/package.json +9 -11
  82. package/scripts/check-dist.mjs +17 -0
  83. package/scripts/check-vendored-swift.mjs +148 -0
  84. package/scripts/vendor-natives.mjs +149 -0
  85. package/scripts/vendor-natives.test.mjs +112 -0
  86. package/src/deep-links.ts +19 -1
  87. package/src/index.ts +267 -803
  88. package/src/native-types.ts +74 -171
  89. package/src/native.ts +0 -23
  90. package/src/types.ts +81 -0
  91. package/src/aem.ts +0 -33
  92. package/src/mmkv-storage.ts +0 -21
  93. package/src/native-storage.ts +0 -63
  94. package/src/secure-store.ts +0 -26
@@ -1,104 +1,144 @@
1
- import ExpoModulesCore
2
- import StoreKit
3
- import AdServices
4
- import AdSupport
5
1
  import AppTrackingTransparency
6
- import UIKit
7
- import DeviceCheck
8
- import CryptoKit
9
- #if canImport(AdAttributionKit)
10
- import AdAttributionKit
11
- #endif
12
-
13
- // Advenue iOS native module. Exposes SKAdNetwork conversion-value updates
14
- // (SKAN 4.0 fine + coarse) and the Apple Search Ads attribution token to JS.
15
- // The conversion-value POLICY lives in TS (@advenue/skadnetwork); this module
16
- // only performs the privileged StoreKit/AdServices calls.
2
+ import ExpoModulesCore
3
+ import Foundation
4
+
5
+ // No `import Advenue` / `import AdvenueCore` here, and that is not an omission.
6
+ // The podspec globs this file and the vendored SDK sources into ONE pod target,
7
+ // so those module names resolve to nothing — importing them is the error that
8
+ // stopped this package building at all. Every symbol below is already in scope
9
+ // for the same reason. `check-vendored-swift.mjs` keeps it that way.
10
+
11
+ /// The React Native bridge to the Advenue iOS SDK.
12
+ ///
13
+ /// It used to be a toolbox: one function per native capability — storage,
14
+ /// advertising ids, App Attest, SKAdNetwork, TCF, device info — because a
15
+ /// TypeScript core drove them one at a time. That core is retired, and every
16
+ /// one of those functions had stopped being called. They are gone rather than
17
+ /// left in place, because dead code in an SDK is code someone later assumes is
18
+ /// live, and one of them was warming a second Play Integrity provider on the
19
+ /// Android side for nobody.
20
+ ///
21
+ /// What is left is the bridge and one capability. `requestTrackingAuthorization`
22
+ /// stays a capability because the app decides WHEN to prompt, and that decision
23
+ /// belongs to the app's own UX rather than to the SDK's lifecycle.
17
24
  public class AdvenueIosModule: Module {
18
- // UIKit (UIScreen/UIDevice/UIPasteboard) is main-thread-only, but synchronous
19
- // `Function` handlers run on whatever thread invoked them (the JS thread on
20
- // RN's bridge). Hop to main when needed; `sync` is safe because these are
21
- // cheap local reads and the guard prevents a same-thread deadlock.
22
- private static func onMainSync<T>(_ work: () -> T) -> T {
23
- if Thread.isMainThread {
24
- return work()
25
- }
26
- return DispatchQueue.main.sync(execute: work)
27
- }
28
-
29
25
  public func definition() -> ModuleDefinition {
30
26
  Name("AdvenueIos")
31
27
 
32
- // Call once at launch so SKAdNetwork starts the measurement window.
33
- Function("registerAppForAttribution") {
34
- if #available(iOS 16.1, *) {
35
- // No-op: updatePostbackConversionValue implicitly registers.
36
- } else {
37
- SKAdNetwork.registerAppForAdNetworkAttribution()
38
- }
39
- }
40
-
41
- // SKAN 4.0: fine value 0–63 + coarse "low" | "medium" | "high". On
42
- // iOS 17.4+ the AdAttributionKit postback is updated as well, since an
43
- // impression may be signed for either rail.
44
- AsyncFunction("updateConversionValue") { (fineValue: Int, coarse: String, lockWindow: Bool, promise: Promise) in
45
- #if canImport(AdAttributionKit)
46
- if #available(iOS 17.4, *) {
47
- let aakCoarse: CoarseConversionValue = {
48
- switch coarse {
49
- case "high": return .high
50
- case "medium": return .medium
51
- default: return .low
52
- }
53
- }()
54
- Task {
55
- do {
56
- try await Postback.updateConversionValue(fineValue, coarseConversionValue: aakCoarse, lockPostback: lockWindow)
57
- } catch {
58
- // Expected when no AAK-signed impression exists; logged so SKAN/AAK
59
- // postback divergence on iOS 17.4+ is diagnosable. The SKAN call
60
- // below still resolves the promise.
61
- NSLog("[Advenue] AdAttributionKit conversion update skipped: %@", error.localizedDescription)
62
- }
63
- }
64
- }
65
- #endif
66
- if #available(iOS 16.1, *) {
67
- let coarseValue: SKAdNetwork.CoarseConversionValue = {
68
- switch coarse {
69
- case "high": return .high
70
- case "medium": return .medium
71
- default: return .low
72
- }
73
- }()
74
- SKAdNetwork.updatePostbackConversionValue(fineValue, coarseValue: coarseValue, lockWindow: lockWindow) { error in
75
- if let error = error { promise.reject("ESKAN", error.localizedDescription) }
76
- else { promise.resolve(nil) }
77
- }
78
- } else if #available(iOS 15.4, *) {
79
- SKAdNetwork.updatePostbackConversionValue(fineValue) { error in
80
- if let error = error { promise.reject("ESKAN", error.localizedDescription) }
81
- else { promise.resolve(nil) }
28
+ // Diagnostics travel back to JavaScript. `onError` is a JS closure and
29
+ // cannot cross the channel, so what crosses is the event: a stable context
30
+ // identifier and a message, never PII. Without this the SDK's own swallowed
31
+ // failures — deferred identity, a rejected event, a transport error — reach
32
+ // nobody, which is what happened between the inversion and this commit.
33
+ Events("onAdvenueError")
34
+
35
+ Function("bridgeInitialize") { (options: [String: Any]) in
36
+ guard let app = options["apiKey"] as? String else { return }
37
+ var config = AdvenueConfig(
38
+ apiKey: app,
39
+ endpoint: (options["endpoint"] as? String) ?? DEFAULT_ENDPOINT,
40
+ appVersion: options["appVersion"] as? String,
41
+ requireConsent: (options["requireConsent"] as? Bool) ?? false,
42
+ signingSecret: options["signingSecret"] as? String,
43
+ onError: { [weak self] context, error in
44
+ self?.sendEvent("onAdvenueError", ["context": context, "message": "\(error)"])
45
+ },
46
+ // The wrapper's version, not this SDK's: a React Native release pins
47
+ // the native snapshot inside it, so its version identifies both.
48
+ sdkVersion: options["sdkVersion"] as? String)
49
+ if let flushInterval = options["flushIntervalMs"] as? Int {
50
+ config.flushIntervalMs = flushInterval
51
+ }
52
+ if let batchSize = options["batchSize"] as? Int { config.batchSize = batchSize }
53
+ Advenue.initialize(config)
54
+ // Firebase App Instance ID, when the host app already ships Firebase.
55
+ // `AdvenueFirebase` guards its import with `#if canImport`, which is
56
+ // false under SPM unless declared but true under CocoaPods, where the
57
+ // host app's pods are linked together — so this is safe here and would
58
+ // not be in a Swift Package. The Android side gets the same automatic
59
+ // detection from `plugin-firebase-reflect`.
60
+ AdvenueFirebase.register()
61
+ }
62
+
63
+ Function("bridgeTrack") { (name: String, properties: [String: Any]?) in
64
+ Advenue.track(name, properties: properties.map(Self.toAdvenueValues))
65
+ }
66
+
67
+ Function("bridgeFlush") { Advenue.flush() }
68
+ Function("bridgeSetUserId") { (id: String?) in Advenue.setUserId(id) }
69
+ Function("bridgeSetConsent") { (granted: Bool) in Advenue.setConsent(granted) }
70
+ Function("bridgeForgetMe") { Advenue.forgetMe() }
71
+ Function("bridgeShutdown") { Advenue.shutdown() }
72
+ Function("bridgeResolvedAppId") { () -> String? in Advenue.resolvedAppId() }
73
+
74
+ Function("bridgeSetConsentData") { (data: [String: Any]) in
75
+ guard let gdpr = data["isUserSubjectToGDPR"] as? Bool else { return }
76
+ Advenue.setConsentData(
77
+ Consent(
78
+ isUserSubjectToGDPR: gdpr,
79
+ hasConsentForDataUsage: data["hasConsentForDataUsage"] as? Bool,
80
+ hasConsentForAdsPersonalization: data["hasConsentForAdsPersonalization"] as? Bool,
81
+ hasConsentForAdStorage: data["hasConsentForAdStorage"] as? Bool))
82
+ }
83
+
84
+ // Read back, for the JavaScript consent cache. The cache is seeded from
85
+ // this at initialize: consent is persisted and survives restarts, so a
86
+ // facade defaulting to false on every cold start would silently discard a
87
+ // preference the user gave.
88
+ Function("bridgeGetConsent") { () -> Bool in Advenue.trackingConsent() }
89
+
90
+ Function("bridgeGetConsentData") { () -> [String: Any]? in
91
+ guard let consent = Advenue.consentData() else { return nil }
92
+ var out: [String: Any] = ["isUserSubjectToGDPR": consent.isUserSubjectToGDPR]
93
+ // Written only when stated: "not asked" is not "denied", and a false
94
+ // invented here is a recorded refusal that never happened.
95
+ if let v = consent.hasConsentForDataUsage { out["hasConsentForDataUsage"] = v }
96
+ if let v = consent.hasConsentForAdsPersonalization {
97
+ out["hasConsentForAdsPersonalization"] = v
98
+ }
99
+ if let v = consent.hasConsentForAdStorage { out["hasConsentForAdStorage"] = v }
100
+ return out
101
+ }
102
+
103
+ Function("bridgeSetAppInstanceId") { (id: String?) in Advenue.setAppInstanceId(id) }
104
+
105
+ Function("bridgeSetPushToken") { (token: String?, provider: String?) in
106
+ Advenue.setPushToken(token, provider: provider)
107
+ }
108
+
109
+ Function("bridgeProcessDeepLink") { (url: String) in
110
+ guard let parsed = URL(string: url) else { return }
111
+ Advenue.processDeepLink(parsed)
112
+ }
113
+
114
+ // The amount arrives as the app's price string, not micros: converting it
115
+ // in JavaScript would put a money computation back in the layer this
116
+ // release is emptying. A malformed amount records nothing.
117
+ Function("bridgeRecordSkanRevenue") { (amount: String, currency: String) in
118
+ Advenue.recordSkanRevenue(amount: amount, currency: currency)
119
+ }
120
+
121
+ AsyncFunction("bridgeDeviceId") { (promise: Promise) in
122
+ Task { promise.resolve(await Advenue.deviceId()) }
123
+ }
124
+
125
+ AsyncFunction("bridgeResolveDeferredDeepLink") { (promise: Promise) in
126
+ Task {
127
+ guard let link = await Advenue.resolveDeferredDeepLink() else {
128
+ promise.resolve(nil)
129
+ return
82
130
  }
83
- } else {
84
- SKAdNetwork.updateConversionValue(fineValue)
85
- promise.resolve(nil)
131
+ promise.resolve([
132
+ "deepLinkValue": link.deepLinkValue as Any,
133
+ "campaign": link.campaign as Any,
134
+ "network": link.network as Any,
135
+ "influencerId": link.influencerId as Any,
136
+ "isDeferred": link.isDeferred,
137
+ "isFirstLaunch": link.isFirstLaunch,
138
+ ])
86
139
  }
87
140
  }
88
141
 
89
- // Apple Search Ads attribution token; resolved server-side via @advenue/skadnetwork.
90
- AsyncFunction("getAttributionToken") { (promise: Promise) in
91
- if #available(iOS 14.3, *) {
92
- do {
93
- let token = try AAAttribution.attributionToken()
94
- promise.resolve(token)
95
- } catch {
96
- promise.reject("EASA", error.localizedDescription)
97
- }
98
- } else {
99
- promise.resolve(nil)
100
- }
101
- }
102
142
 
103
143
  // ATT consent prompt. The consumer app must declare
104
144
  // NSUserTrackingUsageDescription in Info.plist, or iOS rejects the call.
@@ -111,262 +151,23 @@ public class AdvenueIosModule: Module {
111
151
  promise.resolve("authorized")
112
152
  }
113
153
  }
114
-
115
- AsyncFunction("getAdvertisingId") { (promise: Promise) in
116
- if #available(iOS 14, *) {
117
- guard ATTrackingManager.trackingAuthorizationStatus == .authorized else {
118
- promise.resolve(nil)
119
- return
120
- }
121
- }
122
- let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
123
- promise.resolve(idfa == "00000000-0000-0000-0000-000000000000" ? nil : idfa)
124
- }
125
-
126
- Function("getTrackingAuthorizationStatus") { () -> String in
127
- if #available(iOS 14, *) {
128
- return Self.attStatusString(ATTrackingManager.trackingAuthorizationStatus)
129
- }
130
- return "authorized"
131
- }
132
-
133
- // Vendor-scoped device id (identifierForVendor). Available WITHOUT ATT because
134
- // it is NOT an advertising identifier. First-party fraud/dedup use only — it
135
- // must not be used for cross-network attribution, which would be "tracking".
136
- Function("getIdentifierForVendor") { () -> String? in
137
- return Self.onMainSync { UIDevice.current.identifierForVendor?.uuidString }
138
- }
139
-
140
- // G3.3 — DeviceCheck token. Calls DCDevice.current.generateToken to obtain
141
- // the opaque base64 token that identifies this physical device to the Apple
142
- // DeviceCheck API. The server uses it (with the per-app DeviceCheck key) to
143
- // query/update the persistent 2-bit per-device flag. Unlike App Attest, this
144
- // requires no challenge/server round-trip — just generate and attach.
145
- //
146
- // Rejects with EUNSUPPORTED when DCDevice.current.isSupported is false (iOS
147
- // simulator, Catalyst, etc.) so the JS layer can fail-safe and omit the token.
148
- // IMPORTANT: NOT compile/on-device verified here; reviewed against the
149
- // DCDevice API. The JS fail-safe in sdk-react-native ensures missing token
150
- // never blocks attribution.
151
- AsyncFunction("getDeviceCheckToken") { (promise: Promise) in
152
- guard DCDevice.current.isSupported else {
153
- promise.reject("EUNSUPPORTED", "DCDevice is not supported on this device")
154
- return
155
- }
156
- DCDevice.current.generateToken { tokenData, error in
157
- if let error = error {
158
- promise.reject("EDEVICECHECK", error.localizedDescription)
159
- return
160
- }
161
- guard let tokenData = tokenData else {
162
- promise.reject("EDEVICECHECK", "generateToken returned nil data")
163
- return
164
- }
165
- promise.resolve(tokenData.base64EncodedString())
166
- }
167
- }
168
-
169
- // G3.2n — App Attest. Takes the server-supplied challenge string, computes
170
- // clientDataHash = SHA-256(challenge UTF-8), generates (or reuses a
171
- // keychain-cached) DCAppAttest key, and calls attestKey to obtain the
172
- // CBOR attestation object. Resolves { keyId, attestationObject } where
173
- // attestationObject is base64-encoded — pass directly to the server's
174
- // verifyAppAttest endpoint. Rejects with a code on any failure so the JS
175
- // layer can fail-safe (omit attestation rather than blocking the install).
176
- // IMPORTANT: DCAppAttestService.isSupported is false on simulators; this
177
- // causes the JS layer to skip attestation entirely (server sees 'unavailable').
178
- AsyncFunction("attestKey") { (challenge: String, promise: Promise) in
179
- guard #available(iOS 14.0, *) else {
180
- promise.reject("EUNSUPPORTED", "App Attest requires iOS 14.0+")
181
- return
182
- }
183
- let service = DCAppAttestService.shared
184
- guard service.isSupported else {
185
- promise.reject("EUNSUPPORTED", "DCAppAttestService is not supported on this device")
186
- return
187
- }
188
-
189
- // Compute clientDataHash = SHA-256 of the UTF-8 challenge bytes.
190
- guard let challengeData = challenge.data(using: .utf8) else {
191
- promise.reject("EBADCHALLENGE", "Failed to encode challenge as UTF-8")
192
- return
193
- }
194
- let clientDataHash = Data(SHA256.hash(data: challengeData))
195
-
196
- // Load a previously generated key from Keychain, or generate a new one.
197
- // Key is cached per-install/device so we only call generateKey once.
198
- Self.resolveKeyId(service: service) { result in
199
- switch result {
200
- case .failure(let error):
201
- promise.reject("EKEYID", error.localizedDescription)
202
- case .success(let keyId):
203
- service.attestKey(keyId, clientDataHash: clientDataHash) { attestationObject, error in
204
- if let error = error {
205
- // A key can be attested only once. If Apple rejects this keyId as
206
- // invalid (e.g. it was already attested but the server never recorded
207
- // it, or the key became invalid), the cached id is permanently
208
- // unusable — drop it so the NEXT call generates a fresh key instead
209
- // of looping on the dead id forever.
210
- if (error as? DCError)?.code == .invalidKey {
211
- Self.deleteKeyId()
212
- }
213
- promise.reject("EATTEST", error.localizedDescription)
214
- return
215
- }
216
- guard let attestationObject = attestationObject else {
217
- promise.reject("EATTEST", "attestKey returned nil attestation object")
218
- return
219
- }
220
- let base64Attest = attestationObject.base64EncodedString()
221
- promise.resolve(["keyId": keyId, "attestationObject": base64Attest])
222
- }
223
- }
224
- }
225
- }
226
-
227
- // Device metadata for Meta CAPI `extinfo` (Task 3n). Synchronous — every
228
- // API used here is a cheap local read (no network I/O). Best-effort per
229
- // field: a field is simply omitted when its source is unavailable, so the
230
- // JS-side fail-safe (collectDeviceInfo) degrades gracefully rather than
231
- // sending misleading placeholder values.
232
- // IMPORTANT: NOT compile/on-device verified here; reviewed against the
233
- // UIKit/Foundation APIs. The JS fail-safe in sdk-react-native ensures a
234
- // missing/partial result never blocks attribution.
235
- Function("getDeviceInfo") { () -> [String: Any] in
236
- var info: [String: Any] = [:]
237
-
238
- // Hardware model identifier (e.g. "iPhone16,1") via utsname, not the
239
- // marketing name — matches the Android side's Build.MODEL granularity.
240
- var systemInfo = utsname()
241
- uname(&systemInfo)
242
- let model = withUnsafePointer(to: &systemInfo.machine) {
243
- $0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in
244
- String(cString: ptr)
245
- }
246
- }
247
- info["model"] = model
248
- info["locale"] = Locale.current.identifier
249
- info["timezone"] = TimeZone.current.identifier
250
- info["cpuCores"] = ProcessInfo.processInfo.activeProcessorCount
251
-
252
- let (screenWidth, screenHeight, screenDensity) = Self.onMainSync { () -> (Int, Int, Double) in
253
- let screen = UIScreen.main
254
- return (
255
- Int(screen.bounds.width * screen.scale),
256
- Int(screen.bounds.height * screen.scale),
257
- Double(screen.scale)
258
- )
259
- }
260
- info["screenWidth"] = screenWidth
261
- info["screenHeight"] = screenHeight
262
- info["screenDensity"] = screenDensity
263
-
264
- if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) {
265
- let gb = 1024.0 * 1024.0 * 1024.0
266
- if let total = attrs[.systemSize] as? NSNumber {
267
- info["totalStorageGb"] = total.doubleValue / gb
268
- }
269
- if let free = attrs[.systemFreeSize] as? NSNumber {
270
- info["freeStorageGb"] = free.doubleValue / gb
271
- }
272
- }
273
-
274
- return info
275
- }
276
-
277
- // MARK: - Built-in SDK storage (Adjust-style: the app supplies nothing)
278
-
279
- // Sync key/value storage backing the SDK's offline buffer + state. A
280
- // dedicated UserDefaults suite keeps SDK keys out of the app's defaults.
281
- Function("storageGetItem") { (key: String) -> String? in
282
- Self.sdkDefaults.string(forKey: key)
283
- }
284
- Function("storageSetItem") { (key: String, value: String) in
285
- Self.sdkDefaults.set(value, forKey: key)
286
- }
287
- Function("storageRemoveItem") { (key: String) in
288
- Self.sdkDefaults.removeObject(forKey: key)
289
- }
290
-
291
- // MARK: - DMA / TCF consent (read-only)
292
-
293
- // IAB TCF v2 data a TCF-compliant CMP (e.g. Google UMP) writes to the
294
- // app's *standard* UserDefaults — deliberately not our io.advenue.sdk
295
- // suite. Raw values pass through; the TS side (tcfToConsent) owns the
296
- // policy mapping. UserDefaults is thread-safe — no main-thread hop.
297
- Function("getTcfData") { () -> [String: Any?]? in
298
- let std = UserDefaults.standard
299
- // object(forKey:), not integer(forKey:): a missing key must read as
300
- // unknown, not 0 ("GDPR does not apply") — a defaulted 0 would strip
301
- // consent handling from EEA traffic.
302
- let gdprApplies = std.object(forKey: "IABTCF_gdprApplies") as? Int
303
- let purposeConsents = std.string(forKey: "IABTCF_PurposeConsents")
304
- if gdprApplies == nil && purposeConsents == nil { return nil }
305
- return ["gdprApplies": gdprApplies, "purposeConsents": purposeConsents]
306
- }
307
-
308
- // Keychain-backed secure storage for the reinstall-durable identifiers
309
- // (device id, install guard). kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
310
- // survives app reinstall, readable after first unlock (background events),
311
- // and never migrates via device-to-device backup restore — a restored
312
- // backup must NOT clone another device's attribution identity.
313
- Function("secureGetItem") { (key: String) -> String? in
314
- Self.keychainGet(key)
315
- }
316
- Function("secureSetItem") { (key: String, value: String) in
317
- Self.keychainSet(key, value)
318
- }
319
- Function("secureRemoveItem") { (key: String) in
320
- Self.keychainRemove(key)
321
- }
322
- }
323
-
324
- // MARK: - Built-in storage helpers
325
-
326
- private static let sdkDefaults: UserDefaults =
327
- UserDefaults(suiteName: "io.advenue.sdk") ?? .standard
328
-
329
- // Shares `keychainService` with the App Attest helpers below — same service,
330
- // distinct accounts (storage keys vs "app-attest-key-id"), so neither can
331
- // read or delete the other's entries.
332
- private static func keychainBaseQuery(_ key: String) -> [String: Any] {
333
- [
334
- kSecClass as String: kSecClassGenericPassword,
335
- kSecAttrService as String: keychainService,
336
- kSecAttrAccount as String: key,
337
- ]
338
154
  }
339
155
 
340
- private static func keychainGet(_ key: String) -> String? {
341
- var query = keychainBaseQuery(key)
342
- query[kSecReturnData as String] = true
343
- query[kSecMatchLimit as String] = kSecMatchLimitOne
344
- var result: AnyObject?
345
- let status = SecItemCopyMatching(query as CFDictionary, &result)
346
- guard status == errSecSuccess, let data = result as? Data else {
347
- return nil
348
- }
349
- return String(data: data, encoding: .utf8)
350
- }
351
-
352
- private static func keychainSet(_ key: String, _ value: String) {
353
- guard let data = value.data(using: .utf8) else { return }
354
- let base = keychainBaseQuery(key)
355
- let update: [String: Any] = [kSecValueData as String: data]
356
- let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
357
- if status == errSecItemNotFound {
358
- var add = base
359
- add[kSecValueData as String] = data
360
- add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
361
- SecItemAdd(add as CFDictionary, nil)
156
+ /// reaches the warehouse looking like a measurement.
157
+ private static func toAdvenueValues(_ raw: [String: Any]) -> [String: AdvenueValue] {
158
+ var out: [String: AdvenueValue] = [:]
159
+ for (key, value) in raw {
160
+ switch value {
161
+ case let v as String: out[key] = .string(v)
162
+ case let v as Bool: out[key] = .bool(v)
163
+ case let v as Int: out[key] = .int(v)
164
+ case let v as Double: out[key] = .double(v)
165
+ default: continue
166
+ }
362
167
  }
168
+ return out
363
169
  }
364
170
 
365
- private static func keychainRemove(_ key: String) {
366
- SecItemDelete(keychainBaseQuery(key) as CFDictionary)
367
- }
368
-
369
- @available(iOS 14, *)
370
171
  private static func attStatusString(_ status: ATTrackingManager.AuthorizationStatus) -> String {
371
172
  switch status {
372
173
  case .authorized: return "authorized"
@@ -375,93 +176,4 @@ public class AdvenueIosModule: Module {
375
176
  default: return "notDetermined"
376
177
  }
377
178
  }
378
-
379
- // MARK: - App Attest Keychain helpers
380
-
381
- private static let keychainService = "io.advenue.sdk"
382
- private static let keychainAccount = "app-attest-key-id"
383
-
384
- /// Loads the persisted App Attest keyId from the Keychain. Returns nil when
385
- /// no key has been generated yet; the caller must then call generateKey and
386
- /// persist the result via saveKeyId(_:).
387
- @available(iOS 14.0, *)
388
- private static func loadKeyId() -> String? {
389
- let query: [CFString: Any] = [
390
- kSecClass: kSecClassGenericPassword,
391
- kSecAttrService: keychainService,
392
- kSecAttrAccount: keychainAccount,
393
- kSecReturnData: true,
394
- kSecMatchLimit: kSecMatchLimitOne,
395
- ]
396
- var item: CFTypeRef?
397
- let status = SecItemCopyMatching(query as CFDictionary, &item)
398
- guard status == errSecSuccess, let data = item as? Data else { return nil }
399
- return String(data: data, encoding: .utf8)
400
- }
401
-
402
- /// Persists the App Attest keyId to the Keychain, replacing any prior value.
403
- @available(iOS 14.0, *)
404
- private static func saveKeyId(_ keyId: String) {
405
- guard let data = keyId.data(using: .utf8) else { return }
406
- // Try to update an existing item first; fall back to adding a new one.
407
- let query: [CFString: Any] = [
408
- kSecClass: kSecClassGenericPassword,
409
- kSecAttrService: keychainService,
410
- kSecAttrAccount: keychainAccount,
411
- ]
412
- let attributes: [CFString: Any] = [kSecValueData: data]
413
- var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
414
- if status == errSecItemNotFound {
415
- var addQuery = query
416
- addQuery[kSecValueData] = data
417
- status = SecItemAdd(addQuery as CFDictionary, nil)
418
- }
419
- if status != errSecSuccess {
420
- NSLog("[Advenue] Keychain write for App Attest keyId failed: %d", status)
421
- }
422
- }
423
-
424
- /// Removes the persisted App Attest keyId so the next resolveKeyId generates a
425
- /// fresh key. Called when Apple reports the cached key as invalid.
426
- @available(iOS 14.0, *)
427
- private static func deleteKeyId() {
428
- let query: [CFString: Any] = [
429
- kSecClass: kSecClassGenericPassword,
430
- kSecAttrService: keychainService,
431
- kSecAttrAccount: keychainAccount,
432
- ]
433
- let status = SecItemDelete(query as CFDictionary)
434
- if status != errSecSuccess && status != errSecItemNotFound {
435
- NSLog("[Advenue] Keychain delete for App Attest keyId failed: %d", status)
436
- }
437
- }
438
-
439
- /// Resolves a DCAppAttest keyId: loads from Keychain if present, otherwise
440
- /// generates a new one via DCAppAttestService.generateKey and persists it.
441
- @available(iOS 14.0, *)
442
- private static func resolveKeyId(
443
- service: DCAppAttestService,
444
- completion: @escaping (Result<String, Error>) -> Void
445
- ) {
446
- if let cached = loadKeyId() {
447
- completion(.success(cached))
448
- return
449
- }
450
- service.generateKey { keyId, error in
451
- if let error = error {
452
- completion(.failure(error))
453
- return
454
- }
455
- guard let keyId = keyId else {
456
- completion(.failure(NSError(
457
- domain: "io.advenue.appattest",
458
- code: -1,
459
- userInfo: [NSLocalizedDescriptionKey: "generateKey returned nil keyId"]
460
- )))
461
- return
462
- }
463
- saveKeyId(keyId)
464
- completion(.success(keyId))
465
- }
466
- }
467
179
  }