@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,48 @@
1
+ import Foundation
2
+
3
+ /// Maps raw IAB TCF v2 CMP data to the DMA `Consent` shape, per Google's EU
4
+ /// User Consent Policy reading of the TCF purposes:
5
+ ///
6
+ /// - `ad_storage` ← Purpose 1
7
+ /// - `ad_user_data` ← Purposes 1 **and** 7
8
+ /// - `ad_personalization` ← Purposes 3 **and** 4
9
+ ///
10
+ /// Returns nil when `gdprApplies` is neither 0 nor 1. Absent consent must stay
11
+ /// absent: a guessed value is forwarded to ad networks as a real signal, and a
12
+ /// signal invented on a user's behalf is the one thing consent plumbing must
13
+ /// never do.
14
+ public func tcfToConsent(gdprApplies: Int?, purposeConsents: String?) -> Consent? {
15
+ guard gdprApplies == 0 || gdprApplies == 1 else { return nil }
16
+ if gdprApplies == 0 { return Consent(isUserSubjectToGDPR: false) }
17
+
18
+ let consents = Array(purposeConsents ?? "")
19
+ func purpose(_ n: Int) -> Bool { n <= consents.count && consents[n - 1] == "1" }
20
+
21
+ return Consent(
22
+ isUserSubjectToGDPR: true,
23
+ hasConsentForDataUsage: purpose(1) && purpose(7),
24
+ hasConsentForAdsPersonalization: purpose(3) && purpose(4),
25
+ hasConsentForAdStorage: purpose(1))
26
+ }
27
+
28
+ /// Meta AEM `campaign_ids`, extracted from an `al_applink_data` payload.
29
+ ///
30
+ /// The input is the ALREADY percent-decoded query value: decoding again would
31
+ /// corrupt a blob containing '%'. Meta does not publicly document the schema, so
32
+ /// both shapes seen in the wild are accepted, top level first.
33
+ ///
34
+ /// The blob is opaque, Meta-encrypted, non-user metadata and is returned
35
+ /// verbatim — never trimmed or re-encoded. The 2048 cap is URL hygiene; the
36
+ /// server re-validates, because an SDK-side cap is advisory at a trust boundary.
37
+ public func extractAemCampaignIds(_ alApplinkData: String) -> String? {
38
+ guard let data = alApplinkData.data(using: .utf8),
39
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
40
+ else { return nil }
41
+
42
+ let top = parsed["campaign_ids"] as? String
43
+ let nested = (parsed["extras"] as? [String: Any])?["campaign_ids"] as? String
44
+ guard let value = (top?.isEmpty == false ? top : nil) ?? (nested?.isEmpty == false ? nested : nil)
45
+ else { return nil }
46
+
47
+ return value.count <= 2048 ? value : nil
48
+ }
@@ -0,0 +1,14 @@
1
+ import Foundation
2
+
3
+ /// What the core needs from a transport, and no more.
4
+ ///
5
+ /// Declared here rather than in the platform layer so `AdvenueCore` keeps its
6
+ /// only-Foundation property while still owning the flush loop. The loop is
7
+ /// behaviour the `flush/` conformance vectors bind, so it cannot live above the
8
+ /// core: a rule the vectors cannot reach is a rule three implementations can
9
+ /// each interpret differently.
10
+ public protocol EventTransport: Sendable {
11
+ /// Delivers one batch. Throws `IngestError` on a non-2xx response, and on a
12
+ /// transport failure, which maps to 408.
13
+ func send(_ events: [ClientEvent]) async throws
14
+ }
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+
3
+ #if canImport(FirebaseAnalytics)
4
+ import FirebaseAnalytics
5
+ #endif
6
+
7
+ /// Supplies the Firebase Analytics app instance id.
8
+ ///
9
+ /// This lives in its own product for one reason: declaring FirebaseAnalytics
10
+ /// in the base target would force it on every consumer, including those who
11
+ /// never touch Firebase. The RN module gets away with `#if canImport` because
12
+ /// CocoaPods links the host app's pods together; under SPM `canImport` is
13
+ /// false unless the dependency is declared, so a separate product is the only
14
+ /// correct shape. Adjust draws the same base/plugin line.
15
+ public enum AdvenueFirebase {
16
+ /// Registers the provider with the SDK. Call after `Advenue.initialize`.
17
+ public static func register() {
18
+ Advenue.setAppInstanceIdProvider { await appInstanceId() }
19
+ }
20
+
21
+ /// Nil when Firebase is absent — the SDK carries on without the field
22
+ /// rather than failing, since an app instance id is an enrichment and not a
23
+ /// requirement for attribution.
24
+ public static func appInstanceId() async -> String? {
25
+ #if canImport(FirebaseAnalytics)
26
+ return await withCheckedContinuation { continuation in
27
+ Analytics.appInstanceID { id, _ in continuation.resume(returning: id) }
28
+ }
29
+ #else
30
+ return nil
31
+ #endif
32
+ }
33
+ }
@@ -0,0 +1,87 @@
1
+ import Foundation
2
+
3
+ #if canImport(AdSupport)
4
+ import AdSupport
5
+ #endif
6
+ #if canImport(AppTrackingTransparency)
7
+ import AppTrackingTransparency
8
+ #endif
9
+ #if canImport(UIKit)
10
+ import UIKit
11
+ #endif
12
+
13
+ public enum TrackingAuthorization: String, Sendable {
14
+ case notDetermined = "not_determined"
15
+ case restricted
16
+ case denied
17
+ case authorized
18
+ }
19
+
20
+ /// The all-zero IDFA. Apple returns it when tracking is not authorised, and it
21
+ /// is not an identifier — it must never travel as one, or every unauthorised
22
+ /// device looks like the same device.
23
+ public let ZERO_IDFA = "00000000-0000-0000-0000-000000000000"
24
+
25
+ /// ATT, IDFA and the vendor id.
26
+ ///
27
+ /// The SDK never prompts on its own: Apple gives the app control of the
28
+ /// timing, an early call silently answers `notDetermined`, and a prompt the
29
+ /// user did not expect is a review risk that is not the SDK's to take.
30
+ public struct AdvertisingIdentity: Sendable {
31
+ public init() {}
32
+
33
+ public var status: TrackingAuthorization {
34
+ #if canImport(AppTrackingTransparency)
35
+ switch ATTrackingManager.trackingAuthorizationStatus {
36
+ case .authorized: return .authorized
37
+ case .denied: return .denied
38
+ case .restricted: return .restricted
39
+ default: return .notDetermined
40
+ }
41
+ #else
42
+ return .notDetermined
43
+ #endif
44
+ }
45
+
46
+ /// The IDFA, or nil when it is unavailable or all zeros.
47
+ public var advertisingId: String? {
48
+ #if canImport(AdSupport)
49
+ guard status == .authorized else { return nil }
50
+ let id = ASIdentifierManager.shared().advertisingIdentifier.uuidString
51
+ return id == ZERO_IDFA ? nil : id
52
+ #else
53
+ return nil
54
+ #endif
55
+ }
56
+
57
+ /// Available without ATT because it is not an advertising identifier.
58
+ /// First-party fraud and dedup only — never cross-network attribution, which
59
+ /// would be "tracking" under Apple's policy.
60
+ public var vendorId: String? {
61
+ #if canImport(UIKit)
62
+ return UIDevice.current.identifierForVendor?.uuidString
63
+ #else
64
+ return nil
65
+ #endif
66
+ }
67
+
68
+ /// True when the user limited tracking. This is the field that separates
69
+ /// "denied" from "organic" on the server, so a false value here sends a
70
+ /// consent signal the user never gave.
71
+ public var limitAdTracking: Bool {
72
+ status == .denied || status == .restricted
73
+ }
74
+
75
+ /// Presents the system prompt. The app decides when.
76
+ public func requestAuthorization() async -> TrackingAuthorization {
77
+ #if canImport(AppTrackingTransparency)
78
+ return await withCheckedContinuation { continuation in
79
+ ATTrackingManager.requestTrackingAuthorization { _ in
80
+ continuation.resume(returning: self.status)
81
+ }
82
+ }
83
+ #else
84
+ return .notDetermined
85
+ #endif
86
+ }
87
+ }
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// B4: dosya destekli anahtar-değer deposu. Caches dizini iCloud/yedek
4
+ /// kapsamı dışındadır — kuyruk blob'u (reklam kimlikli olay gövdeleri)
5
+ /// yedek imajına sızmaz. Linux taşınabilirliği için yalnızca Foundation.
6
+ public struct CacheFileStore: KeyValueStore, @unchecked Sendable {
7
+ private let directory: URL
8
+ private let fileManager = FileManager.default
9
+
10
+ /// - Parameter directory: Kök dizin. Üretimde Caches/advenue kullanılır;
11
+ /// testler geçici dizin verir.
12
+ public init(directory: URL) {
13
+ self.directory = directory
14
+ }
15
+
16
+ public static func caches() -> CacheFileStore? {
17
+ guard
18
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
19
+ else { return nil }
20
+ let directory = base.appendingPathComponent("advenue", isDirectory: true)
21
+ try? FileManager.default.createDirectory(
22
+ at: directory, withIntermediateDirectories: true)
23
+ return CacheFileStore(directory: directory)
24
+ }
25
+
26
+ private func file(forKey key: String) -> URL {
27
+ // Anahtar dosya adı olur — boş/kök kaçışı engellenir.
28
+ let safe = key.replacingOccurrences(of: "/", with: "_")
29
+ return directory.appendingPathComponent(safe, isDirectory: false)
30
+ }
31
+
32
+ public func string(forKey key: String) -> String? {
33
+ try? String(contentsOf: file(forKey: key), encoding: .utf8)
34
+ }
35
+
36
+ public func set(_ value: String, forKey key: String) {
37
+ try? fileManager.createDirectory(
38
+ at: directory, withIntermediateDirectories: true)
39
+ try? value.write(to: file(forKey: key), atomically: true, encoding: .utf8)
40
+ }
41
+
42
+ public func removeObject(forKey key: String) {
43
+ try? fileManager.removeItem(at: file(forKey: key))
44
+ }
45
+ }
46
+
47
+ /// B4: bileşik depo — kuyruk blob'u dosyalara (yedek dışı), geri kalan
48
+ /// UserDefaults'a. Android `CompositeStore` ile aynı yönlendirme.
49
+ /// Eski UserDefaults anahtarı ilk okumada migrate edilir (RN uyumluluğu).
50
+ public struct CompositeStore: KeyValueStore, @unchecked Sendable {
51
+ private let defaults: UserDefaultsStore
52
+ private let files: CacheFileStore
53
+ private let fileKeys: Set<String>
54
+
55
+ public init(
56
+ defaults: UserDefaultsStore = UserDefaultsStore(),
57
+ files: CacheFileStore,
58
+ fileKeys: Set<String> = [QUEUE_KEY]
59
+ ) {
60
+ self.defaults = defaults
61
+ self.files = files
62
+ self.fileKeys = fileKeys
63
+ }
64
+
65
+ public func string(forKey key: String) -> String? {
66
+ guard fileKeys.contains(key) else { return defaults.string(forKey: key) }
67
+ if let hit = files.string(forKey: key) { return hit }
68
+ // Tek seferlik geriye dönük migrate: RN/önceki sürüm UserDefaults'ta bıraktı.
69
+ guard let legacy = defaults.string(forKey: key) else { return nil }
70
+ files.set(legacy, forKey: key)
71
+ defaults.removeObject(forKey: key)
72
+ return legacy
73
+ }
74
+
75
+ public func set(_ value: String, forKey key: String) {
76
+ if fileKeys.contains(key) {
77
+ files.set(value, forKey: key)
78
+ } else {
79
+ defaults.set(value, forKey: key)
80
+ }
81
+ }
82
+
83
+ public func removeObject(forKey key: String) {
84
+ if fileKeys.contains(key) {
85
+ files.removeObject(forKey: key)
86
+ } else {
87
+ defaults.removeObject(forKey: key)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,42 @@
1
+ import Foundation
2
+
3
+ /// Fetches the one-time App Attest challenge.
4
+ ///
5
+ /// Served by the **ingestion** host, not the dashboard API: the SDK already
6
+ /// talks to it and it is the service that will verify the attestation.
7
+ public struct HttpChallengeFetcher: ChallengeSource {
8
+ private let endpoint: String
9
+ private let apiKey: String
10
+ private let session: URLSession
11
+
12
+ public init(
13
+ endpoint: String = DEFAULT_ENDPOINT, apiKey: String, session: URLSession = .shared
14
+ ) {
15
+ self.endpoint = endpoint
16
+ self.apiKey = apiKey
17
+ self.session = session
18
+ }
19
+
20
+ public func challenge(deviceId: String) async throws -> String {
21
+ let escaped =
22
+ deviceId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deviceId
23
+ var request = URLRequest(
24
+ url: URL(string: "\(endpoint)/v1/attest/challenge?deviceId=\(escaped)")!)
25
+ request.httpMethod = "GET"
26
+ request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
27
+ // Matches the deadline the RN SDK uses. Attestation is enrichment: it never
28
+ // holds the install.
29
+ request.timeoutInterval = 5
30
+
31
+ let (payload, response) = try await session.data(for: request)
32
+ guard let http = response as? HTTPURLResponse,
33
+ (200..<300).contains(http.statusCode)
34
+ else { throw AttestationError.attestFailed }
35
+
36
+ guard let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
37
+ let challenge = body["challenge"] as? String, !challenge.isEmpty
38
+ else { throw AttestationError.badChallenge }
39
+
40
+ return challenge
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ import Foundation
2
+
3
+ /// Asks the server what this device was attributed to.
4
+ ///
5
+ /// A `pending` answer means the pipeline has not decided yet. Most devices are
6
+ /// organic and will answer pending forever, which is why the caller has a
7
+ /// budget rather than a loop.
8
+ public struct HttpConversionFetcher: ConversionFetcher {
9
+ private let endpoint: String
10
+ private let apiKey: String
11
+ private let deviceId: String
12
+ private let session: URLSession
13
+
14
+ public init(
15
+ endpoint: String = DEFAULT_API_ENDPOINT,
16
+ apiKey: String,
17
+ deviceId: String,
18
+ session: URLSession = .shared
19
+ ) {
20
+ self.endpoint = endpoint
21
+ self.apiKey = apiKey
22
+ self.deviceId = deviceId
23
+ self.session = session
24
+ }
25
+
26
+ public func fetch() async throws -> ConversionResult {
27
+ let escaped =
28
+ deviceId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? deviceId
29
+ var request = URLRequest(url: URL(string: "\(endpoint)/sdk/conversion-data?deviceId=\(escaped)")!)
30
+ request.httpMethod = "GET"
31
+ request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
32
+ request.timeoutInterval = 15
33
+
34
+ let payload: Data
35
+ let response: URLResponse
36
+ do {
37
+ (payload, response) = try await session.data(for: request)
38
+ } catch {
39
+ // No status to reason about; 408 marks it retryable, as sdk-core does.
40
+ throw IngestError(status: 408)
41
+ }
42
+ guard let http = response as? HTTPURLResponse else { throw IngestError(status: 408) }
43
+ guard (200..<300).contains(http.statusCode) else {
44
+ throw IngestError(status: http.statusCode)
45
+ }
46
+
47
+ guard let body = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else {
48
+ return .pending
49
+ }
50
+ if body["pending"] as? Bool == true { return .pending }
51
+
52
+ return .resolved(
53
+ DeepLink(
54
+ deepLinkValue: body["deepLinkValue"] as? String,
55
+ campaign: body["campaign"] as? String,
56
+ network: body["network"] as? String,
57
+ influencerId: body["influencerId"] as? String))
58
+ }
59
+ }
60
+
61
+ /// The dashboard API, which is where the conversion lookup lives — not the
62
+ /// ingest host.
63
+ public let DEFAULT_API_ENDPOINT = "https://api.advenue.io"
@@ -0,0 +1,19 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ /// Production HMAC-SHA256. `AdvenueCore` never imports CryptoKit — that is
5
+ /// what lets its conformance vectors build and run on Linux — so the
6
+ /// implementation is injected from here.
7
+ ///
8
+ /// This type and the swift-crypto one used by the core's tests are verified
9
+ /// against the SAME signing vectors, which is how their agreement is
10
+ /// established without compiling both into one binary.
11
+ public struct CryptoKitSigner: Signer {
12
+ public init() {}
13
+
14
+ public func hmacSHA256Hex(secret: String, message: String) -> String {
15
+ let key = SymmetricKey(data: Data(secret.utf8))
16
+ let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
17
+ return mac.map { String(format: "%02x", $0) }.joined()
18
+ }
19
+ }
@@ -0,0 +1,91 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ #if canImport(DeviceCheck)
5
+ import DeviceCheck
6
+ #endif
7
+
8
+ /// App Attest and DeviceCheck, ported from the React Native module.
9
+ ///
10
+ /// **Unavailable in every simulator.** `DCAppAttestService.isSupported` and
11
+ /// `DCDevice.isSupported` are both false there, so the tests here settle that we
12
+ /// degrade rather than crash and that the key cache behaves — not that Apple
13
+ /// produces a verifiable attestation. That is a device claim and it is on the
14
+ /// checklist.
15
+ public struct DeviceCheckAttestation: Attestation {
16
+ /// The cached key id. Apple charges for `generateKey`, and more importantly a
17
+ /// key can be attested only once, so the id has to outlive the call.
18
+ /// The account name the React Native module already uses. Matching it is not
19
+ /// cosmetic: an app upgrading onto this SDK would otherwise regenerate its
20
+ /// attest key, and the server has the old key id recorded as attested for
21
+ /// that device — a fresh one arriving reads as a new device or a
22
+ /// re-attestation. A key can be attested only once, so the waste is real too.
23
+ public static let KEY_ID_KEY = "app-attest-key-id"
24
+
25
+ private let secure: any SecureStore
26
+
27
+ public init(secure: any SecureStore) {
28
+ self.secure = secure
29
+ }
30
+
31
+ /// The server re-derives this hash from the same challenge, so these bytes
32
+ /// are a contract rather than an implementation detail.
33
+ public static func clientDataHash(challenge: String) throws -> Data {
34
+ guard let bytes = challenge.data(using: .utf8) else {
35
+ throw AttestationError.badChallenge
36
+ }
37
+ return Data(SHA256.hash(data: bytes))
38
+ }
39
+
40
+ public func attest(challenge: String) async throws -> AttestationResult {
41
+ #if canImport(DeviceCheck)
42
+ guard #available(iOS 14.0, *) else { throw AttestationError.unsupported }
43
+ let service = DCAppAttestService.shared
44
+ guard service.isSupported else { throw AttestationError.unsupported }
45
+
46
+ let hash = try Self.clientDataHash(challenge: challenge)
47
+ let keyId = try await resolveKeyId(service)
48
+
49
+ do {
50
+ let object = try await service.attestKey(keyId, clientDataHash: hash)
51
+ return AttestationResult(
52
+ keyId: keyId, attestationObject: object.base64EncodedString())
53
+ } catch {
54
+ // A key can be attested only once. When Apple calls a cached id
55
+ // invalid — it was already attested, or it went bad — that id is
56
+ // permanently dead. Dropping it makes the NEXT call generate a fresh
57
+ // key; keeping it makes every later attempt loop on a corpse.
58
+ if (error as? DCError)?.code == .invalidKey {
59
+ secure.delete(Self.KEY_ID_KEY)
60
+ }
61
+ throw AttestationError.attestFailed
62
+ }
63
+ #else
64
+ throw AttestationError.unsupported
65
+ #endif
66
+ }
67
+
68
+ public func deviceCheckToken() async throws -> String {
69
+ #if canImport(DeviceCheck)
70
+ guard DCDevice.current.isSupported else { throw AttestationError.unsupported }
71
+ let data = try await DCDevice.current.generateToken()
72
+ return data.base64EncodedString()
73
+ #else
74
+ throw AttestationError.unsupported
75
+ #endif
76
+ }
77
+
78
+ #if canImport(DeviceCheck)
79
+ @available(iOS 14.0, *)
80
+ private func resolveKeyId(_ service: DCAppAttestService) async throws -> String {
81
+ if case .found(let cached) = secure.read(Self.KEY_ID_KEY) { return cached }
82
+ do {
83
+ let generated = try await service.generateKey()
84
+ secure.write(generated, forKey: Self.KEY_ID_KEY)
85
+ return generated
86
+ } catch {
87
+ throw AttestationError.keyGeneration
88
+ }
89
+ }
90
+ #endif
91
+ }
@@ -0,0 +1,77 @@
1
+ import Foundation
2
+
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
6
+
7
+ /// The app's own version (`CFBundleShortVersionString`) for every event's
8
+ /// top-level `appVersion`. Resolved by the SDK, never taken from the app: the
9
+ /// React Native 0.x layer filled it this way, and 1.0 relying on the config
10
+ /// instead left every event unversioned (2026-09-19). Nil when the bundle has
11
+ /// no version.
12
+ public func readAppVersion() -> String? {
13
+ guard let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
14
+ !short.isEmpty
15
+ else { return nil }
16
+ return short
17
+ }
18
+
19
+ /// Device metadata for Meta CAPI `extinfo`.
20
+ ///
21
+ /// Best-effort per field: a field is **omitted**, never defaulted, when its
22
+ /// source is unavailable. A placeholder zero reaching the warehouse is
23
+ /// indistinguishable from a real measurement, which is worse than a gap — the
24
+ /// Android collector follows the same rule.
25
+ public func collectDeviceInfo() -> [String: AdvenueValue] {
26
+ var info: [String: AdvenueValue] = [:]
27
+
28
+ info["model"] = .string(hardwareModel())
29
+ info["locale"] = .string(Locale.current.identifier)
30
+ info["timezone"] = .string(TimeZone.current.identifier)
31
+ info["cpuCores"] = .int(ProcessInfo.processInfo.processorCount)
32
+
33
+ if let bundleId = Bundle.main.bundleIdentifier {
34
+ info["packageName"] = .string(bundleId)
35
+ }
36
+ if let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
37
+ info["shortVersion"] = .string(short)
38
+ }
39
+ if let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
40
+ info["longVersion"] = .string(build)
41
+ }
42
+
43
+ #if canImport(UIKit)
44
+ let screen = UIScreen.main.bounds
45
+ info["screenWidth"] = .int(Int(screen.width))
46
+ info["screenHeight"] = .int(Int(screen.height))
47
+ info["screenDensity"] = .double(Double(UIScreen.main.scale))
48
+ #endif
49
+
50
+ if let attributes = try? FileManager.default.attributesOfFileSystem(
51
+ forPath: NSHomeDirectory())
52
+ {
53
+ let gigabyte = 1024.0 * 1024.0 * 1024.0
54
+ if let total = attributes[.systemSize] as? NSNumber {
55
+ info["totalStorageGb"] = .double(total.doubleValue / gigabyte)
56
+ }
57
+ if let free = attributes[.systemFreeSize] as? NSNumber {
58
+ info["freeStorageGb"] = .double(free.doubleValue / gigabyte)
59
+ }
60
+ }
61
+
62
+ return info
63
+ }
64
+
65
+ /// The hardware identifier (`iPhone16,2`), which is what Meta's extinfo wants —
66
+ /// `UIDevice.model` answers "iPhone" for every iPhone ever made and is useless
67
+ /// for segmentation.
68
+ private func hardwareModel() -> String {
69
+ var systemInfo = utsname()
70
+ uname(&systemInfo)
71
+ let mirror = Mirror(reflecting: systemInfo.machine)
72
+ let identifier = mirror.children.reduce(into: "") { partial, element in
73
+ guard let value = element.value as? Int8, value != 0 else { return }
74
+ partial.append(Character(UnicodeScalar(UInt8(value))))
75
+ }
76
+ return identifier.isEmpty ? "unknown" : identifier
77
+ }
@@ -0,0 +1,55 @@
1
+ /// What UIKit told us about the app.
2
+ public enum LifecycleSignal: Sendable {
3
+ case didBecomeActive
4
+ /// Transient. Control Center, an incoming call, the app switcher preview —
5
+ /// none of which is a backgrounding, and all of which are followed by
6
+ /// `didBecomeActive` again.
7
+ case willResignActive
8
+ case didEnterBackground
9
+ }
10
+
11
+ public enum ForegroundTransition: Sendable, Equatable {
12
+ case none
13
+ case enteredForeground
14
+ case enteredBackground
15
+ }
16
+
17
+ /// The foreground/background decision, as a pure state machine — no UIKit type
18
+ /// appears in this file, which is what makes it provable without a device.
19
+ ///
20
+ /// It is deliberately simpler than the Android tracker, and the difference is
21
+ /// the platform rather than an omission. Android has to survive a rotation
22
+ /// recreating the activity, which walks a started-activity counter 1 → 0 → 1
23
+ /// and needs both a configuration-change check and a 700 ms debounce to avoid
24
+ /// emitting a phantom `session_end`. iOS recreates nothing, and
25
+ /// `didEnterBackground` is unambiguous, so pairing it against `didBecomeActive`
26
+ /// is the whole rule.
27
+ ///
28
+ /// What DOES bite on iOS is the transient interruption, and that is what
29
+ /// `willResignActive` is here to be ignored for. Reading it as a backgrounding
30
+ /// ends a session every time a notification banner appears.
31
+ public struct ForegroundTracker: Sendable {
32
+ private var inForeground: Bool
33
+
34
+ /// - Parameter inForeground: seed for an SDK that already opened its own
35
+ /// session at `initialize`, so the activation that follows does not open a
36
+ /// second one.
37
+ public init(inForeground: Bool = false) {
38
+ self.inForeground = inForeground
39
+ }
40
+
41
+ public mutating func on(_ signal: LifecycleSignal) -> ForegroundTransition {
42
+ switch signal {
43
+ case .willResignActive:
44
+ return .none
45
+ case .didBecomeActive:
46
+ if inForeground { return .none }
47
+ inForeground = true
48
+ return .enteredForeground
49
+ case .didEnterBackground:
50
+ if !inForeground { return .none }
51
+ inForeground = false
52
+ return .enteredBackground
53
+ }
54
+ }
55
+ }