@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +117 -368
- package/android/src/main/kotlin/io/advenue/Advenue.kt +574 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +141 -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 +193 -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 +436 -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 +50 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -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 +163 -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 +96 -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 +188 -675
- package/dist/index.d.cts +203 -336
- package/dist/index.d.ts +203 -336
- package/dist/index.js +187 -681
- package/ios/AdvenueIosModule.swift +143 -446
- package/ios/vendor/Advenue/Advenue.swift +632 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +89 -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 +607 -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/CacheFileStore.swift +90 -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 +77 -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 +155 -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 +13 -15
- 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 +273 -830
- package/src/native-types.ts +73 -181
- package/src/native.ts +0 -23
- package/src/types.ts +86 -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,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
|
+
}
|