@advenue/react-native 0.9.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 -368
  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 -675
  39. package/dist/index.d.cts +199 -336
  40. package/dist/index.d.ts +199 -336
  41. package/dist/index.js +182 -681
  42. package/ios/AdvenueIosModule.swift +144 -446
  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 +266 -830
  88. package/src/native-types.ts +74 -181
  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
@@ -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,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,65 @@
1
+ import Foundation
2
+
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
6
+
7
+ /// Device metadata for Meta CAPI `extinfo`.
8
+ ///
9
+ /// Best-effort per field: a field is **omitted**, never defaulted, when its
10
+ /// source is unavailable. A placeholder zero reaching the warehouse is
11
+ /// indistinguishable from a real measurement, which is worse than a gap — the
12
+ /// Android collector follows the same rule.
13
+ public func collectDeviceInfo() -> [String: AdvenueValue] {
14
+ var info: [String: AdvenueValue] = [:]
15
+
16
+ info["model"] = .string(hardwareModel())
17
+ info["locale"] = .string(Locale.current.identifier)
18
+ info["timezone"] = .string(TimeZone.current.identifier)
19
+ info["cpuCores"] = .int(ProcessInfo.processInfo.processorCount)
20
+
21
+ if let bundleId = Bundle.main.bundleIdentifier {
22
+ info["packageName"] = .string(bundleId)
23
+ }
24
+ if let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
25
+ info["shortVersion"] = .string(short)
26
+ }
27
+ if let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
28
+ info["longVersion"] = .string(build)
29
+ }
30
+
31
+ #if canImport(UIKit)
32
+ let screen = UIScreen.main.bounds
33
+ info["screenWidth"] = .int(Int(screen.width))
34
+ info["screenHeight"] = .int(Int(screen.height))
35
+ info["screenDensity"] = .double(Double(UIScreen.main.scale))
36
+ #endif
37
+
38
+ if let attributes = try? FileManager.default.attributesOfFileSystem(
39
+ forPath: NSHomeDirectory())
40
+ {
41
+ let gigabyte = 1024.0 * 1024.0 * 1024.0
42
+ if let total = attributes[.systemSize] as? NSNumber {
43
+ info["totalStorageGb"] = .double(total.doubleValue / gigabyte)
44
+ }
45
+ if let free = attributes[.systemFreeSize] as? NSNumber {
46
+ info["freeStorageGb"] = .double(free.doubleValue / gigabyte)
47
+ }
48
+ }
49
+
50
+ return info
51
+ }
52
+
53
+ /// The hardware identifier (`iPhone16,2`), which is what Meta's extinfo wants —
54
+ /// `UIDevice.model` answers "iPhone" for every iPhone ever made and is useless
55
+ /// for segmentation.
56
+ private func hardwareModel() -> String {
57
+ var systemInfo = utsname()
58
+ uname(&systemInfo)
59
+ let mirror = Mirror(reflecting: systemInfo.machine)
60
+ let identifier = mirror.children.reduce(into: "") { partial, element in
61
+ guard let value = element.value as? Int8, value != 0 else { return }
62
+ partial.append(Character(UnicodeScalar(UInt8(value))))
63
+ }
64
+ return identifier.isEmpty ? "unknown" : identifier
65
+ }
@@ -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
+ }
@@ -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
+ }