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,218 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ class BatchEventManager {
5
+ static let shared = BatchEventManager()
6
+
7
+ private let offlineCache = OfflineCache()
8
+ private var networkManager: NetworkManager?
9
+ private let monitor = NWPathMonitor()
10
+ private let queue = DispatchQueue(label: "com.alkashier.linklytics.batcheventmanager")
11
+ private var isConnected = false
12
+ private var isSendingBatch = false
13
+ private var isInitialized = false
14
+ private var isDebugMode = false
15
+
16
+ // Network Reliability
17
+ private var consecutiveFailures = 0
18
+ private var lastFailureTimestamp: Date?
19
+ private var currentNetworkType: String = "unknown"
20
+
21
+ private let batchSize = 50
22
+ private let initialBackoff: TimeInterval = 10
23
+ private let maxBackoff: TimeInterval = 300 // 5 mins
24
+
25
+ private init() {}
26
+
27
+ func initialize(networkManager: NetworkManager, debugMode: Bool) {
28
+ self.networkManager = networkManager
29
+ self.isDebugMode = debugMode
30
+ self.isInitialized = true
31
+
32
+ startMonitoring()
33
+ }
34
+
35
+ private func startMonitoring() {
36
+ monitor.pathUpdateHandler = { [weak self] path in
37
+ self?.queue.async {
38
+ let wasConnected = self?.isConnected ?? false
39
+ self?.isConnected = path.status == .satisfied
40
+
41
+ // Track network type
42
+ if path.usesInterfaceType(.wifi) {
43
+ self?.currentNetworkType = "wifi"
44
+ } else if path.usesInterfaceType(.cellular) {
45
+ self?.currentNetworkType = "cellular"
46
+ } else if path.usesInterfaceType(.wiredEthernet) {
47
+ self?.currentNetworkType = "ethernet"
48
+ } else if path.status == .satisfied {
49
+ self?.currentNetworkType = "other"
50
+ } else {
51
+ self?.currentNetworkType = "none"
52
+ }
53
+
54
+ if self?.isConnected == true && !wasConnected {
55
+ if self?.isDebugMode == true {
56
+ print("Linklytics: Connectivity restored (\(self?.currentNetworkType ?? "unknown")), attempting to send cached events")
57
+ }
58
+ // Wait a moment for connection to stabilize (5s matching Android)
59
+ self?.queue.asyncAfter(deadline: .now() + 5.0) {
60
+ self?.sendCachedEvents()
61
+ }
62
+ }
63
+ }
64
+ }
65
+ monitor.start(queue: queue)
66
+ }
67
+
68
+ func getNetworkType() -> String {
69
+ return queue.sync { currentNetworkType }
70
+ }
71
+
72
+ func stopMonitoring() {
73
+ monitor.cancel()
74
+ }
75
+
76
+ func sendEvent(_ eventName: String, parameters: [String: Any], deviceInfo: [String: Any], appUserId: String? = nil) {
77
+ guard isInitialized else {
78
+ print("Linklytics: BatchEventManager not initialized")
79
+ return
80
+ }
81
+
82
+ let offlineEvent = OfflineEvent(eventName: eventName, parameters: parameters, deviceInfo: deviceInfo, appUserId: appUserId, isDebug: self.isDebugMode)
83
+
84
+ queue.async {
85
+ if self.isConnected {
86
+ // Try sending immediately
87
+ self.sendImmediate(offlineEvent)
88
+ } else {
89
+ if self.isDebugMode {
90
+ print("Linklytics: No connection, caching event: \(eventName)")
91
+ }
92
+ self.offlineCache.cacheEvent(offlineEvent)
93
+ }
94
+ }
95
+ }
96
+
97
+ private func sendImmediate(_ event: OfflineEvent) {
98
+ guard let networkManager = networkManager else { return }
99
+
100
+ // Convert AnyCodable back to [String: Any] for NetworkManager (which currently expects [String: String], we need to update it)
101
+ // Wait, NetworkManager needs update to accept [String: Any].
102
+ // For now, let's assume NetworkManager will be updated.
103
+
104
+ networkManager.sendEvent(event: event) { [weak self] result in
105
+ switch result {
106
+ case .success:
107
+ if self?.isDebugMode == true {
108
+ print("Linklytics: Event sent immediately: \(event.eventName)")
109
+ }
110
+ case .failure(let error):
111
+ if self?.isDebugMode == true {
112
+ print("Linklytics: Failed to send event immediately, caching: \(error)")
113
+ }
114
+ self?.offlineCache.cacheEvent(event)
115
+ }
116
+ }
117
+ }
118
+
119
+ private func isInBackoff() -> Bool {
120
+ guard consecutiveFailures > 0, let lastFailure = lastFailureTimestamp else { return false }
121
+ let now = Date()
122
+ let nextAllowedTime = lastFailure.addingTimeInterval(getNextBackoffDelay())
123
+ return now < nextAllowedTime
124
+ }
125
+
126
+ private func getNextBackoffDelay() -> TimeInterval {
127
+ if consecutiveFailures == 0 { return 0 }
128
+ let exponentialDelay = initialBackoff * pow(2.0, Double(consecutiveFailures - 1))
129
+ return min(exponentialDelay, maxBackoff)
130
+ }
131
+
132
+ private func sendCachedEvents() {
133
+ guard !isSendingBatch else { return }
134
+
135
+ if isInBackoff() {
136
+ if isDebugMode {
137
+ let remaining = lastFailureTimestamp!.addingTimeInterval(getNextBackoffDelay()).timeIntervalSince(Date())
138
+ print("Linklytics: SDK in backoff period, skipping sync. Remaining: \(Int(remaining))s")
139
+ }
140
+ return
141
+ }
142
+
143
+ isSendingBatch = true
144
+
145
+ let events = offlineCache.getEventsReadyForSending()
146
+ if events.isEmpty {
147
+ isSendingBatch = false
148
+ return
149
+ }
150
+
151
+ if isDebugMode {
152
+ print("Linklytics: Found \(events.count) cached events to send")
153
+ }
154
+
155
+ // Chunk into batches of 50
156
+ let batches = stride(from: 0, to: events.count, by: 50).map {
157
+ Array(events[$0..<min($0 + 50, events.count)])
158
+ }
159
+
160
+ sendBatchesRecursive(batches: batches, index: 0)
161
+ }
162
+
163
+ private func sendBatchesRecursive(batches: [[OfflineEvent]], index: Int) {
164
+ guard index < batches.count else {
165
+ isSendingBatch = false
166
+ return
167
+ }
168
+
169
+ let batch = batches[index]
170
+
171
+ // Check connection
172
+ if !isConnected {
173
+ if isDebugMode { print("Linklytics: Connection lost during batch send") }
174
+ isSendingBatch = false
175
+ return
176
+ }
177
+
178
+ // Extract common fields for the batch header (optional, backend can also handle per-event)
179
+ // But for V2 we want the top-level app_user_id if possible
180
+ let batchAppUserId = batch.first?.appUserId
181
+ let batchDebug = batch.first?.isDebug ?? self.isDebugMode
182
+
183
+ networkManager?.sendBatchEvents(events: batch, appUserId: batchAppUserId, debug: batchDebug) { [weak self] result in
184
+ switch result {
185
+ case .success:
186
+ if self?.isDebugMode == true {
187
+ print("Linklytics: Batch \(index + 1)/\(batches.count) sent successfully")
188
+ }
189
+ // Remove successful events
190
+ let ids = batch.map { $0.id }
191
+ self?.offlineCache.removeEvents(withIds: ids)
192
+
193
+ // Reset backoff on success
194
+ self?.consecutiveFailures = 0
195
+ self?.lastFailureTimestamp = nil
196
+
197
+ // Next batch with delay
198
+ self?.queue.asyncAfter(deadline: .now() + 1.0) {
199
+ self?.sendBatchesRecursive(batches: batches, index: index + 1)
200
+ }
201
+
202
+ case .failure(let error):
203
+ if self?.isDebugMode == true {
204
+ print("Linklytics: Batch \(index + 1) failed: \(error)")
205
+ }
206
+ // Handle failures (increment retry)
207
+ self?.offlineCache.handleFailedEvents(batch)
208
+
209
+ // Track failure for backoff
210
+ self?.consecutiveFailures += 1
211
+ self?.lastFailureTimestamp = Date()
212
+
213
+ // Stop processing remaining batches to backoff
214
+ self?.isSendingBatch = false
215
+ }
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ class OfflineCache {
4
+ private let storage = SQLiteStorage()
5
+ private let maxRetryCount = 3
6
+
7
+ func cacheEvent(_ event: OfflineEvent) {
8
+ storage.insert(event: event)
9
+ }
10
+
11
+ func getEvents() -> [OfflineEvent] {
12
+ return storage.getAllEvents()
13
+ }
14
+
15
+ func getEventsReadyForSending() -> [OfflineEvent] {
16
+ return storage.getAllEvents().filter { $0.retryCount < self.maxRetryCount }
17
+ }
18
+
19
+ func removeEvents(withIds ids: [String]) {
20
+ storage.removeEvents(withIds: ids)
21
+ }
22
+
23
+ func handleFailedEvents(_ failedEvents: [OfflineEvent]) {
24
+ let ids = failedEvents.map { $0.id }
25
+ storage.updateRetryCount(forIds: ids)
26
+ }
27
+
28
+ func getCachedCount() -> Int {
29
+ return storage.getCount()
30
+ }
31
+
32
+ func clearCache() {
33
+ storage.clear()
34
+ }
35
+ }
@@ -0,0 +1,225 @@
1
+ import Foundation
2
+ import SQLite3
3
+
4
+ class SQLiteStorage {
5
+ private var db: OpaquePointer?
6
+ private let dbName = "linklytics_events.sqlite"
7
+ private let queue = DispatchQueue(label: "com.alkashier.linklytics.sqlite", attributes: .concurrent)
8
+ private let maxCachedEvents = 1000
9
+
10
+ init() {
11
+ openDatabase()
12
+ createTable()
13
+ }
14
+
15
+ deinit {
16
+ sqlite3_close(db)
17
+ }
18
+
19
+ private func openDatabase() {
20
+ let fileURL = try! FileManager.default
21
+ .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
22
+ .appendingPathComponent(dbName)
23
+
24
+ if sqlite3_open(fileURL.path, &db) != SQLITE_OK {
25
+ print("Linklytics: Error opening database")
26
+ }
27
+ }
28
+
29
+ private func createTable() {
30
+ let createTableString = """
31
+ CREATE TABLE IF NOT EXISTS events(
32
+ id TEXT PRIMARY KEY NOT NULL,
33
+ event_name TEXT,
34
+ parameters TEXT,
35
+ device_info TEXT,
36
+ timestamp INTEGER,
37
+ retry_count INTEGER,
38
+ app_user_id TEXT,
39
+ debug INTEGER DEFAULT 0
40
+ );
41
+ """
42
+
43
+ var createTableStatement: OpaquePointer?
44
+ if sqlite3_prepare_v2(db, createTableString, -1, &createTableStatement, nil) == SQLITE_OK {
45
+ if sqlite3_step(createTableStatement) == SQLITE_DONE {
46
+ // Table created
47
+ } else {
48
+ print("Linklytics: Events table could not be created.")
49
+ }
50
+ }
51
+ sqlite3_finalize(createTableStatement)
52
+
53
+ // Migration: Ensure debug column exists
54
+ let alterTableString = "ALTER TABLE events ADD COLUMN debug INTEGER DEFAULT 0;"
55
+ sqlite3_exec(db, alterTableString, nil, nil, nil)
56
+ }
57
+
58
+ func insert(event: OfflineEvent) {
59
+ queue.async(flags: .barrier) {
60
+ let insertStatementString = "INSERT INTO events (id, event_name, parameters, device_info, timestamp, retry_count, app_user_id, debug) VALUES (?, ?, ?, ?, ?, ?, ?, ?);"
61
+ var insertStatement: OpaquePointer?
62
+
63
+ if sqlite3_prepare_v2(self.db, insertStatementString, -1, &insertStatement, nil) == SQLITE_OK {
64
+ sqlite3_bind_text(insertStatement, 1, (event.id as NSString).utf8String, -1, nil)
65
+ sqlite3_bind_text(insertStatement, 2, (event.eventName as NSString).utf8String, -1, nil)
66
+
67
+ let paramsData = try? JSONEncoder().encode(event.parameters)
68
+ let paramsString = paramsData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
69
+ sqlite3_bind_text(insertStatement, 3, (paramsString as NSString).utf8String, -1, nil)
70
+
71
+ let deviceData = try? JSONEncoder().encode(event.deviceInfo)
72
+ let deviceString = deviceData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
73
+ sqlite3_bind_text(insertStatement, 4, (deviceString as NSString).utf8String, -1, nil)
74
+
75
+ sqlite3_bind_int64(insertStatement, 5, event.timestamp)
76
+ sqlite3_bind_int(insertStatement, 6, Int32(event.retryCount))
77
+
78
+ if let userId = event.appUserId {
79
+ sqlite3_bind_text(insertStatement, 7, (userId as NSString).utf8String, -1, nil)
80
+ } else {
81
+ sqlite3_bind_null(insertStatement, 7)
82
+ }
83
+
84
+ sqlite3_bind_int(insertStatement, 8, event.isDebug ? 1 : 0)
85
+
86
+ if sqlite3_step(insertStatement) != SQLITE_DONE {
87
+ print("Linklytics: Could not insert row.")
88
+ }
89
+ }
90
+ sqlite3_finalize(insertStatement)
91
+
92
+ self.checkAndCleanupInternal()
93
+ }
94
+ }
95
+
96
+ func getAllEvents() -> [OfflineEvent] {
97
+ return queue.sync {
98
+ let queryStatementString = "SELECT * FROM events ORDER BY timestamp ASC;"
99
+ var queryStatement: OpaquePointer?
100
+ var events: [OfflineEvent] = []
101
+
102
+ if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
103
+ while sqlite3_step(queryStatement) == SQLITE_ROW {
104
+ let id = String(cString: sqlite3_column_text(queryStatement, 0))
105
+ let name = String(cString: sqlite3_column_text(queryStatement, 1))
106
+ let paramsJson = String(cString: sqlite3_column_text(queryStatement, 2))
107
+ let deviceJson = String(cString: sqlite3_column_text(queryStatement, 3))
108
+ let timestamp = sqlite3_column_int64(queryStatement, 4)
109
+ let retryCount = Int(sqlite3_column_int(queryStatement, 5))
110
+ let appUserId = sqlite3_column_text(queryStatement, 6).flatMap { String(cString: $0) }
111
+ let isDebug = sqlite3_column_int(queryStatement, 7) != 0
112
+
113
+ let params = (try? JSONDecoder().decode([String: AnyCodable].self, from: paramsJson.data(using: .utf8)!)) ?? [:]
114
+ let device = (try? JSONDecoder().decode([String: AnyCodable].self, from: deviceJson.data(using: .utf8)!)) ?? [:]
115
+
116
+ let event = OfflineEvent(id: id, eventName: name, parameters: params, deviceInfo: device, timestamp: timestamp, retryCount: retryCount, appUserId: appUserId, isDebug: isDebug)
117
+ events.append(event)
118
+ }
119
+ }
120
+ sqlite3_finalize(queryStatement)
121
+ return events
122
+ }
123
+ }
124
+
125
+ func removeEvents(withIds ids: [String]) {
126
+ queue.async(flags: .barrier) {
127
+ let placeholders = Array(repeating: "?", count: ids.count).joined(separator: ",")
128
+ let deleteStatementString = "DELETE FROM events WHERE id IN (\(placeholders));"
129
+ var deleteStatement: OpaquePointer?
130
+
131
+ if sqlite3_prepare_v2(self.db, deleteStatementString, -1, &deleteStatement, nil) == SQLITE_OK {
132
+ for (index, id) in ids.enumerated() {
133
+ sqlite3_bind_text(deleteStatement, Int32(index + 1), (id as NSString).utf8String, -1, nil)
134
+ }
135
+
136
+ if sqlite3_step(deleteStatement) != SQLITE_DONE {
137
+ print("Linklytics: Could not delete rows.")
138
+ }
139
+ }
140
+ sqlite3_finalize(deleteStatement)
141
+ }
142
+ }
143
+
144
+ func updateRetryCount(forIds ids: [String]) {
145
+ queue.async(flags: .barrier) {
146
+ let placeholders = Array(repeating: "?", count: ids.count).joined(separator: ",")
147
+ let updateStatementString = "UPDATE events SET retry_count = retry_count + 1 WHERE id IN (\(placeholders));"
148
+ var updateStatement: OpaquePointer?
149
+
150
+ if sqlite3_prepare_v2(self.db, updateStatementString, -1, &updateStatement, nil) == SQLITE_OK {
151
+ for (index, id) in ids.enumerated() {
152
+ sqlite3_bind_text(updateStatement, Int32(index + 1), (id as NSString).utf8String, -1, nil)
153
+ }
154
+
155
+ if sqlite3_step(updateStatement) != SQLITE_DONE {
156
+ print("Linklytics: Could not update retry counts.")
157
+ }
158
+ }
159
+ sqlite3_finalize(updateStatement)
160
+
161
+ // Cleanup events that reached max retry (e.g. 3)
162
+ let cleanupStatementString = "DELETE FROM events WHERE retry_count >= 3;"
163
+ var cleanupStatement: OpaquePointer?
164
+ if sqlite3_prepare_v2(self.db, cleanupStatementString, -1, &cleanupStatement, nil) == SQLITE_OK {
165
+ _ = sqlite3_step(cleanupStatement)
166
+ }
167
+ sqlite3_finalize(cleanupStatement)
168
+ }
169
+ }
170
+
171
+ func getCount() -> Int {
172
+ return queue.sync {
173
+ let queryStatementString = "SELECT COUNT(*) FROM events;"
174
+ var queryStatement: OpaquePointer?
175
+ var count = 0
176
+
177
+ if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
178
+ if sqlite3_step(queryStatement) == SQLITE_ROW {
179
+ count = Int(sqlite3_column_int(queryStatement, 0))
180
+ }
181
+ }
182
+ sqlite3_finalize(queryStatement)
183
+ return count
184
+ }
185
+ }
186
+
187
+ func clear() {
188
+ queue.async(flags: .barrier) {
189
+ let deleteStatementString = "DELETE FROM events;"
190
+ var deleteStatement: OpaquePointer?
191
+ if sqlite3_prepare_v2(self.db, deleteStatementString, -1, &deleteStatement, nil) == SQLITE_OK {
192
+ _ = sqlite3_step(deleteStatement)
193
+ }
194
+ sqlite3_finalize(deleteStatement)
195
+ }
196
+ }
197
+
198
+ private func checkAndCleanupInternal() {
199
+ // Simple FIFO cleanup if over limit
200
+ let currentCount = getCountInternal()
201
+ if currentCount > maxCachedEvents {
202
+ let deleteCount = currentCount - maxCachedEvents + (maxCachedEvents / 10) // Delete overage + 10%
203
+ let deleteStatementString = "DELETE FROM events WHERE id IN (SELECT id FROM events ORDER BY timestamp ASC LIMIT ?);"
204
+ var deleteStatement: OpaquePointer?
205
+ if sqlite3_prepare_v2(db, deleteStatementString, -1, &deleteStatement, nil) == SQLITE_OK {
206
+ sqlite3_bind_int(deleteStatement, 1, Int32(deleteCount))
207
+ _ = sqlite3_step(deleteStatement)
208
+ }
209
+ sqlite3_finalize(deleteStatement)
210
+ }
211
+ }
212
+
213
+ private func getCountInternal() -> Int {
214
+ let queryStatementString = "SELECT COUNT(*) FROM events;"
215
+ var queryStatement: OpaquePointer?
216
+ var count = 0
217
+ if sqlite3_prepare_v2(db, queryStatementString, -1, &queryStatement, nil) == SQLITE_OK {
218
+ if sqlite3_step(queryStatement) == SQLITE_ROW {
219
+ count = Int(sqlite3_column_int(queryStatement, 0))
220
+ }
221
+ }
222
+ sqlite3_finalize(queryStatement)
223
+ return count
224
+ }
225
+ }
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+
3
+ // MARK: - App Response Model
4
+ public struct AppResponse: Codable {
5
+ public let appSlug: String
6
+
7
+ enum CodingKeys: String, CodingKey {
8
+ case appSlug = "app_slug"
9
+ }
10
+
11
+ public init(
12
+ appSlug: String
13
+ ) {
14
+ self.appSlug = appSlug
15
+ }
16
+ }
@@ -0,0 +1,24 @@
1
+ import Foundation
2
+
3
+ // MARK: - Deep Link Route Model
4
+ public struct DeepLinkRoute {
5
+ public let route: String
6
+ public let parameters: [String: String]
7
+ public let link: String?
8
+ public let appSlug: String?
9
+ public let isFirstLaunch: Bool
10
+
11
+ public init(
12
+ route: String,
13
+ parameters: [String: String] = [:],
14
+ link: String? = nil,
15
+ appSlug: String? = nil,
16
+ isFirstLaunch: Bool = false
17
+ ) {
18
+ self.route = route
19
+ self.parameters = parameters
20
+ self.link = link
21
+ self.appSlug = appSlug
22
+ self.isFirstLaunch = isFirstLaunch
23
+ }
24
+ }
@@ -0,0 +1,81 @@
1
+ import Foundation
2
+
3
+ // MARK: - Deferred Link Response Model
4
+ struct DeferredLinkResponse: Decodable {
5
+ let url: String?
6
+ let parameters: [String: String]?
7
+ let campaignInfo: [String: String]?
8
+ let utmParams: [String: String]?
9
+ let hasDeferredLink: Bool
10
+
11
+ enum CodingKeys: String, CodingKey {
12
+ case url
13
+ case parameters
14
+ case campaignInfo = "campaign_info"
15
+ case utmParams = "utm_params" // Assuming backend sends snake_case
16
+ case hasDeferredLink = "has_deferred_link"
17
+ case success
18
+ case timestamp
19
+ }
20
+
21
+ init(from decoder: Decoder) throws {
22
+ let container = try decoder.container(keyedBy: CodingKeys.self)
23
+
24
+ hasDeferredLink = try container.decodeIfPresent(Bool.self, forKey: .hasDeferredLink) ?? false
25
+ url = try container.decodeIfPresent(String.self, forKey: .url)
26
+
27
+ // Handle campaignInfo (dictionary in JSON)
28
+ if let campaignContainer = try? container.nestedContainer(keyedBy: DynamicKey.self, forKey: .campaignInfo) {
29
+ var campaignDict: [String: String] = [:]
30
+ for key in campaignContainer.allKeys {
31
+ if let stringVal = try? campaignContainer.decodeIfPresent(String.self, forKey: key) {
32
+ campaignDict[key.stringValue] = stringVal
33
+ }
34
+ }
35
+ campaignInfo = campaignDict
36
+ } else {
37
+ campaignInfo = nil
38
+ }
39
+
40
+ // Handle parameters: Mix of String/Int/Bool -> Convert to [String: String]
41
+ if let paramsContainer = try? container.nestedContainer(keyedBy: DynamicKey.self, forKey: .parameters) {
42
+ var params: [String: String] = [:]
43
+ for key in paramsContainer.allKeys {
44
+ if let stringVal = try? paramsContainer.decode(String.self, forKey: key) {
45
+ params[key.stringValue] = stringVal
46
+ } else if let intVal = try? paramsContainer.decode(Int.self, forKey: key) {
47
+ params[key.stringValue] = String(intVal)
48
+ } else if let boolVal = try? paramsContainer.decode(Bool.self, forKey: key) {
49
+ params[key.stringValue] = String(boolVal)
50
+ } else if let doubleVal = try? paramsContainer.decode(Double.self, forKey: key) {
51
+ params[key.stringValue] = String(doubleVal)
52
+ }
53
+ }
54
+ parameters = params
55
+ } else {
56
+ parameters = nil
57
+ }
58
+
59
+ // Handle utmParams: Mix of String/Int/Bool -> Convert to [String: String]
60
+ if let utmContainer = try? container.nestedContainer(keyedBy: DynamicKey.self, forKey: .utmParams) {
61
+ var utms: [String: String] = [:]
62
+ for key in utmContainer.allKeys {
63
+ if let stringVal = try? utmContainer.decode(String.self, forKey: key) {
64
+ utms[key.stringValue] = stringVal
65
+ }
66
+ // Usually UTMs are strings, but safe to cover others
67
+ }
68
+ utmParams = utms
69
+ } else {
70
+ utmParams = nil
71
+ }
72
+ }
73
+
74
+ // Helper for dynamic keys
75
+ struct DynamicKey: CodingKey {
76
+ var stringValue: String
77
+ init?(stringValue: String) { self.stringValue = stringValue }
78
+ var intValue: Int?
79
+ init?(intValue: Int) { return nil }
80
+ }
81
+ }
@@ -0,0 +1,13 @@
1
+ import Foundation
2
+
3
+ // MARK: - Dynamic Link Response Model
4
+ struct DynamicLinkResponse: Codable {
5
+ let success: Bool
6
+ let short_url: String?
7
+
8
+
9
+ enum CodingKeys: String, CodingKey {
10
+ case success
11
+ case short_url
12
+ }
13
+ }
@@ -0,0 +1,14 @@
1
+ import Foundation
2
+
3
+ // MARK: - Event Response Model
4
+ struct EventResponse: Codable {
5
+ let success: Bool
6
+ let message: String?
7
+
8
+
9
+ enum CodingKeys: String, CodingKey {
10
+ case success
11
+ case message
12
+
13
+ }
14
+ }