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.
- package/LinkrunnerSDK.podspec +1 -1
- package/ios/LinkrunnerSDK.swift +2 -2
- package/ios/Podfile +1 -1
- package/ios/Pods/LinkrunnerKit/LICENSE +21 -0
- package/ios/Pods/LinkrunnerKit/README.md +15 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/HmacSignatureGenerator.swift +95 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/Linkrunner.swift +1109 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/Models.swift +356 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/RequestSigningInterceptor.swift +110 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/SHA256.swift +12 -0
- package/ios/Pods/LinkrunnerKit/Sources/Linkrunner/SKAdNetworkService.swift +428 -0
- package/ios/Pods/Pods.xcodeproj/project.pbxproj +440 -0
- package/ios/Pods/Pods.xcodeproj/xcuserdata/shofiyabootwala.xcuserdatad/xcschemes/LinkrunnerKit.xcscheme +58 -0
- package/ios/Pods/Pods.xcodeproj/xcuserdata/shofiyabootwala.xcuserdatad/xcschemes/xcschememanagement.plist +16 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-Info.plist +26 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-dummy.m +5 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-prefix.pch +12 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit-umbrella.h +16 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.debug.xcconfig +17 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.modulemap +6 -0
- package/ios/Pods/Target Support Files/LinkrunnerKit/LinkrunnerKit.release.xcconfig +17 -0
- 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
|
+
}
|