@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
- package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
- package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
- package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
- package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
- package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
- package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
- package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
- package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
- package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
- package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
- package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
- package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
- package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
- package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
- package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
- package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
- package/dist/index.cjs +183 -655
- package/dist/index.d.cts +199 -331
- package/dist/index.d.ts +199 -331
- package/dist/index.js +182 -661
- package/ios/AdvenueIosModule.swift +144 -432
- package/ios/vendor/Advenue/Advenue.swift +596 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
- package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
- package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
- package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
- package/ios/vendor/AdvenueCore/Consent.swift +40 -0
- package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
- package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
- package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
- package/ios/vendor/AdvenueCore/Engine.swift +587 -0
- package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
- package/ios/vendor/AdvenueCore/Limits.swift +41 -0
- package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
- package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
- package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
- package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
- package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
- package/ios/vendor/AdvenueCore/Transport.swift +14 -0
- package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
- package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
- package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
- package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
- package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
- package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
- package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
- package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
- package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
- package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
- package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
- package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
- package/package.json +9 -11
- package/scripts/check-dist.mjs +17 -0
- package/scripts/check-vendored-swift.mjs +148 -0
- package/scripts/vendor-natives.mjs +149 -0
- package/scripts/vendor-natives.test.mjs +112 -0
- package/src/deep-links.ts +19 -1
- package/src/index.ts +267 -803
- package/src/native-types.ts +74 -171
- package/src/native.ts +0 -23
- package/src/types.ts +81 -0
- package/src/aem.ts +0 -33
- package/src/mmkv-storage.ts +0 -21
- package/src/native-storage.ts +0 -63
- 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
|
+
}
|