rn-linkrunner 2.5.2 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/LinkrunnerSDK.podspec +1 -1
  2. package/ios/LinkrunnerSDK.swift +2 -2
  3. package/ios/Podfile +1 -1
  4. package/ios/Pods/LinkrunnerKit/LICENSE +21 -0
  5. package/ios/Pods/LinkrunnerKit/README.md +15 -0
  6. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/HmacSignatureGenerator.swift +95 -0
  7. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/Linkrunner.swift +1109 -0
  8. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/Models.swift +356 -0
  9. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/RequestSigningInterceptor.swift +110 -0
  10. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/SHA256.swift +12 -0
  11. package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/SKAdNetworkService.swift +428 -0
  12. package/ios/Pods/Pods.xcodeproj/project.pbxproj +440 -0
  13. package/ios/Pods/Pods.xcodeproj/xcuserdata/shofiyabootwala.xcuserdatad/xcschemes/LinkrunnerKit.xcscheme +58 -0
  14. package/ios/Pods/Pods.xcodeproj/xcuserdata/shofiyabootwala.xcuserdatad/xcschemes/xcschememanagement.plist +16 -0
  15. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-Info.plist +26 -0
  16. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-dummy.m +5 -0
  17. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-prefix.pch +12 -0
  18. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-umbrella.h +16 -0
  19. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.debug.xcconfig +17 -0
  20. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.modulemap +6 -0
  21. package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.release.xcconfig +17 -0
  22. package/package.json +1 -1
@@ -0,0 +1,1109 @@
1
+ import Foundation
2
+
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
6
+
7
+ #if canImport(AdSupport)
8
+ import AdSupport
9
+ #endif
10
+
11
+ #if canImport(AppTrackingTransparency)
12
+ import AppTrackingTransparency
13
+ #endif
14
+
15
+ #if canImport(Network)
16
+ import Network
17
+ #endif
18
+
19
+ @available(iOS 15.0, *)
20
+ public class LinkrunnerSDK: @unchecked Sendable {
21
+ // Configuration options
22
+ private var hashPII: Bool = false
23
+ private var disableIdfa: Bool = false
24
+ private var debug: Bool = false
25
+
26
+ // Define a Sendable device data structure
27
+ private struct DeviceData: Sendable {
28
+ var device: String
29
+ var deviceName: String
30
+ var systemVersion: String
31
+ var brand: String
32
+ var manufacturer: String
33
+ var bundleId: String?
34
+ var appVersion: String?
35
+ var buildNumber: String?
36
+ var connectivity: String
37
+ var deviceDisplay: DisplayData
38
+ var idfa: String?
39
+ var idfv: String?
40
+ var locale: String?
41
+ var language: String?
42
+ var country: String?
43
+ var timezone: String?
44
+ var timezoneOffset: Int?
45
+ var userAgent: String?
46
+ var installInstanceId: String
47
+
48
+ struct DisplayData: Sendable {
49
+ var width: Double
50
+ var height: Double
51
+ var scale: Double
52
+ }
53
+
54
+ // Convert to dictionary for network requests
55
+ func toDictionary() -> SendableDictionary {
56
+ var dict: SendableDictionary = [
57
+ "device": device,
58
+ "device_name": deviceName,
59
+ "system_version": systemVersion,
60
+ "brand": brand,
61
+ "manufacturer": manufacturer,
62
+ "connectivity": connectivity,
63
+ "device_display": [
64
+ "width": deviceDisplay.width,
65
+ "height": deviceDisplay.height,
66
+ "scale": deviceDisplay.scale
67
+ ] as [String: Any],
68
+ "install_instance_id": installInstanceId
69
+ ]
70
+
71
+ if let bundleId = bundleId { dict["bundle_id"] = bundleId }
72
+ if let appVersion = appVersion { dict["version"] = appVersion }
73
+ if let buildNumber = buildNumber { dict["build_number"] = buildNumber }
74
+ if let idfa = idfa { dict["idfa"] = idfa }
75
+ if let idfv = idfv { dict["idfv"] = idfv }
76
+ if let locale = locale { dict["locale"] = locale }
77
+ if let language = language { dict["language"] = language }
78
+ if let country = country { dict["country"] = country }
79
+ if let timezone = timezone { dict["timezone"] = timezone }
80
+ if let timezoneOffset = timezoneOffset { dict["timezone_offset"] = timezoneOffset }
81
+ if let userAgent = userAgent { dict["user_agent"] = userAgent }
82
+
83
+ return dict
84
+ }
85
+ }
86
+ // Network monitoring properties
87
+ #if canImport(Network)
88
+ private var networkMonitor: NWPathMonitor?
89
+ private var currentConnectionType: String?
90
+ #endif
91
+ public static let shared = LinkrunnerSDK()
92
+
93
+ private var token: String?
94
+ private var secretKey: String?
95
+ private var keyId: String?
96
+
97
+ // Time tracking for SKAN
98
+ private var appInstallTime: Date?
99
+
100
+ // Request signing configuration
101
+ private let requestInterceptor = RequestSigningInterceptor()
102
+ private let baseUrl = "https://api.linkrunner.io"
103
+
104
+
105
+ #if canImport(Network)
106
+ private func setupNetworkMonitoring() {
107
+ networkMonitor = NWPathMonitor()
108
+ let queue = DispatchQueue(label: "NetworkMonitoring")
109
+
110
+ // Initialize the connection type before starting the monitor
111
+ self.currentConnectionType = "unknown"
112
+
113
+ networkMonitor?.pathUpdateHandler = { [weak self] path in
114
+ // Only check interface type when status is satisfied to avoid warnings
115
+ if path.status == .satisfied {
116
+ // Use a local variable to determine the connection type
117
+ let connectionType: String
118
+
119
+ // Simply check the interface type without accessing endpoints
120
+ if path.usesInterfaceType(.wifi) {
121
+ connectionType = "wifi"
122
+ } else if path.usesInterfaceType(.cellular) {
123
+ connectionType = "cellular"
124
+ } else if path.usesInterfaceType(.wiredEthernet) {
125
+ connectionType = "ethernet"
126
+ } else {
127
+ connectionType = "other"
128
+ }
129
+
130
+ // Update the connection type on the main object
131
+ self?.currentConnectionType = connectionType
132
+ } else {
133
+ self?.currentConnectionType = "disconnected"
134
+ }
135
+ }
136
+
137
+ networkMonitor?.start(queue: queue)
138
+ }
139
+ #endif
140
+
141
+ private init() {
142
+ #if canImport(Network)
143
+ setupNetworkMonitoring()
144
+ #endif
145
+ }
146
+
147
+ // MARK: - Public Methods
148
+
149
+ /// Configure request signing using raw key data
150
+ /// - Parameters:
151
+ /// - secretKey: Secret key for HMAC signing
152
+ /// - keyId: Key identifier for HMAC signing
153
+ public func configureRequestSigning(secretKey: String, keyId: String) {
154
+ requestInterceptor.configure(secretKey: secretKey, keyId: keyId)
155
+ }
156
+
157
+ /// Reset request signing configuration
158
+ public func resetRequestSigning() {
159
+ requestInterceptor.reset()
160
+ }
161
+
162
+ /// Initialize the Linkrunner SDK with your project token
163
+ /// - Parameter token: Your Linkrunner project token
164
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
165
+ public func initialize(token: String, secretKey: String? = nil, keyId: String? = nil, disableIdfa: Bool? = false, debug: Bool? = false) async {
166
+ self.token = token
167
+ self.disableIdfa = disableIdfa ?? false
168
+ self.debug = debug ?? false
169
+
170
+ // Set app install time on first initialization
171
+ if appInstallTime == nil {
172
+ appInstallTime = getAppInstallTime()
173
+
174
+ // Initialize SKAN with default values (0/low) on first init
175
+ await SKAdNetworkService.shared.registerInitialConversionValue()
176
+ }
177
+
178
+ // Only set secretKey and keyId when they are provided
179
+ if let secretKey = secretKey, let keyId = keyId, !secretKey.isEmpty, !keyId.isEmpty {
180
+ self.secretKey = secretKey
181
+ self.keyId = keyId
182
+
183
+ // Configure request signing only when both secretKey and keyId are provided
184
+ configureRequestSigning(secretKey: secretKey, keyId: keyId)
185
+ }
186
+ await initApiCall(token: token, source: "GENERAL", debug: debug)
187
+ }
188
+
189
+ /// Enables or disables hashing of personally identifiable information (PII)
190
+ /// - Parameter enabled: Whether PII hashing should be enabled
191
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
192
+ public func enablePIIHashing(_ enabled: Bool = true) {
193
+ self.hashPII = enabled
194
+ }
195
+
196
+ /// Returns whether PII hashing is currently enabled
197
+ /// - Returns: Boolean indicating if PII hashing is enabled
198
+ public func isPIIHashingEnabled() -> Bool {
199
+ return self.hashPII
200
+ }
201
+
202
+ /// Hashes a string using SHA-256 algorithm
203
+ /// - Parameter input: The string to hash
204
+ /// - Returns: Hashed string in hexadecimal format
205
+ public func hashWithSHA256(_ input: String) -> String {
206
+ let inputData = Data(input.utf8)
207
+ let hashedData = SHA256.hash(data: inputData)
208
+ let hashString = hashedData.compactMap { String(format: "%02x", $0) }.joined()
209
+ return hashString
210
+ }
211
+
212
+ /// Register a user signup with Linkrunner
213
+ /// - Parameter userData: User data to register
214
+ /// - Parameter additionalData: Any additional data to include
215
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
216
+ public func signup(userData: UserData, additionalData: SendableDictionary? = nil) async {
217
+ guard let token = self.token else {
218
+ #if DEBUG
219
+ print("Linkrunner: Signup failed - SDK not initialized")
220
+ #endif
221
+ return
222
+ }
223
+
224
+ var requestData: SendableDictionary = [
225
+ "token": token,
226
+ "user_data": userData.toDictionary(hashPII: self.hashPII),
227
+ "platform": "IOS",
228
+ "install_instance_id": await getLinkRunnerInstallInstanceId(),
229
+ "time_since_app_install": getTimeSinceAppInstall()
230
+ ]
231
+
232
+ var dataDict: SendableDictionary = additionalData ?? [:]
233
+ dataDict["device_data"] = (await deviceData()).toDictionary()
234
+ requestData["data"] = dataDict
235
+
236
+ do {
237
+ let response = try await makeRequest(
238
+ endpoint: "/api/client/trigger",
239
+ body: requestData
240
+ )
241
+
242
+ // Process SKAN conversion values from response in background
243
+ await processSKANResponse(response, source: "signup")
244
+
245
+ } catch {
246
+ #if DEBUG
247
+ print("Linkrunner: Signup failed with error: \(error)")
248
+ #endif
249
+ }
250
+ }
251
+
252
+ /// Set user data in Linkrunner
253
+ /// - Parameter userData: User data to set
254
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
255
+ public func setUserData(_ userData: UserData) async {
256
+ guard let token = self.token else {
257
+ #if DEBUG
258
+ print("Linkrunner: setUserData failed - SDK not initialized")
259
+ #endif
260
+ return
261
+ }
262
+
263
+ let requestData: SendableDictionary = [
264
+ "token": token,
265
+ "user_data": userData.toDictionary(hashPII: self.hashPII),
266
+ "device_data": (await deviceData()).toDictionary(),
267
+ "install_instance_id": await getLinkRunnerInstallInstanceId()
268
+ ]
269
+
270
+ do {
271
+ _ = try await makeRequest(
272
+ endpoint: "/api/client/set-user-data",
273
+ body: requestData
274
+ )
275
+ } catch {
276
+ #if DEBUG
277
+ print("Linkrunner: setUserData failed with error: \(error)")
278
+ #endif
279
+ }
280
+ }
281
+
282
+ /// Set additional integration data
283
+ /// - Parameter integrationData: The integration data to set
284
+ /// - Returns: The response from the server, if any
285
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
286
+ public func setAdditionalData(_ integrationData: IntegrationData) async {
287
+ guard let token = self.token else {
288
+ #if DEBUG
289
+ print("Linkrunner: setAdditionalData failed - SDK not initialized")
290
+ #endif
291
+ return
292
+ }
293
+
294
+ let integrationDict = integrationData.toDictionary()
295
+ if integrationDict.isEmpty {
296
+ #if DEBUG
297
+ print("Linkrunner: setAdditionalData failed - Integration data is required")
298
+ #endif
299
+ return
300
+ }
301
+
302
+ let installInstanceId = await getLinkRunnerInstallInstanceId()
303
+ let requestData: SendableDictionary = [
304
+ "token": token,
305
+ "install_instance_id": installInstanceId,
306
+ "integration_info": integrationDict,
307
+ "platform": "IOS"
308
+ ]
309
+
310
+ do {
311
+ let response = try await makeRequest(
312
+ endpoint: "/api/client/integrations",
313
+ body: requestData
314
+ )
315
+
316
+ guard let status = response["status"] as? Int, (status == 200 || status == 201) else {
317
+ let msg = response["msg"] as? String ?? "Unknown error"
318
+ #if DEBUG
319
+ print("Linkrunner: setAdditionalData failed with API error: \(msg)")
320
+ #endif
321
+ return
322
+ }
323
+ } catch {
324
+ #if DEBUG
325
+ print("Linkrunner: setAdditionalData failed with error: \(error)")
326
+ #endif
327
+ }
328
+ }
329
+
330
+ /// Request App Tracking Transparency permission
331
+ /// - Parameter completionHandler: Optional callback with the authorization status
332
+ public func requestTrackingAuthorization(completionHandler: (@Sendable (ATTrackingManager.AuthorizationStatus) -> Void)? = nil) {
333
+ DispatchQueue.main.async {
334
+ #if canImport(AppTrackingTransparency)
335
+ ATTrackingManager.requestTrackingAuthorization { status in
336
+ #if DEBUG
337
+ var statusString = ""
338
+ switch status {
339
+ case .notDetermined: statusString = "Not Determined"
340
+ case .restricted: statusString = "Restricted"
341
+ case .denied: statusString = "Denied"
342
+ case .authorized: statusString = "Authorized"
343
+ @unknown default: statusString = "Unknown"
344
+ }
345
+
346
+ print("Linkrunner: Tracking authorization status: \(statusString)")
347
+ #endif
348
+
349
+ // Use Task to safely call the handler across isolation boundaries
350
+ if let completionHandler = completionHandler {
351
+ Task { @MainActor in
352
+ completionHandler(status)
353
+ }
354
+ }
355
+ }
356
+ #else
357
+ // Fallback when AppTrackingTransparency is not available
358
+ print("Linkrunner: AppTrackingTransparency not available")
359
+ if let completionHandler = completionHandler {
360
+ Task { @MainActor in
361
+ completionHandler(.notDetermined)
362
+ }
363
+ }
364
+ #endif
365
+ }
366
+ }
367
+
368
+ /// Track a custom event
369
+ /// - Parameters:
370
+ /// - eventName: Name of the event
371
+ /// - eventData: Optional event data
372
+ /// - eventId: Optional unique identifier to deduplicate events server-side
373
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
374
+ public func trackEvent(eventName: String, eventData: SendableDictionary? = nil, eventId: String? = nil) async {
375
+ guard let token = self.token else {
376
+ #if DEBUG
377
+ print("Linkrunner: trackEvent failed - SDK not initialized")
378
+ #endif
379
+ return
380
+ }
381
+
382
+ if eventName.isEmpty {
383
+ #if DEBUG
384
+ print("Linkrunner: trackEvent failed - Event name is required")
385
+ #endif
386
+ return
387
+ }
388
+
389
+ var requestData: SendableDictionary = [
390
+ "token": token,
391
+ "event_name": eventName,
392
+ "event_data": eventData as Any,
393
+ "device_data": (await deviceData()).toDictionary(),
394
+ "install_instance_id": await getLinkRunnerInstallInstanceId(),
395
+ "time_since_app_install": getTimeSinceAppInstall(),
396
+ "platform": "IOS"
397
+ ]
398
+
399
+ if let eventId = eventId, !eventId.isEmpty {
400
+ requestData["event_id"] = eventId
401
+ }
402
+
403
+ do {
404
+ let response = try await makeRequest(
405
+ endpoint: "/api/client/capture-event",
406
+ body: requestData
407
+ )
408
+
409
+ // Process SKAN conversion values from response in background
410
+ await processSKANResponse(response, source: "event")
411
+
412
+ #if DEBUG
413
+ print("Linkrunner: Tracking event", eventName, eventData ?? [:])
414
+ #endif
415
+ } catch {
416
+ #if DEBUG
417
+ print("Linkrunner: trackEvent failed with error: \(error)")
418
+ #endif
419
+ }
420
+ }
421
+
422
+ /// Capture a payment
423
+ /// - Parameters:
424
+ /// - amount: Payment amount
425
+ /// - userId: User identifier
426
+ /// - paymentId: Optional payment identifier
427
+ /// - type: Optional payment type
428
+ /// - status: Optional payment status
429
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
430
+ public func capturePayment(
431
+ amount: Double,
432
+ userId: String,
433
+ paymentId: String? = nil,
434
+ type: PaymentType = .default,
435
+ status: PaymentStatus = .completed
436
+ ) async {
437
+ guard let token = self.token else {
438
+ #if DEBUG
439
+ print("Linkrunner: capturePayment failed - SDK not initialized")
440
+ #endif
441
+ return
442
+ }
443
+
444
+ var requestData: SendableDictionary = [
445
+ "token": token,
446
+ "user_id": userId,
447
+ "platform": "IOS",
448
+ "amount": amount,
449
+ "install_instance_id": await getLinkRunnerInstallInstanceId(),
450
+ "time_since_app_install": getTimeSinceAppInstall(),
451
+ ]
452
+
453
+ if let paymentId = paymentId {
454
+ requestData["payment_id"] = paymentId
455
+ }
456
+
457
+ requestData["type"] = type.rawValue
458
+ requestData["status"] = status.rawValue
459
+
460
+ var dataDict: SendableDictionary = [:]
461
+ dataDict["device_data"] = (await deviceData()).toDictionary()
462
+ requestData["data"] = dataDict
463
+
464
+ do {
465
+ let response = try await makeRequest(
466
+ endpoint: "/api/client/capture-payment",
467
+ body: requestData
468
+ )
469
+
470
+ // Process SKAN conversion values from response in background
471
+ await processSKANResponse(response, source: "payment")
472
+
473
+ #if DEBUG
474
+ print("Linkrunner: Payment captured successfully ", [
475
+ "amount": amount,
476
+ "paymentId": paymentId ?? "N/A",
477
+ "userId": userId,
478
+ "type": type.rawValue,
479
+ "status": status.rawValue
480
+ ] as [String: Any])
481
+ #endif
482
+ } catch {
483
+ #if DEBUG
484
+ print("Linkrunner: capturePayment failed with error: \(error)")
485
+ #endif
486
+ }
487
+ }
488
+
489
+ /// Remove a captured payment
490
+ /// - Parameters:
491
+ /// - userId: User identifier
492
+ /// - paymentId: Optional payment identifier
493
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
494
+ public func removePayment(userId: String, paymentId: String? = nil) async {
495
+ guard let token = self.token else {
496
+ #if DEBUG
497
+ print("Linkrunner: removePayment failed - SDK not initialized")
498
+ #endif
499
+ return
500
+ }
501
+
502
+ if paymentId == nil && userId.isEmpty {
503
+ #if DEBUG
504
+ print("Linkrunner: removePayment failed - Either paymentId or userId must be provided")
505
+ #endif
506
+ return
507
+ }
508
+
509
+ var requestData: SendableDictionary = [
510
+ "token": token,
511
+ "user_id": userId,
512
+ "platform": "IOS",
513
+ "install_instance_id": await getLinkRunnerInstallInstanceId()
514
+ ]
515
+
516
+ if let paymentId = paymentId {
517
+ requestData["payment_id"] = paymentId
518
+ }
519
+
520
+ var dataDict: SendableDictionary = [:]
521
+ dataDict["device_data"] = (await deviceData()).toDictionary()
522
+ requestData["data"] = dataDict
523
+
524
+ do {
525
+ _ = try await makeRequest(
526
+ endpoint: "/api/client/remove-captured-payment",
527
+ body: requestData
528
+ )
529
+
530
+ #if DEBUG
531
+ print("Linkrunner: Payment entry removed successfully!", [
532
+ "paymentId": paymentId ?? "N/A",
533
+ "userId": userId
534
+ ] as [String: Any])
535
+ #endif
536
+ } catch {
537
+ #if DEBUG
538
+ print("Linkrunner: removePayment failed with error: \(error)")
539
+ #endif
540
+ }
541
+ }
542
+
543
+ /// Update the push notification token for the current user
544
+ /// - Parameter pushToken: The push notification token to be associated with the user
545
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
546
+ public func setPushToken(_ pushToken: String) async {
547
+ guard let token = self.token else {
548
+ #if DEBUG
549
+ print("Linkrunner: setPushToken failed - SDK not initialized")
550
+ #endif
551
+ return
552
+ }
553
+
554
+ if pushToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
555
+ #if DEBUG
556
+ print("Linkrunner: setPushToken failed - Push token cannot be empty")
557
+ #endif
558
+ return
559
+ }
560
+
561
+ let requestData: SendableDictionary = [
562
+ "token": token,
563
+ "push_token": pushToken,
564
+ "platform": "IOS",
565
+ "install_instance_id": await getLinkRunnerInstallInstanceId()
566
+ ]
567
+
568
+ do {
569
+ _ = try await makeRequest(
570
+ endpoint: "/api/client/update-push-token",
571
+ body: requestData
572
+ )
573
+
574
+ #if DEBUG
575
+ print("Linkrunner: Push token updated successfully")
576
+ #endif
577
+ } catch {
578
+ #if DEBUG
579
+ print("Linkrunner: setPushToken failed with error: \(error)")
580
+ #endif
581
+ }
582
+ }
583
+
584
+ /// Fetches attribution data for the current installation
585
+ /// - Returns: The attribution data response
586
+ /// to ensure backward compatibility we return empty LRAttributionDataResponse on error
587
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
588
+ public func getAttributionData() async -> LRAttributionDataResponse {
589
+ guard let token = self.token else {
590
+ #if DEBUG
591
+ print("GetAttributionData: SDK not initialized")
592
+ #endif
593
+ return LRAttributionDataResponse(
594
+ attributionSource: "Error getting attribution data",
595
+ campaignData: nil,
596
+ deeplink: nil
597
+ )
598
+ }
599
+
600
+ let requestData: SendableDictionary = [
601
+ "token": token,
602
+ "platform": "IOS",
603
+ "install_instance_id": await getLinkRunnerInstallInstanceId(),
604
+ "device_data": (await deviceData()).toDictionary(),
605
+ "debug": self.debug
606
+ ]
607
+
608
+ do {
609
+ let response = try await makeRequestWithoutRetry(
610
+ endpoint: "/api/client/attribution-data",
611
+ body: requestData
612
+ )
613
+
614
+ #if DEBUG
615
+ print("LinkrunnerKit: Fetching attribution data")
616
+ #endif
617
+
618
+ if let data = response["data"] as? SendableDictionary {
619
+ return try LRAttributionDataResponse(dictionary: data)
620
+ } else {
621
+ #if DEBUG
622
+ print("GetAttributionData: Invalid response")
623
+ #endif
624
+ return LRAttributionDataResponse(
625
+ attributionSource: "Error getting attribution data",
626
+ campaignData: nil,
627
+ deeplink: nil
628
+ )
629
+ }
630
+ } catch {
631
+ #if DEBUG
632
+ print("GetAttributionData: Failed to fetch attribution data - Error: \(error)")
633
+ #endif
634
+ return LRAttributionDataResponse(
635
+ attributionSource: "Error getting attribution data",
636
+ campaignData: nil,
637
+ deeplink: nil
638
+ )
639
+ }
640
+ }
641
+
642
+ // MARK: - Private Methods
643
+
644
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
645
+ private func initApiCall(token: String, source: String, link: String? = nil, debug: Bool? = false) async {
646
+ let deviceDataDict = (await deviceData()).toDictionary()
647
+ let installInstanceId = await getLinkRunnerInstallInstanceId()
648
+
649
+ var requestData: SendableDictionary = [
650
+ "token": token,
651
+ "package_version": getPackageVersion(),
652
+ "app_version": getAppVersion(),
653
+ "device_data": deviceDataDict,
654
+ "platform": "IOS",
655
+ "source": source,
656
+ "install_instance_id": installInstanceId,
657
+ "debug": debug
658
+ ]
659
+
660
+ if let link = link {
661
+ requestData["link"] = link
662
+ }
663
+
664
+ do {
665
+ _ = try await makeRequest(
666
+ endpoint: "/api/client/init",
667
+ body: requestData
668
+ )
669
+
670
+ #if DEBUG
671
+ print("Linkrunner: Initialization successful")
672
+ #endif
673
+
674
+ } catch {
675
+ #if DEBUG
676
+ print("Linkrunner: Init failed with error: \(error)")
677
+ #endif
678
+ }
679
+ }
680
+
681
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
682
+ private func makeRequestWithoutRetry(endpoint: String, body: SendableDictionary) async throws -> SendableDictionary {
683
+ guard let url = URL(string: baseUrl + endpoint) else {
684
+ throw LinkrunnerError.invalidUrl
685
+ }
686
+
687
+ var request = URLRequest(url: url)
688
+ request.httpMethod = "POST"
689
+ request.addValue("application/json", forHTTPHeaderField: "Content-Type")
690
+ request.addValue("application/json", forHTTPHeaderField: "Accept")
691
+
692
+ do {
693
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
694
+ } catch {
695
+ throw LinkrunnerError.jsonEncodingFailed
696
+ }
697
+
698
+ // This will automatically handle signing if credentials are configured
699
+ let (responseData, response) = try await requestInterceptor.signAndSendRequest(request)
700
+ guard let httpResponse = response as? HTTPURLResponse else {
701
+ throw LinkrunnerError.invalidResponse
702
+ }
703
+
704
+ if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
705
+ throw LinkrunnerError.httpError(httpResponse.statusCode)
706
+ }
707
+
708
+ // Parse response without retry logic
709
+ guard let jsonResponse = try JSONSerialization.jsonObject(with: responseData) as? SendableDictionary else {
710
+ throw LinkrunnerError.jsonDecodingFailed
711
+ }
712
+
713
+ return jsonResponse
714
+ }
715
+
716
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
717
+ private func makeRequest(endpoint: String, body: SendableDictionary) async throws -> SendableDictionary {
718
+ return try await makeRequestWithRetry(endpoint: endpoint, body: body, attempt: 0)
719
+ }
720
+
721
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
722
+ private func makeRequestWithRetry(endpoint: String, body: SendableDictionary, attempt: Int) async throws -> SendableDictionary {
723
+ guard let url = URL(string: baseUrl + endpoint) else {
724
+ throw LinkrunnerError.invalidUrl
725
+ }
726
+
727
+ var request = URLRequest(url: url)
728
+ request.httpMethod = "POST"
729
+ request.addValue("application/json", forHTTPHeaderField: "Content-Type")
730
+ request.addValue("application/json", forHTTPHeaderField: "Accept")
731
+
732
+ do {
733
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
734
+ } catch {
735
+ throw LinkrunnerError.jsonEncodingFailed
736
+ }
737
+
738
+ do {
739
+ // This will automatically handle signing if credentials are configured
740
+ let (responseData, response) = try await requestInterceptor.signAndSendRequest(request)
741
+ guard let httpResponse = response as? HTTPURLResponse else {
742
+ throw LinkrunnerError.invalidResponse
743
+ }
744
+
745
+ let statusCode = httpResponse.statusCode
746
+ let shouldRetryHttp = (statusCode == 429) || (500...599).contains(statusCode)
747
+
748
+ // Check for HTTP 500 errors that should trigger retry
749
+ if shouldRetryHttp {
750
+ if attempt < 4 {
751
+ #if DEBUG
752
+ print("Linkrunner: HTTP \(statusCode) on attempt \(attempt), retrying...")
753
+ #endif
754
+ return try await retryAfterDelay(endpoint: endpoint, body: body, attempt: attempt + 1)
755
+ } else {
756
+ #if DEBUG
757
+ print("Linkrunner: HTTP \(statusCode) on final attempt \(attempt), failing")
758
+ #endif
759
+ throw LinkrunnerError.httpError(httpResponse.statusCode)
760
+ }
761
+ }
762
+
763
+ if statusCode < 200 || statusCode >= 300 {
764
+ throw LinkrunnerError.httpError(statusCode)
765
+ }
766
+
767
+ guard let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
768
+ throw LinkrunnerError.jsonDecodingFailed
769
+ }
770
+
771
+ // Convert to SendableDictionary to ensure sendable compliance
772
+ let sendableJson = json as SendableDictionary
773
+ return sendableJson
774
+
775
+ } catch {
776
+ // Check if this is a retryable network error
777
+ if isRetryableError(error) && attempt < 4 {
778
+ #if DEBUG
779
+ print("Linkrunner: Network error on attempt \(attempt), retrying... Error: \(error)")
780
+ #endif
781
+ return try await retryAfterDelay(endpoint: endpoint, body: body, attempt: attempt + 1)
782
+ } else {
783
+ #if DEBUG
784
+ if attempt >= 4 {
785
+ print("Linkrunner: Network error on final attempt \(attempt), failing. Error: \(error)")
786
+ } else {
787
+ print("Linkrunner: Non-retryable error: \(error)")
788
+ }
789
+ #endif
790
+ throw error
791
+ }
792
+ }
793
+ }
794
+
795
+ @available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
796
+ private func retryAfterDelay(endpoint: String, body: SendableDictionary, attempt: Int) async throws -> SendableDictionary {
797
+ // Calculate exponential backoff delay: 2s, 4s, 8s for attempts 1, 2, 3
798
+ // Initial trigger is 0th attempt, then 4 retry attempts
799
+ // Formula: baseDelay * (2 ^ (attempt - 1))
800
+ let baseDelay: TimeInterval = 2.0
801
+ let delay = baseDelay * pow(2.0, Double(attempt - 1))
802
+
803
+ #if DEBUG
804
+ print("Linkrunner: Waiting \(delay) seconds before retry attempt \(attempt)")
805
+ #endif
806
+
807
+ // Task.sleep suspends the task for the specified duration
808
+ // Task.sleep does not block the thread, other tasks can run on the same thread
809
+ try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
810
+
811
+ return try await makeRequestWithRetry(endpoint: endpoint, body: body, attempt: attempt)
812
+ }
813
+
814
+ private func isRetryableError(_ error: Error) -> Bool {
815
+ // Check for network connection errors
816
+ if let urlError = error as? URLError {
817
+ switch urlError.code {
818
+ case .notConnectedToInternet,
819
+ .networkConnectionLost,
820
+ .timedOut,
821
+ .cannotConnectToHost,
822
+ .cannotFindHost,
823
+ .dnsLookupFailed,
824
+ .badServerResponse,
825
+ .resourceUnavailable:
826
+ return true
827
+ default:
828
+ return false
829
+ }
830
+ }
831
+
832
+ return false
833
+ }
834
+
835
+ private func getPackageVersion() -> String {
836
+ return "3.6.0" // Swift package version
837
+ }
838
+
839
+ private func getAppVersion() -> String {
840
+ return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
841
+ }
842
+ }
843
+
844
+ // MARK: - Device Data
845
+
846
+ @available(iOS 15.0, *)
847
+ extension LinkrunnerSDK {
848
+ private func deviceData() async -> DeviceData {
849
+ // Create a Sendable wrapper using Task isolation to convert to a Sendable result
850
+ return await Task { () -> DeviceData in
851
+ #if canImport(UIKit)
852
+ // Device info
853
+ let currentDevice = await UIDevice.current
854
+ let deviceModel = await currentDevice.model
855
+ let deviceName = await currentDevice.name
856
+ let systemVersion = await currentDevice.systemVersion
857
+
858
+ // App info
859
+ let bundle = Bundle.main
860
+ let bundleId = bundle.bundleIdentifier
861
+ let appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
862
+ let buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String
863
+
864
+ // Network info
865
+ let connectivity = getNetworkType()
866
+
867
+ // Screen info
868
+ let screen = await UIScreen.main
869
+ let screenBounds = await screen.bounds
870
+ let screenScale = await screen.scale
871
+ let displayData = DeviceData.DisplayData(
872
+ width: screenBounds.width,
873
+ height: screenBounds.height,
874
+ scale: screenScale
875
+ )
876
+
877
+ // Variable for IDFA
878
+ var idfa: String? = nil
879
+
880
+ // Advertising ID - only collect if disableIdfa is false
881
+ if !self.disableIdfa {
882
+ #if canImport(AppTrackingTransparency)
883
+ if ATTrackingManager.trackingAuthorizationStatus == .notDetermined {
884
+ // Create a continuation to make the async SDK call work in our async function
885
+ await withCheckedContinuation { continuation in
886
+ DispatchQueue.main.async {
887
+ ATTrackingManager.requestTrackingAuthorization { _ in
888
+ continuation.resume()
889
+ }
890
+ }
891
+ }
892
+ }
893
+
894
+ // Check the status after potential request
895
+ if ATTrackingManager.trackingAuthorizationStatus == .authorized {
896
+ #if canImport(AdSupport)
897
+ idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString
898
+ #endif
899
+ }
900
+ #endif
901
+ }
902
+
903
+ // Device ID (for IDFV)
904
+ let identifierForVendor = await currentDevice.identifierForVendor
905
+ let idfv = identifierForVendor?.uuidString
906
+
907
+ // Locale info
908
+ let locale = Locale.current
909
+ let localeIdentifier = locale.identifier
910
+ let languageCode = locale.languageCode
911
+ let regionCode = locale.regionCode
912
+
913
+ // Timezone
914
+ let timezone = TimeZone.current
915
+ let timezoneIdentifier = timezone.identifier
916
+ let timezoneOffset = timezone.secondsFromGMT() / 60
917
+
918
+ // User agent
919
+ let userAgent = await getUserAgent()
920
+
921
+ // Install instance ID
922
+ let installInstanceId = await getLinkRunnerInstallInstanceId()
923
+
924
+ return DeviceData(
925
+ device: deviceModel,
926
+ deviceName: deviceName,
927
+ systemVersion: systemVersion,
928
+ brand: "Apple",
929
+ manufacturer: "Apple",
930
+ bundleId: bundleId,
931
+ appVersion: appVersion,
932
+ buildNumber: buildNumber,
933
+ connectivity: connectivity,
934
+ deviceDisplay: displayData,
935
+ idfa: idfa,
936
+ idfv: idfv,
937
+ locale: localeIdentifier,
938
+ language: languageCode,
939
+ country: regionCode,
940
+ timezone: timezoneIdentifier,
941
+ timezoneOffset: timezoneOffset,
942
+ userAgent: userAgent,
943
+ installInstanceId: installInstanceId
944
+ )
945
+ #else
946
+ // Fallback for non-UIKit platforms
947
+ return DeviceData(
948
+ device: "Unknown",
949
+ deviceName: "Unknown",
950
+ systemVersion: "Unknown",
951
+ brand: "Apple",
952
+ manufacturer: "Apple",
953
+ bundleId: nil,
954
+ appVersion: nil,
955
+ buildNumber: nil,
956
+ connectivity: "unknown",
957
+ deviceDisplay: DeviceData.DisplayData(width: 0, height: 0, scale: 1),
958
+ idfa: nil,
959
+ idfv: nil,
960
+ locale: nil,
961
+ language: nil,
962
+ country: nil,
963
+ timezone: nil,
964
+ timezoneOffset: nil,
965
+ userAgent: nil,
966
+ installInstanceId: await getLinkRunnerInstallInstanceId()
967
+ )
968
+ #endif
969
+ }.value
970
+ }
971
+
972
+ private func getNetworkType() -> String {
973
+ #if canImport(Network)
974
+ // Using a static property to keep track of the network type
975
+ // This helps avoid creating a new monitor for each call
976
+ if networkMonitor == nil {
977
+ setupNetworkMonitoring()
978
+ // Return "unknown" immediately after setup to avoid race condition
979
+ return "unknown"
980
+ }
981
+
982
+ // Thread-safe access to the current connection type
983
+ let connectionType = currentConnectionType ?? "unknown"
984
+ return connectionType
985
+ #else
986
+ // Fallback for platforms where Network framework is not available
987
+ return "unknown"
988
+ #endif
989
+ }
990
+
991
+ private func getUserAgent() async -> String {
992
+ #if canImport(UIKit)
993
+ let device = await UIDevice.current
994
+ let appInfo = Bundle.main.infoDictionary
995
+ let appVersion = appInfo?["CFBundleShortVersionString"] as? String ?? "Unknown"
996
+ let buildNumber = appInfo?["CFBundleVersion"] as? String ?? "Unknown"
997
+ let deviceModel = await device.model
998
+ let systemVersion = await device.systemVersion
999
+
1000
+ return "Linkrunner-iOS/\(appVersion) (\(deviceModel); iOS \(systemVersion); Build/\(buildNumber))"
1001
+ #else
1002
+ return "Linkrunner-iOS/Unknown"
1003
+ #endif
1004
+ }
1005
+ }
1006
+
1007
+ // MARK: - Storage Methods
1008
+
1009
+ extension LinkrunnerSDK {
1010
+ private static let STORAGE_KEY = "linkrunner_install_instance_id"
1011
+ private static let DEEPLINK_URL_STORAGE_KEY = "linkrunner_deeplink_url"
1012
+ private static let ID_LENGTH = 20
1013
+
1014
+ private func getLinkRunnerInstallInstanceId() async -> String {
1015
+ if let installInstanceId = UserDefaults.standard.string(forKey: LinkrunnerSDK.STORAGE_KEY) {
1016
+ return installInstanceId
1017
+ }
1018
+
1019
+ let installInstanceId = generateRandomString(length: LinkrunnerSDK.ID_LENGTH)
1020
+ UserDefaults.standard.set(installInstanceId, forKey: LinkrunnerSDK.STORAGE_KEY)
1021
+ return installInstanceId
1022
+ }
1023
+
1024
+ private func generateRandomString(length: Int) -> String {
1025
+ let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
1026
+ return String((0..<length).map { _ in
1027
+ chars.randomElement()!
1028
+ })
1029
+ }
1030
+
1031
+ private func setDeeplinkURL(_ deeplinkURL: String) async {
1032
+ UserDefaults.standard.set(deeplinkURL, forKey: LinkrunnerSDK.DEEPLINK_URL_STORAGE_KEY)
1033
+ }
1034
+
1035
+ private func getDeeplinkURL() async throws -> String? {
1036
+ return UserDefaults.standard.string(forKey: LinkrunnerSDK.DEEPLINK_URL_STORAGE_KEY)
1037
+ }
1038
+
1039
+ // MARK: - App Install Time Tracking
1040
+
1041
+ private static let APP_INSTALL_TIME_KEY = "linkrunner_app_install_time"
1042
+
1043
+ private func getAppInstallTime() -> Date {
1044
+ // Check if we already have the install time stored
1045
+ if let storedTimestamp = UserDefaults.standard.object(forKey: LinkrunnerSDK.APP_INSTALL_TIME_KEY) as? Date {
1046
+ return storedTimestamp
1047
+ }
1048
+
1049
+ // If not stored, use current time as install time and store it
1050
+ let installTime = Date()
1051
+ UserDefaults.standard.set(installTime, forKey: LinkrunnerSDK.APP_INSTALL_TIME_KEY)
1052
+ return installTime
1053
+ }
1054
+
1055
+ private func getTimeSinceAppInstall() -> TimeInterval {
1056
+ print("Linkrunner: Getting time since app install")
1057
+ guard let installTime = appInstallTime else {
1058
+ return 0
1059
+ }
1060
+ return Date().timeIntervalSince(installTime)
1061
+ }
1062
+
1063
+ // MARK: - SKAN Response Processing
1064
+
1065
+ private func processSKANResponse(_ response: SendableDictionary, source: String) async {
1066
+ // Process SKAN data in background to avoid blocking main thread
1067
+ Task.detached(priority: .utility) {
1068
+
1069
+ #if DEBUG
1070
+ print("LinkrunnerKit: Processing SKAN response from \(source)")
1071
+ print("LinkrunnerKit: Response: \(response)")
1072
+ #endif
1073
+
1074
+ let response = response["data"] as? SendableDictionary ?? [:]
1075
+ // Extract SKAN conversion values from response
1076
+ guard let fineValue = response["fine_conversion_value"] as? Int else {
1077
+ return // No SKAN data in response
1078
+ }
1079
+
1080
+
1081
+ let coarseValue = response["coarse_conversion_value"] as? String
1082
+ let lockWindow = response["lock_postback"] as? Bool ?? false
1083
+
1084
+
1085
+ #if DEBUG
1086
+ print("LinkrunnerKit: Fine value: \(fineValue)")
1087
+ print("LinkrunnerKit: Coarse value: \(coarseValue)")
1088
+ print("LinkrunnerKit: Lock window: \(lockWindow)")
1089
+ print("LinkrunnerKit: Received SKAN values from \(source): fine=\(fineValue), coarse=\(coarseValue ?? "nil"), lock=\(lockWindow)")
1090
+ #endif
1091
+
1092
+ // Update conversion value through SKAN service
1093
+ let success = await SKAdNetworkService.shared.updateConversionValue(
1094
+ fineValue: fineValue,
1095
+ coarseValue: coarseValue,
1096
+ lockWindow: lockWindow,
1097
+ source: source
1098
+ )
1099
+
1100
+ #if DEBUG
1101
+ if success {
1102
+ print("LinkrunnerKit: Successfully updated SKAN conversion value from \(source)")
1103
+ } else {
1104
+ print("LinkrunnerKit: Failed to update SKAN conversion value from \(source)")
1105
+ }
1106
+ #endif
1107
+ }
1108
+ }
1109
+ }