@advenue/react-native 0.4.0 → 0.5.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.
- package/README.md +2 -2
- package/android/build.gradle +22 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +354 -0
- package/dist/index.d.ts +173 -8
- package/dist/index.js +6 -4
- package/expo-module.config.json +9 -0
- package/ios/AdvenueIos.podspec +20 -0
- package/ios/AdvenueIosModule.swift +461 -0
- package/package.json +8 -10
- package/src/deep-links.ts +5 -2
- package/src/index.ts +10 -3
- package/src/native-types.ts +169 -0
- package/src/native.ts +14 -8
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import StoreKit
|
|
3
|
+
import AdServices
|
|
4
|
+
import AdSupport
|
|
5
|
+
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.
|
|
17
|
+
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
|
+
public func definition() -> ModuleDefinition {
|
|
30
|
+
Name("AdvenueIos")
|
|
31
|
+
|
|
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) }
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
SKAdNetwork.updateConversionValue(fineValue)
|
|
85
|
+
promise.resolve(nil)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
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
|
+
|
|
103
|
+
// ATT consent prompt. The consumer app must declare
|
|
104
|
+
// NSUserTrackingUsageDescription in Info.plist, or iOS rejects the call.
|
|
105
|
+
AsyncFunction("requestTrackingAuthorization") { (promise: Promise) in
|
|
106
|
+
if #available(iOS 14, *) {
|
|
107
|
+
ATTrackingManager.requestTrackingAuthorization { status in
|
|
108
|
+
promise.resolve(Self.attStatusString(status))
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
promise.resolve("authorized")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
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
|
+
// Reads our clipboard match token (advmatch:<id>) written by the link
|
|
141
|
+
// redirector, for deterministic deferred deep linking. Returns ONLY our
|
|
142
|
+
// token, never arbitrary clipboard contents. (Reading the pasteboard shows
|
|
143
|
+
// the iOS paste notification — expected, user-visible, deterministic path.)
|
|
144
|
+
Function("getPasteboardMatchToken") { () -> String? in
|
|
145
|
+
return Self.onMainSync { () -> String? in
|
|
146
|
+
guard let s = UIPasteboard.general.string, s.hasPrefix("advmatch:") else { return nil }
|
|
147
|
+
return s
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// G3.3 — DeviceCheck token. Calls DCDevice.current.generateToken to obtain
|
|
152
|
+
// the opaque base64 token that identifies this physical device to the Apple
|
|
153
|
+
// DeviceCheck API. The server uses it (with the per-app DeviceCheck key) to
|
|
154
|
+
// query/update the persistent 2-bit per-device flag. Unlike App Attest, this
|
|
155
|
+
// requires no challenge/server round-trip — just generate and attach.
|
|
156
|
+
//
|
|
157
|
+
// Rejects with EUNSUPPORTED when DCDevice.current.isSupported is false (iOS
|
|
158
|
+
// simulator, Catalyst, etc.) so the JS layer can fail-safe and omit the token.
|
|
159
|
+
// IMPORTANT: NOT compile/on-device verified here; reviewed against the
|
|
160
|
+
// DCDevice API. The JS fail-safe in sdk-react-native ensures missing token
|
|
161
|
+
// never blocks attribution.
|
|
162
|
+
AsyncFunction("getDeviceCheckToken") { (promise: Promise) in
|
|
163
|
+
guard DCDevice.current.isSupported else {
|
|
164
|
+
promise.reject("EUNSUPPORTED", "DCDevice is not supported on this device")
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
DCDevice.current.generateToken { tokenData, error in
|
|
168
|
+
if let error = error {
|
|
169
|
+
promise.reject("EDEVICECHECK", error.localizedDescription)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
guard let tokenData = tokenData else {
|
|
173
|
+
promise.reject("EDEVICECHECK", "generateToken returned nil data")
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
promise.resolve(tokenData.base64EncodedString())
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// G3.2n — App Attest. Takes the server-supplied challenge string, computes
|
|
181
|
+
// clientDataHash = SHA-256(challenge UTF-8), generates (or reuses a
|
|
182
|
+
// keychain-cached) DCAppAttest key, and calls attestKey to obtain the
|
|
183
|
+
// CBOR attestation object. Resolves { keyId, attestationObject } where
|
|
184
|
+
// attestationObject is base64-encoded — pass directly to the server's
|
|
185
|
+
// verifyAppAttest endpoint. Rejects with a code on any failure so the JS
|
|
186
|
+
// layer can fail-safe (omit attestation rather than blocking the install).
|
|
187
|
+
// IMPORTANT: DCAppAttestService.isSupported is false on simulators; this
|
|
188
|
+
// causes the JS layer to skip attestation entirely (server sees 'unavailable').
|
|
189
|
+
AsyncFunction("attestKey") { (challenge: String, promise: Promise) in
|
|
190
|
+
guard #available(iOS 14.0, *) else {
|
|
191
|
+
promise.reject("EUNSUPPORTED", "App Attest requires iOS 14.0+")
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
let service = DCAppAttestService.shared
|
|
195
|
+
guard service.isSupported else {
|
|
196
|
+
promise.reject("EUNSUPPORTED", "DCAppAttestService is not supported on this device")
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Compute clientDataHash = SHA-256 of the UTF-8 challenge bytes.
|
|
201
|
+
guard let challengeData = challenge.data(using: .utf8) else {
|
|
202
|
+
promise.reject("EBADCHALLENGE", "Failed to encode challenge as UTF-8")
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
let clientDataHash = Data(SHA256.hash(data: challengeData))
|
|
206
|
+
|
|
207
|
+
// Load a previously generated key from Keychain, or generate a new one.
|
|
208
|
+
// Key is cached per-install/device so we only call generateKey once.
|
|
209
|
+
Self.resolveKeyId(service: service) { result in
|
|
210
|
+
switch result {
|
|
211
|
+
case .failure(let error):
|
|
212
|
+
promise.reject("EKEYID", error.localizedDescription)
|
|
213
|
+
case .success(let keyId):
|
|
214
|
+
service.attestKey(keyId, clientDataHash: clientDataHash) { attestationObject, error in
|
|
215
|
+
if let error = error {
|
|
216
|
+
// A key can be attested only once. If Apple rejects this keyId as
|
|
217
|
+
// invalid (e.g. it was already attested but the server never recorded
|
|
218
|
+
// it, or the key became invalid), the cached id is permanently
|
|
219
|
+
// unusable — drop it so the NEXT call generates a fresh key instead
|
|
220
|
+
// of looping on the dead id forever.
|
|
221
|
+
if (error as? DCError)?.code == .invalidKey {
|
|
222
|
+
Self.deleteKeyId()
|
|
223
|
+
}
|
|
224
|
+
promise.reject("EATTEST", error.localizedDescription)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
guard let attestationObject = attestationObject else {
|
|
228
|
+
promise.reject("EATTEST", "attestKey returned nil attestation object")
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
let base64Attest = attestationObject.base64EncodedString()
|
|
232
|
+
promise.resolve(["keyId": keyId, "attestationObject": base64Attest])
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Device metadata for Meta CAPI `extinfo` (Task 3n). Synchronous — every
|
|
239
|
+
// API used here is a cheap local read (no network I/O). Best-effort per
|
|
240
|
+
// field: a field is simply omitted when its source is unavailable, so the
|
|
241
|
+
// JS-side fail-safe (collectDeviceInfo) degrades gracefully rather than
|
|
242
|
+
// sending misleading placeholder values.
|
|
243
|
+
// IMPORTANT: NOT compile/on-device verified here; reviewed against the
|
|
244
|
+
// UIKit/Foundation APIs. The JS fail-safe in sdk-react-native ensures a
|
|
245
|
+
// missing/partial result never blocks attribution.
|
|
246
|
+
Function("getDeviceInfo") { () -> [String: Any] in
|
|
247
|
+
var info: [String: Any] = [:]
|
|
248
|
+
|
|
249
|
+
// Hardware model identifier (e.g. "iPhone16,1") via utsname, not the
|
|
250
|
+
// marketing name — matches the Android side's Build.MODEL granularity.
|
|
251
|
+
var systemInfo = utsname()
|
|
252
|
+
uname(&systemInfo)
|
|
253
|
+
let model = withUnsafePointer(to: &systemInfo.machine) {
|
|
254
|
+
$0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in
|
|
255
|
+
String(cString: ptr)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
info["model"] = model
|
|
259
|
+
info["locale"] = Locale.current.identifier
|
|
260
|
+
info["timezone"] = TimeZone.current.identifier
|
|
261
|
+
info["cpuCores"] = ProcessInfo.processInfo.activeProcessorCount
|
|
262
|
+
|
|
263
|
+
let (screenWidth, screenHeight, screenDensity) = Self.onMainSync { () -> (Int, Int, Double) in
|
|
264
|
+
let screen = UIScreen.main
|
|
265
|
+
return (
|
|
266
|
+
Int(screen.bounds.width * screen.scale),
|
|
267
|
+
Int(screen.bounds.height * screen.scale),
|
|
268
|
+
Double(screen.scale)
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
info["screenWidth"] = screenWidth
|
|
272
|
+
info["screenHeight"] = screenHeight
|
|
273
|
+
info["screenDensity"] = screenDensity
|
|
274
|
+
|
|
275
|
+
if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) {
|
|
276
|
+
let gb = 1024.0 * 1024.0 * 1024.0
|
|
277
|
+
if let total = attrs[.systemSize] as? NSNumber {
|
|
278
|
+
info["totalStorageGb"] = total.doubleValue / gb
|
|
279
|
+
}
|
|
280
|
+
if let free = attrs[.systemFreeSize] as? NSNumber {
|
|
281
|
+
info["freeStorageGb"] = free.doubleValue / gb
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return info
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// MARK: - Built-in SDK storage (Adjust-style: the app supplies nothing)
|
|
289
|
+
|
|
290
|
+
// Sync key/value storage backing the SDK's offline buffer + state. A
|
|
291
|
+
// dedicated UserDefaults suite keeps SDK keys out of the app's defaults.
|
|
292
|
+
Function("storageGetItem") { (key: String) -> String? in
|
|
293
|
+
Self.sdkDefaults.string(forKey: key)
|
|
294
|
+
}
|
|
295
|
+
Function("storageSetItem") { (key: String, value: String) in
|
|
296
|
+
Self.sdkDefaults.set(value, forKey: key)
|
|
297
|
+
}
|
|
298
|
+
Function("storageRemoveItem") { (key: String) in
|
|
299
|
+
Self.sdkDefaults.removeObject(forKey: key)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Keychain-backed secure storage for the reinstall-durable identifiers
|
|
303
|
+
// (device id, install guard). kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
|
|
304
|
+
// survives app reinstall, readable after first unlock (background events),
|
|
305
|
+
// and never migrates via device-to-device backup restore — a restored
|
|
306
|
+
// backup must NOT clone another device's attribution identity.
|
|
307
|
+
Function("secureGetItem") { (key: String) -> String? in
|
|
308
|
+
Self.keychainGet(key)
|
|
309
|
+
}
|
|
310
|
+
Function("secureSetItem") { (key: String, value: String) in
|
|
311
|
+
Self.keychainSet(key, value)
|
|
312
|
+
}
|
|
313
|
+
Function("secureRemoveItem") { (key: String) in
|
|
314
|
+
Self.keychainRemove(key)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// MARK: - Built-in storage helpers
|
|
319
|
+
|
|
320
|
+
private static let sdkDefaults: UserDefaults =
|
|
321
|
+
UserDefaults(suiteName: "io.advenue.sdk") ?? .standard
|
|
322
|
+
|
|
323
|
+
// Shares `keychainService` with the App Attest helpers below — same service,
|
|
324
|
+
// distinct accounts (storage keys vs "app-attest-key-id"), so neither can
|
|
325
|
+
// read or delete the other's entries.
|
|
326
|
+
private static func keychainBaseQuery(_ key: String) -> [String: Any] {
|
|
327
|
+
[
|
|
328
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
329
|
+
kSecAttrService as String: keychainService,
|
|
330
|
+
kSecAttrAccount as String: key,
|
|
331
|
+
]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private static func keychainGet(_ key: String) -> String? {
|
|
335
|
+
var query = keychainBaseQuery(key)
|
|
336
|
+
query[kSecReturnData as String] = true
|
|
337
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
338
|
+
var result: AnyObject?
|
|
339
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
340
|
+
guard status == errSecSuccess, let data = result as? Data else {
|
|
341
|
+
return nil
|
|
342
|
+
}
|
|
343
|
+
return String(data: data, encoding: .utf8)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private static func keychainSet(_ key: String, _ value: String) {
|
|
347
|
+
guard let data = value.data(using: .utf8) else { return }
|
|
348
|
+
let base = keychainBaseQuery(key)
|
|
349
|
+
let update: [String: Any] = [kSecValueData as String: data]
|
|
350
|
+
let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
|
|
351
|
+
if status == errSecItemNotFound {
|
|
352
|
+
var add = base
|
|
353
|
+
add[kSecValueData as String] = data
|
|
354
|
+
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
355
|
+
SecItemAdd(add as CFDictionary, nil)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private static func keychainRemove(_ key: String) {
|
|
360
|
+
SecItemDelete(keychainBaseQuery(key) as CFDictionary)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@available(iOS 14, *)
|
|
364
|
+
private static func attStatusString(_ status: ATTrackingManager.AuthorizationStatus) -> String {
|
|
365
|
+
switch status {
|
|
366
|
+
case .authorized: return "authorized"
|
|
367
|
+
case .denied: return "denied"
|
|
368
|
+
case .restricted: return "restricted"
|
|
369
|
+
default: return "notDetermined"
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// MARK: - App Attest Keychain helpers
|
|
374
|
+
|
|
375
|
+
private static let keychainService = "io.advenue.sdk"
|
|
376
|
+
private static let keychainAccount = "app-attest-key-id"
|
|
377
|
+
|
|
378
|
+
/// Loads the persisted App Attest keyId from the Keychain. Returns nil when
|
|
379
|
+
/// no key has been generated yet; the caller must then call generateKey and
|
|
380
|
+
/// persist the result via saveKeyId(_:).
|
|
381
|
+
@available(iOS 14.0, *)
|
|
382
|
+
private static func loadKeyId() -> String? {
|
|
383
|
+
let query: [CFString: Any] = [
|
|
384
|
+
kSecClass: kSecClassGenericPassword,
|
|
385
|
+
kSecAttrService: keychainService,
|
|
386
|
+
kSecAttrAccount: keychainAccount,
|
|
387
|
+
kSecReturnData: true,
|
|
388
|
+
kSecMatchLimit: kSecMatchLimitOne,
|
|
389
|
+
]
|
|
390
|
+
var item: CFTypeRef?
|
|
391
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
392
|
+
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
|
393
|
+
return String(data: data, encoding: .utf8)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Persists the App Attest keyId to the Keychain, replacing any prior value.
|
|
397
|
+
@available(iOS 14.0, *)
|
|
398
|
+
private static func saveKeyId(_ keyId: String) {
|
|
399
|
+
guard let data = keyId.data(using: .utf8) else { return }
|
|
400
|
+
// Try to update an existing item first; fall back to adding a new one.
|
|
401
|
+
let query: [CFString: Any] = [
|
|
402
|
+
kSecClass: kSecClassGenericPassword,
|
|
403
|
+
kSecAttrService: keychainService,
|
|
404
|
+
kSecAttrAccount: keychainAccount,
|
|
405
|
+
]
|
|
406
|
+
let attributes: [CFString: Any] = [kSecValueData: data]
|
|
407
|
+
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
408
|
+
if status == errSecItemNotFound {
|
|
409
|
+
var addQuery = query
|
|
410
|
+
addQuery[kSecValueData] = data
|
|
411
|
+
status = SecItemAdd(addQuery as CFDictionary, nil)
|
|
412
|
+
}
|
|
413
|
+
if status != errSecSuccess {
|
|
414
|
+
NSLog("[Advenue] Keychain write for App Attest keyId failed: %d", status)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/// Removes the persisted App Attest keyId so the next resolveKeyId generates a
|
|
419
|
+
/// fresh key. Called when Apple reports the cached key as invalid.
|
|
420
|
+
@available(iOS 14.0, *)
|
|
421
|
+
private static func deleteKeyId() {
|
|
422
|
+
let query: [CFString: Any] = [
|
|
423
|
+
kSecClass: kSecClassGenericPassword,
|
|
424
|
+
kSecAttrService: keychainService,
|
|
425
|
+
kSecAttrAccount: keychainAccount,
|
|
426
|
+
]
|
|
427
|
+
let status = SecItemDelete(query as CFDictionary)
|
|
428
|
+
if status != errSecSuccess && status != errSecItemNotFound {
|
|
429
|
+
NSLog("[Advenue] Keychain delete for App Attest keyId failed: %d", status)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/// Resolves a DCAppAttest keyId: loads from Keychain if present, otherwise
|
|
434
|
+
/// generates a new one via DCAppAttestService.generateKey and persists it.
|
|
435
|
+
@available(iOS 14.0, *)
|
|
436
|
+
private static func resolveKeyId(
|
|
437
|
+
service: DCAppAttestService,
|
|
438
|
+
completion: @escaping (Result<String, Error>) -> Void
|
|
439
|
+
) {
|
|
440
|
+
if let cached = loadKeyId() {
|
|
441
|
+
completion(.success(cached))
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
service.generateKey { keyId, error in
|
|
445
|
+
if let error = error {
|
|
446
|
+
completion(.failure(error))
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
guard let keyId = keyId else {
|
|
450
|
+
completion(.failure(NSError(
|
|
451
|
+
domain: "io.advenue.appattest",
|
|
452
|
+
code: -1,
|
|
453
|
+
userInfo: [NSLocalizedDescriptionKey: "generateKey returned nil keyId"]
|
|
454
|
+
)))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
saveKeyId(keyId)
|
|
458
|
+
completion(.success(keyId))
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@advenue/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Advenue MMP React Native SDK — attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"main": "./dist/index.js",
|
|
@@ -16,6 +17,9 @@
|
|
|
16
17
|
"dist",
|
|
17
18
|
"src",
|
|
18
19
|
"plugin",
|
|
20
|
+
"ios",
|
|
21
|
+
"android",
|
|
22
|
+
"expo-module.config.json",
|
|
19
23
|
"!**/*.test.ts"
|
|
20
24
|
],
|
|
21
25
|
"publishConfig": {
|
|
@@ -26,20 +30,16 @@
|
|
|
26
30
|
"@advenue/sdk-core": "0.3.0"
|
|
27
31
|
},
|
|
28
32
|
"peerDependencies": {
|
|
29
|
-
"@advenue/sdk-android": "*",
|
|
30
|
-
"@advenue/sdk-ios": "*",
|
|
31
33
|
"@expo/config-plugins": ">=8",
|
|
32
34
|
"react": ">=18.2",
|
|
33
|
-
"react-native": ">=0.74"
|
|
35
|
+
"react-native": ">=0.74",
|
|
36
|
+
"expo-modules-core": "*"
|
|
34
37
|
},
|
|
35
38
|
"peerDependenciesMeta": {
|
|
36
39
|
"@expo/config-plugins": {
|
|
37
40
|
"optional": true
|
|
38
41
|
},
|
|
39
|
-
"
|
|
40
|
-
"optional": true
|
|
41
|
-
},
|
|
42
|
-
"@advenue/sdk-ios": {
|
|
42
|
+
"expo-modules-core": {
|
|
43
43
|
"optional": true
|
|
44
44
|
}
|
|
45
45
|
},
|
|
@@ -47,8 +47,6 @@
|
|
|
47
47
|
"tsup": "^8.5.1",
|
|
48
48
|
"typescript": "^5.7.2",
|
|
49
49
|
"vitest": "^2.1.8",
|
|
50
|
-
"@advenue/sdk-android": "0.3.0",
|
|
51
|
-
"@advenue/sdk-ios": "0.3.0",
|
|
52
50
|
"@advenue/shared": "0.1.0",
|
|
53
51
|
"@advenue/signing": "0.0.0"
|
|
54
52
|
},
|
package/src/deep-links.ts
CHANGED
|
@@ -28,10 +28,13 @@ export function parseDirectLink(url: string): DeepLink {
|
|
|
28
28
|
const isHttp = parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
29
29
|
deepLinkValue = isHttp ? path : [parsed.host, path].filter(Boolean).join('/');
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// forEach, not entries(): the iterable protocol types live in
|
|
32
|
+
// lib.dom.iterable / @types/node, neither of which is guaranteed in the
|
|
33
|
+
// dts build's lib set — forEach is in the base lib everywhere.
|
|
34
|
+
parsed.searchParams.forEach((v, k) => {
|
|
32
35
|
params ??= {};
|
|
33
36
|
params[k] = v;
|
|
34
|
-
}
|
|
37
|
+
});
|
|
35
38
|
} catch {
|
|
36
39
|
// Unparseable URL — surface the raw value so the app can still route.
|
|
37
40
|
deepLinkValue = url;
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
type TrackOptions,
|
|
18
18
|
resolveDurableDeviceId,
|
|
19
19
|
} from '@advenue/sdk-core';
|
|
20
|
-
import type { TrackingAuthorizationStatus } from '@advenue/sdk-ios';
|
|
21
20
|
import { parseMatchToken } from '@advenue/shared/match-token';
|
|
22
21
|
import { sha256Hex } from '@advenue/signing';
|
|
23
22
|
import { type DeepLinkHandle, setupDeepLinks } from './deep-links';
|
|
@@ -31,6 +30,7 @@ import {
|
|
|
31
30
|
getPlatformOS,
|
|
32
31
|
} from './native';
|
|
33
32
|
import { createNativeSecureStore, createNativeStorage } from './native-storage';
|
|
33
|
+
import type { TrackingAuthorizationStatus } from './native-types';
|
|
34
34
|
import { type SecureStoreLike, createSecureStore } from './secure-store';
|
|
35
35
|
|
|
36
36
|
export { createMMKVStorage } from './mmkv-storage';
|
|
@@ -40,7 +40,14 @@ export type { SecureStoreLike } from './secure-store';
|
|
|
40
40
|
export { parseDirectLink } from './deep-links';
|
|
41
41
|
export type { AdvenueConfig, Consent, DeepLink, TrackOptions } from '@advenue/sdk-core';
|
|
42
42
|
export { getAndroidNative, getIosNative } from './native';
|
|
43
|
-
export type {
|
|
43
|
+
export type {
|
|
44
|
+
AdvenueAndroidNativeModule,
|
|
45
|
+
AdvenueDeviceInfo,
|
|
46
|
+
AdvenueIosNativeModule,
|
|
47
|
+
InstallReferrerResult,
|
|
48
|
+
MetaInstallReferrerResult,
|
|
49
|
+
TrackingAuthorizationStatus,
|
|
50
|
+
} from './native-types';
|
|
44
51
|
|
|
45
52
|
/**
|
|
46
53
|
* Resolves the device advertising id (IDFA on iOS, GAID on Android), gates it
|
|
@@ -189,7 +196,7 @@ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platfo
|
|
|
189
196
|
/**
|
|
190
197
|
* G3.1n — Play Integrity (Android) / App Attest (iOS) attestation.
|
|
191
198
|
*
|
|
192
|
-
* Your app's Advenue appId (the UUID
|
|
199
|
+
* Your app's Advenue appId (the UUID in Settings → API Keys). When set on
|
|
193
200
|
* Android, the automatic install obtains a Play Integrity Standard token and
|
|
194
201
|
* attaches it to the install event. The requestHash is computed as
|
|
195
202
|
* `sha256Hex("<appId>:<deviceId>")`, matching the server's verification
|