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.
- package/android/build.gradle +33 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/live/smartlinks/reactnative/SmartLinksActivityLifecycle.kt +34 -0
- package/android/src/main/java/live/smartlinks/reactnative/SmartLinksBridgeHandler.kt +172 -0
- package/android/src/main/java/live/smartlinks/reactnative/SmartLinksModule.kt +299 -0
- package/android/src/main/java/live/smartlinks/reactnative/SmartLinksPackage.kt +16 -0
- package/ios/Linklytics/DeviceInfo.swift +114 -0
- package/ios/Linklytics/Linklytics.swift +581 -0
- package/ios/Linklytics/LinklyticsInterface.swift +10 -0
- package/ios/Linklytics/Managers/BatchEventManager.swift +218 -0
- package/ios/Linklytics/Managers/OfflineCache.swift +35 -0
- package/ios/Linklytics/Managers/SQLiteStorage.swift +225 -0
- package/ios/Linklytics/Models/AppResponse.swift +16 -0
- package/ios/Linklytics/Models/DeepLinkRoute.swift +24 -0
- package/ios/Linklytics/Models/DeferredLinkResponse.swift +81 -0
- package/ios/Linklytics/Models/DynamicLinkResponse.swift +13 -0
- package/ios/Linklytics/Models/EventResponse.swift +14 -0
- package/ios/Linklytics/Models/OfflineEvent.swift +80 -0
- package/ios/Linklytics/NetworkManager.swift +360 -0
- package/ios/Linklytics/SdkConstants.swift +29 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/SmartLinksModule.m +150 -0
- package/ios/SmartLinksModule.swift +281 -0
- package/package.json +41 -0
- package/react-native-smartlinks.podspec +27 -0
- package/react-native.config.js +12 -0
- 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
|
+
}
|