react-native-smartlinks 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/android/build.gradle +33 -0
  2. package/android/src/main/AndroidManifest.xml +4 -0
  3. package/android/src/main/java/live/smartlinks/reactnative/SmartLinksActivityLifecycle.kt +34 -0
  4. package/android/src/main/java/live/smartlinks/reactnative/SmartLinksBridgeHandler.kt +172 -0
  5. package/android/src/main/java/live/smartlinks/reactnative/SmartLinksModule.kt +299 -0
  6. package/android/src/main/java/live/smartlinks/reactnative/SmartLinksPackage.kt +16 -0
  7. package/ios/Linklytics/DeviceInfo.swift +114 -0
  8. package/ios/Linklytics/Linklytics.swift +581 -0
  9. package/ios/Linklytics/LinklyticsInterface.swift +10 -0
  10. package/ios/Linklytics/Managers/BatchEventManager.swift +218 -0
  11. package/ios/Linklytics/Managers/OfflineCache.swift +35 -0
  12. package/ios/Linklytics/Managers/SQLiteStorage.swift +225 -0
  13. package/ios/Linklytics/Models/AppResponse.swift +16 -0
  14. package/ios/Linklytics/Models/DeepLinkRoute.swift +24 -0
  15. package/ios/Linklytics/Models/DeferredLinkResponse.swift +81 -0
  16. package/ios/Linklytics/Models/DynamicLinkResponse.swift +13 -0
  17. package/ios/Linklytics/Models/EventResponse.swift +14 -0
  18. package/ios/Linklytics/Models/OfflineEvent.swift +80 -0
  19. package/ios/Linklytics/NetworkManager.swift +360 -0
  20. package/ios/Linklytics/SdkConstants.swift +29 -0
  21. package/ios/PrivacyInfo.xcprivacy +14 -0
  22. package/ios/SmartLinksModule.m +150 -0
  23. package/ios/SmartLinksModule.swift +281 -0
  24. package/package.json +41 -0
  25. package/react-native-smartlinks.podspec +27 -0
  26. package/react-native.config.js +12 -0
  27. package/src/index.ts +84 -0
@@ -0,0 +1,581 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ public class Linklytics: LinklyticsInterface {
5
+
6
+ // MARK: - Public Interface
7
+ public static let shared = Linklytics()
8
+
9
+ // MARK: - Constants
10
+ public static let sdkVersion = "1.2.5"
11
+ public static let apiVersion = "v2"
12
+
13
+ // MARK: - Private Properties
14
+ private var isInitialized = false
15
+ private var apiKey: String?
16
+ private var isDebugMode = false
17
+ private let userDefaults = UserDefaults.standard
18
+ private let prefsKey = "linklytics_prefs"
19
+ private var pendingUniversalLink: URL?
20
+ private var isAppLaunchComplete = false
21
+ private var networkManager: NetworkManager?
22
+ private var deepLinkQueue: [DeepLinkRoute] = []
23
+ private var linkReceivedListener: ((DeepLinkRoute) -> Void)?
24
+
25
+ private var appUserId: String? {
26
+ get {
27
+ if let saved = userDefaults.string(forKey: "\(prefsKey)_app_user_id") {
28
+ return saved
29
+ }
30
+ return UIDevice.current.identifierForVendor?.uuidString
31
+ }
32
+ set { userDefaults.set(newValue, forKey: "\(prefsKey)_app_user_id") }
33
+ }
34
+
35
+ private init() {}
36
+
37
+ // MARK: - Public Methods
38
+ public static var version: String {
39
+ return sdkVersion
40
+ }
41
+
42
+ public static var apiVersionString: String {
43
+ return apiVersion
44
+ }
45
+
46
+ public func setDebugMode(_ enabled: Bool) {
47
+ isDebugMode = enabled
48
+ }
49
+
50
+ public func initialize(apiKey: String, debugMode: Bool = false) {
51
+ self.apiKey = apiKey
52
+ self.isDebugMode = debugMode
53
+ self.isInitialized = true
54
+
55
+ let networkManager = NetworkManager(apiKey: apiKey, debugMode: debugMode)
56
+ self.networkManager = networkManager
57
+
58
+ // Initialize BatchEventManager
59
+ BatchEventManager.shared.initialize(networkManager: networkManager, debugMode: debugMode)
60
+
61
+ // Check if this is first launch and handle install referrer
62
+ if isFirstLaunch() {
63
+ handleFirstLaunch()
64
+ } else {
65
+ // Mark app launch as complete for non-first launches
66
+ isAppLaunchComplete = true
67
+
68
+ // Handle any pending universal link that came before initialization
69
+ if let pendingLink = pendingUniversalLink {
70
+ pendingUniversalLink = nil
71
+ handleUniversalLink(pendingLink, isFromBackground: false)
72
+ }
73
+ }
74
+
75
+ // Try to send any previously cached events
76
+ // BatchEventManager.shared.trySendCachedEvents() // Not needed, it auto-starts monitoring
77
+
78
+ if isDebugMode {
79
+ print("Linklytics: SDK initialized with version \(Self.sdkVersion), API version \(Self.apiVersion)")
80
+ }
81
+ }
82
+
83
+ public func getAppUserId() -> String? {
84
+ return appUserId
85
+ }
86
+
87
+ public func sendEvent(_ eventName: String, parameters: [String: Any?] = [:]) {
88
+ guard isInitialized else {
89
+ if isDebugMode {
90
+ print("Linklytics: SDK not initialized. Call initialize() first.")
91
+ }
92
+ return
93
+ }
94
+
95
+ let stringParams = parameters.compactMapValues { value -> Any? in
96
+ // We can now support AnyCodable, so basically keep as Any but sanitize types that AnyCodable supports
97
+ // actually BatchEventManager takes [String: Any], which converts to OfflineEvent -> AnyCodable.
98
+ // So we just need to unwrap optionals.
99
+ return value
100
+ } as? [String: Any] ?? [:]
101
+
102
+ // Sanitize for AnyCodable compatibility (String, Int, Double, Bool)
103
+ // The previous implementation converted EVERYTHING to String.
104
+ // We want to PRESERVE types if possible.
105
+
106
+ var validParams: [String: Any] = [:]
107
+
108
+ for (key, value) in stringParams {
109
+ if let v = value as? String { validParams[key] = v }
110
+ else if let v = value as? Int { validParams[key] = v }
111
+ else if let v = value as? Double { validParams[key] = v }
112
+ else if let v = value as? Bool { validParams[key] = v }
113
+ else if let v = value as? NSNumber { validParams[key] = v } // Covers Int/Double/Bool
114
+ else { validParams[key] = String(describing: value) } // Fallback to string
115
+ }
116
+
117
+ let deviceInfo = DeviceInfo.fromCurrentDevice(
118
+ sdkVersion: Self.sdkVersion,
119
+ apiVersion: Self.apiVersion,
120
+ networkType: BatchEventManager.shared.getNetworkType()
121
+ )
122
+
123
+ // Use BatchEventManager which handles Offline/Online
124
+ BatchEventManager.shared.sendEvent(eventName, parameters: validParams, deviceInfo: deviceInfo.toMap(), appUserId: self.appUserId)
125
+
126
+ if isDebugMode {
127
+ print("Linklytics: Event passed to BatchEventManager: \(eventName)")
128
+ }
129
+ }
130
+
131
+
132
+
133
+ // MARK: - Deep Link Handling
134
+ public func handleURL(_ url: URL) {
135
+ guard isInitialized else {
136
+ if isDebugMode {
137
+ print("Linklytics: SDK not initialized. Cannot handle deep link.")
138
+ }
139
+ return
140
+ }
141
+
142
+ let urlString = url.absoluteString
143
+ var parameters: [String: String] = [:]
144
+
145
+ if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
146
+ let queryItems = components.queryItems {
147
+ for item in queryItems {
148
+ parameters[item.name] = item.value ?? ""
149
+ }
150
+ }
151
+
152
+ let deepLinkRoute = DeepLinkRoute(
153
+ route: urlString,
154
+ parameters: parameters,
155
+ link: urlString,
156
+ appSlug: getAppSlug(),
157
+ isFirstLaunch: false
158
+ )
159
+
160
+ addDeepLinkToQueue(deepLinkRoute)
161
+
162
+ sendEvent("deep_link_opened", parameters: [
163
+ "route": urlString,
164
+ "parameters": parameters.description
165
+ ])
166
+
167
+ if isDebugMode {
168
+ print("Linklytics: Deep link handled: \(urlString) with parameters: \(parameters)")
169
+ }
170
+ }
171
+
172
+ public func handleUniversalLink(_ url: URL, isFromBackground: Bool = false) {
173
+ // If SDK is not initialized yet, store the link for later processing
174
+ guard isInitialized else {
175
+ pendingUniversalLink = url
176
+ if isDebugMode {
177
+ print("Linklytics: Universal link received before initialization, storing for later: \(url)")
178
+ }
179
+ return
180
+ }
181
+
182
+ // If this is first launch and we haven't completed the launch process yet,
183
+ // store the link for later processing after deferred link check
184
+ if isFirstLaunch() && !isAppLaunchComplete {
185
+ pendingUniversalLink = url
186
+ if isDebugMode {
187
+ print("Linklytics: Universal link received during first launch, storing for after deferred link check: \(url)")
188
+ }
189
+ return
190
+ }
191
+
192
+ let urlString = url.absoluteString
193
+ var parameters: [String: String] = [:]
194
+
195
+ if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
196
+ let queryItems = components.queryItems {
197
+ for item in queryItems {
198
+ parameters[item.name] = item.value ?? ""
199
+ }
200
+ }
201
+
202
+ // Add context about how the link was received
203
+ parameters["link_source"] = isFromBackground ? "background" : "terminated"
204
+
205
+ let deepLinkRoute = DeepLinkRoute(
206
+ route: urlString,
207
+ parameters: parameters,
208
+ link: urlString,
209
+ appSlug: getAppSlug(),
210
+ isFirstLaunch: false
211
+ )
212
+
213
+ addDeepLinkToQueue(deepLinkRoute)
214
+ // Send appropriate event based on app state
215
+ let eventName = isFromBackground ? "universal_link_background" : "universal_link_terminated"
216
+ sendEvent(eventName, parameters: [
217
+ "url": urlString,
218
+ "parameters": parameters.description,
219
+ "app_state": isFromBackground ? "background" : "terminated"
220
+ ])
221
+
222
+ if isDebugMode {
223
+ print("Linklytics: Universal link handled (\(isFromBackground ? "background" : "terminated")): \(urlString) with parameters: \(parameters)")
224
+ }
225
+ }
226
+
227
+ // MARK: - Private Methods
228
+ private func isFirstLaunch() -> Bool {
229
+ let key = "\(prefsKey)_has_launched_before"
230
+ return !userDefaults.bool(forKey: key)
231
+ }
232
+
233
+ private func markAsLaunched() {
234
+ let key = "\(prefsKey)_has_launched_before"
235
+ userDefaults.set(true, forKey: key)
236
+ }
237
+
238
+ private func handleFirstLaunch() {
239
+ if isDebugMode {
240
+ print("Linklytics: Handling first launch - getting app info and checking for deferred deep links")
241
+ }
242
+
243
+ // First, get app info to save the app slug
244
+ guard let networkManager = networkManager else {
245
+ if isDebugMode {
246
+ print("Linklytics: NetworkManager not available")
247
+ }
248
+ return
249
+ }
250
+
251
+ networkManager.getApp { [weak self] result in
252
+ switch result {
253
+ case .success(let appResponse):
254
+ let appSlug = appResponse.appSlug
255
+ self?.saveAppSlug(appSlug)
256
+ if self?.isDebugMode == true {
257
+ print("Linklytics: App slug saved: \(appSlug)")
258
+ }
259
+ case .failure(let error):
260
+ if self?.isDebugMode == true {
261
+ print("Linklytics: Failed to get app info: \(error)")
262
+ }
263
+ }
264
+
265
+ // Continue with deferred link check regardless of app info result
266
+ self?.checkDeferredLinks()
267
+ }
268
+ }
269
+
270
+ private func checkDeferredLinks() {
271
+ // iOS doesn't have install referrer, so we call the deferred-links API
272
+ // to check if there's a pending deep link for this device
273
+ fetchDeferredDeepLink { [weak self] result in
274
+ switch result {
275
+ case .success(let deferredLink):
276
+ self?.handleDeferredDeepLink(deferredLink)
277
+ case .failure(let error):
278
+ if self?.isDebugMode == true {
279
+ print("Linklytics: No deferred deep link found or error: \(error)")
280
+ }
281
+ self?.handleOrganicInstall()
282
+ }
283
+ self?.markAsLaunched()
284
+ self?.isAppLaunchComplete = true
285
+
286
+ // Handle any pending universal link that came during first launch
287
+ if let pendingLink = self?.pendingUniversalLink {
288
+ self?.pendingUniversalLink = nil
289
+ self?.handleUniversalLink(pendingLink, isFromBackground: false)
290
+ }
291
+ }
292
+ }
293
+
294
+ private func saveAppSlug(_ appSlug: String) {
295
+ let key = "\(prefsKey)_app_slug"
296
+ userDefaults.set(appSlug, forKey: key)
297
+ }
298
+
299
+ private func getAppSlug() -> String? {
300
+ let key = "\(prefsKey)_app_slug"
301
+ return userDefaults.string(forKey: key)
302
+ }
303
+
304
+ // MARK: - Deferred Deep Links
305
+
306
+ private enum DeferredLinkError: Error {
307
+ case noApiKey
308
+ case invalidURL
309
+ case noData
310
+ case decodingError
311
+ case networkError(Error)
312
+ }
313
+
314
+ private func fetchDeferredDeepLink(completion: @escaping (Result<DeferredLinkResponse, DeferredLinkError>) -> Void) {
315
+ guard let networkManager = networkManager else {
316
+ completion(.failure(.noApiKey))
317
+ return
318
+ }
319
+
320
+ let deviceInfo = DeviceInfo.fromCurrentDevice(
321
+ sdkVersion: Self.sdkVersion,
322
+ apiVersion: Self.apiVersion,
323
+ networkType: BatchEventManager.shared.getNetworkType()
324
+ )
325
+
326
+ // ✅ Generate fingerprint asynchronously
327
+ deviceInfo.generateFingerprint { [weak self] fingerprint in
328
+ guard let fingerprint = fingerprint else {
329
+ completion(.failure(.decodingError))
330
+ return
331
+ }
332
+
333
+ if self?.isDebugMode == true {
334
+ print("Linklytics: Generated fingerprint: \(fingerprint)")
335
+ }
336
+
337
+ // Pass deviceInfo map to network manager
338
+ networkManager.fetchDeferredLink(fingerprint: fingerprint, appUserId: self?.appUserId ?? "", deviceInfo: deviceInfo.toMap()) { result in
339
+ switch result {
340
+ case .success(let data):
341
+ guard let data = data else {
342
+ completion(.failure(.noData))
343
+ return
344
+ }
345
+
346
+ do {
347
+ let deferredLink = try JSONDecoder().decode(DeferredLinkResponse.self, from: data)
348
+ completion(.success(deferredLink))
349
+ } catch {
350
+ if self?.isDebugMode == true {
351
+ print("Linklytics: Failed to decode deferred link response: \(error)")
352
+ }
353
+ completion(.failure(.decodingError))
354
+ }
355
+ case .failure(let error):
356
+ if self?.isDebugMode == true {
357
+ print("Linklytics: Failed to fetch deferred link: \(error)")
358
+ }
359
+ completion(.failure(.networkError(error)))
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+
366
+ private func handleDeferredDeepLink(_ deferredLink: DeferredLinkResponse) {
367
+ if isDebugMode {
368
+ print("Linklytics: Handling deferred deep link: \(deferredLink.url ?? "no URL")")
369
+ }
370
+
371
+ let route = deferredLink.url ?? "organic"
372
+ var parameters = deferredLink.parameters ?? [:]
373
+
374
+ // Add campaign data if available
375
+ if let campaign = deferredLink.campaignInfo {
376
+ parameters.merge(campaign) { (_, new) in new }
377
+ }
378
+ if let utmParams = deferredLink.utmParams {
379
+ parameters.merge(utmParams) { (_, new) in new }
380
+ }
381
+
382
+ let deepLinkRoute = DeepLinkRoute(
383
+ route: route,
384
+ parameters: parameters,
385
+ link: deferredLink.url,
386
+ appSlug: getAppSlug(),
387
+ isFirstLaunch: true
388
+ )
389
+
390
+
391
+ // Add to queue for getLink() to return
392
+ addDeepLinkToQueue(deepLinkRoute)
393
+
394
+ sendEvent("app_installed", parameters: [
395
+ "referrer": route,
396
+ "deferred_link": deferredLink.hasDeferredLink ? "true" : "false",
397
+ "parameters": parameters.description
398
+ ])
399
+ }
400
+
401
+ private func handleOrganicInstall() {
402
+ if isDebugMode {
403
+ print("Linklytics: Handling organic install")
404
+ }
405
+
406
+ let deepLinkRoute = DeepLinkRoute(
407
+ route: "organic",
408
+ parameters: ["referrer": "organic"],
409
+ link: nil,
410
+ appSlug: getAppSlug(),
411
+ isFirstLaunch: true
412
+ )
413
+
414
+ // Add to queue for getLink() to return
415
+ addDeepLinkToQueue(deepLinkRoute)
416
+
417
+ sendEvent("app_installed", parameters: [
418
+ "referrer": "organic",
419
+ "deferred_link": "false"
420
+ ])
421
+ }
422
+
423
+
424
+ private func generateLongDynamicLink(_ route: String, parameters: [String: String]? = nil) -> String{
425
+ guard let networkManager = networkManager else {
426
+ if isDebugMode {
427
+ print("Linklytics: NetworkManager not available")
428
+ }
429
+ return ""
430
+ }
431
+
432
+ let app = getAppSlug() ?? ""
433
+ if(app == "") {
434
+ networkManager.getApp { [weak self] result in
435
+ switch result {
436
+ case .success(let appResponse):
437
+ let appSlug = appResponse.appSlug
438
+ self?.saveAppSlug(appSlug)
439
+ case .failure(let error):
440
+ if self?.isDebugMode == true {
441
+ print("Linklytics: Failed to get app info: \(error)")
442
+ }
443
+ }
444
+ }
445
+ // Return empty string for now since we can't get app slug synchronously
446
+ return ""
447
+ }
448
+
449
+ let queryString = parameters?.map { "\($0.key)=\($0.value)" }.joined(separator: "&") ?? ""
450
+ let fullURL = SdkConstants.baseURL + "/" + app + "/" + route
451
+ return queryString.isEmpty ? fullURL : fullURL + "?" + queryString
452
+ }
453
+
454
+ private func generateShortDynamicLink(_ url: String, parameters: [String: String]? = nil) -> String{
455
+ guard isInitialized, let networkManager = networkManager else {
456
+ if isDebugMode {
457
+ print("Linklytics: SDK not initialized. Call initialize() first.")
458
+ }
459
+ return ""
460
+ }
461
+
462
+ // This is a synchronous method but we need async network call
463
+ // We'll use a semaphore to make it synchronous
464
+ let semaphore = DispatchSemaphore(value: 0)
465
+ var result = ""
466
+
467
+ if isDebugMode {
468
+ print("Linklytics: generateShortDynamicLink called with url: \(url), parameters: \(parameters ?? [:])")
469
+ }
470
+
471
+ networkManager.generateDynamicLink(url: url, parameters: parameters) { response in
472
+ switch response {
473
+ case .success(let dynamicLinkResponse):
474
+ result = dynamicLinkResponse.short_url ?? ""
475
+ if self.isDebugMode {
476
+ print("Linklytics: Short dynamic link generated successfully: \(result)")
477
+ }
478
+ case .failure(let error):
479
+ if self.isDebugMode {
480
+ print("Linklytics: Failed to generate short dynamic link: \(error)")
481
+ }
482
+ result = ""
483
+ }
484
+ semaphore.signal()
485
+ }
486
+
487
+ // Wait for the async call to complete (with timeout)
488
+ _ = semaphore.wait(timeout: .now() + 10)
489
+
490
+ if isDebugMode {
491
+ print("Linklytics: generateShortDynamicLink result: \(result)")
492
+ }
493
+
494
+ return result
495
+ }
496
+
497
+ // MARK: - Dynamic Link Generation
498
+ public func generateDynamicLink(_ url: String, parameters: [String: String]? = nil, isShort: Bool = false) -> String? {
499
+ guard isInitialized, let networkManager = networkManager else {
500
+ if isDebugMode {
501
+ print("Linklytics: SDK not initialized. Call initialize() first.")
502
+ }
503
+ return nil
504
+ }
505
+
506
+ if isDebugMode {
507
+ print("Linklytics: generateDynamicLink called with url: \(url), parameters: \(parameters ?? [:]), isShort: \(isShort)")
508
+ }
509
+
510
+ if !isShort {
511
+ return generateLongDynamicLink(url, parameters: parameters)
512
+ } else {
513
+ return generateShortDynamicLink(url, parameters: parameters)
514
+ }
515
+ }
516
+
517
+ public func getLink() -> DeepLinkRoute? {
518
+ guard isInitialized else {
519
+ if isDebugMode {
520
+ print("Linklytics: SDK not initialized. Call initialize() first.")
521
+ }
522
+ return nil
523
+ }
524
+
525
+ // Return the first deep link from the queue, if any
526
+ if !deepLinkQueue.isEmpty {
527
+ let link = deepLinkQueue.removeFirst()
528
+ if isDebugMode {
529
+ print("Linklytics: getLink returning: \(link.route)")
530
+ }
531
+ return link
532
+ }
533
+
534
+ if isDebugMode {
535
+ print("Linklytics: getLink called but no deep links in queue")
536
+ }
537
+
538
+ return nil
539
+ }
540
+
541
+ public func setOnLinkReceivedListener(_ listener: @escaping (DeepLinkRoute) -> Void) {
542
+ self.linkReceivedListener = listener
543
+
544
+ // Drain existing queue to the new listener
545
+ while !deepLinkQueue.isEmpty {
546
+ let link = deepLinkQueue.removeFirst()
547
+ if isDebugMode {
548
+ print("Linklytics: Draining queued link to listener: \(link.route)")
549
+ }
550
+ listener(link)
551
+ }
552
+ }
553
+
554
+
555
+
556
+ // MARK: - Internal Deep Link Management
557
+ private func addDeepLinkToQueue(_ deepLinkRoute: DeepLinkRoute) {
558
+ if let listener = linkReceivedListener {
559
+ if isDebugMode {
560
+ print("Linklytics: Notifying listener of new deep link: \(deepLinkRoute.route)")
561
+ }
562
+ listener(deepLinkRoute)
563
+ } else {
564
+ deepLinkQueue.append(deepLinkRoute)
565
+ if isDebugMode {
566
+ print("Linklytics: Added deep link to queue: \(deepLinkRoute.route)")
567
+ }
568
+ }
569
+ }
570
+
571
+ // MARK: - Testing Helper
572
+ public func clearUserData() {
573
+ let launchKey = "\(prefsKey)_has_launched_before"
574
+ let appSlugKey = "\(prefsKey)_app_slug"
575
+ userDefaults.removeObject(forKey: launchKey)
576
+ userDefaults.removeObject(forKey: appSlugKey)
577
+ if isDebugMode {
578
+ print("Linklytics: User data cleared for testing")
579
+ }
580
+ }
581
+ }
@@ -0,0 +1,10 @@
1
+ import Foundation
2
+
3
+ // MARK: - Public Interface
4
+ public protocol LinklyticsInterface {
5
+ func initialize(apiKey: String, debugMode: Bool)
6
+ func sendEvent(_ eventName: String, parameters: [String: Any?])
7
+ func generateDynamicLink(_ url: String, parameters: [String: String]?, isShort: Bool) -> String?
8
+ func getLink() -> DeepLinkRoute?
9
+ func setOnLinkReceivedListener(_ listener: @escaping (DeepLinkRoute) -> Void)
10
+ }