@kafitra/react-native-live-tracking 0.1.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/LICENSE +21 -0
- package/README.md +396 -0
- package/android/build.gradle +71 -0
- package/android/gradle.properties +7 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
- package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
- package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
- package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
- package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
- package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
- package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
- package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
- package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
- package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
- package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
- package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
- package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
- package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
- package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
- package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
- package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
- package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
- package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
- package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
- package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
- package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
- package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
- package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
- package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
- package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
- package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
- package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
- package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
- package/ios/ActivityRecognitionHandler.swift +196 -0
- package/ios/BackgroundModeHelper.swift +132 -0
- package/ios/FirebaseSyncEngine.swift +276 -0
- package/ios/LiveTracking-Bridging-Header.h +2 -0
- package/ios/LiveTracking.m +37 -0
- package/ios/LiveTracking.swift +773 -0
- package/ios/LocationDataPoint.swift +56 -0
- package/ios/LocationEngine.swift +160 -0
- package/ios/MotionSleepManager.swift +151 -0
- package/ios/NetworkListener.swift +105 -0
- package/ios/OfflineQueueManager.swift +503 -0
- package/ios/PermissionHandler.swift +148 -0
- package/ios/QueueEngine.swift +249 -0
- package/ios/SyncEngineController.swift +396 -0
- package/ios/SyncTargetConfig.swift +36 -0
- package/ios/TargetHandler.swift +715 -0
- package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
- package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
- package/ios/Tests/LocationEngineTests.swift +244 -0
- package/ios/Tests/MotionSleepManagerTests.swift +355 -0
- package/ios/Tests/NetworkListenerTests.swift +188 -0
- package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
- package/ios/Tests/PermissionHandlerTests.swift +238 -0
- package/ios/Tests/QueueEngineTests.swift +346 -0
- package/ios/TrackingCleanup.swift +93 -0
- package/ios/TrackingNotificationManager.swift +187 -0
- package/lib/commonjs/EventEmitter.js +113 -0
- package/lib/commonjs/EventEmitter.js.map +1 -0
- package/lib/commonjs/LiveTracking.js +134 -0
- package/lib/commonjs/LiveTracking.js.map +1 -0
- package/lib/commonjs/NativeLiveTracking.js +21 -0
- package/lib/commonjs/NativeLiveTracking.js.map +1 -0
- package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
- package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
- package/lib/commonjs/index.js +103 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/serialization/locationSerializer.js +51 -0
- package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
- package/lib/commonjs/types.js +77 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/utils/distance.js +63 -0
- package/lib/commonjs/utils/distance.js.map +1 -0
- package/lib/commonjs/utils/retry.js +80 -0
- package/lib/commonjs/utils/retry.js.map +1 -0
- package/lib/commonjs/validation.js +463 -0
- package/lib/commonjs/validation.js.map +1 -0
- package/lib/module/EventEmitter.js +105 -0
- package/lib/module/EventEmitter.js.map +1 -0
- package/lib/module/LiveTracking.js +127 -0
- package/lib/module/LiveTracking.js.map +1 -0
- package/lib/module/NativeLiveTracking.js +16 -0
- package/lib/module/NativeLiveTracking.js.map +1 -0
- package/lib/module/filters/distanceTimeFilter.js +58 -0
- package/lib/module/filters/distanceTimeFilter.js.map +1 -0
- package/lib/module/index.js +32 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/serialization/locationSerializer.js +45 -0
- package/lib/module/serialization/locationSerializer.js.map +1 -0
- package/lib/module/types.js +94 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/distance.js +56 -0
- package/lib/module/utils/distance.js.map +1 -0
- package/lib/module/utils/retry.js +72 -0
- package/lib/module/utils/retry.js.map +1 -0
- package/lib/module/validation.js +456 -0
- package/lib/module/validation.js.map +1 -0
- package/lib/typescript/EventEmitter.d.ts +65 -0
- package/lib/typescript/EventEmitter.d.ts.map +1 -0
- package/lib/typescript/LiveTracking.d.ts +23 -0
- package/lib/typescript/LiveTracking.d.ts.map +1 -0
- package/lib/typescript/NativeLiveTracking.d.ts +25 -0
- package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
- package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +21 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
- package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +217 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/utils/distance.d.ts +38 -0
- package/lib/typescript/utils/distance.d.ts.map +1 -0
- package/lib/typescript/utils/retry.d.ts +60 -0
- package/lib/typescript/utils/retry.d.ts.map +1 -0
- package/lib/typescript/validation.d.ts +26 -0
- package/lib/typescript/validation.d.ts.map +1 -0
- package/package.json +126 -0
- package/react-native-live-tracking.podspec +47 -0
- package/src/EventEmitter.ts +118 -0
- package/src/LiveTracking.ts +159 -0
- package/src/NativeLiveTracking.ts +29 -0
- package/src/filters/distanceTimeFilter.ts +75 -0
- package/src/index.ts +51 -0
- package/src/serialization/locationSerializer.ts +57 -0
- package/src/types.ts +252 -0
- package/src/utils/distance.ts +68 -0
- package/src/utils/retry.ts +75 -0
- package/src/validation.ts +552 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import FirebaseDatabase
|
|
3
|
+
import FirebaseFirestore
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Protocol for receiving target handler events (errors, warnings).
|
|
7
|
+
*/
|
|
8
|
+
protocol TargetHandlerDelegate: AnyObject {
|
|
9
|
+
/**
|
|
10
|
+
* Called when a write operation fails after all retry attempts are exhausted.
|
|
11
|
+
*
|
|
12
|
+
* - Parameter targetPath: The Firebase path of the target that failed
|
|
13
|
+
* - Parameter method: The write method that was used
|
|
14
|
+
* - Parameter errorCode: Machine-readable error code
|
|
15
|
+
* - Parameter message: Human-readable error description
|
|
16
|
+
*/
|
|
17
|
+
func onWriteError(targetPath: String, method: String, errorCode: String, message: String)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Called when the offline queue overflows for a target.
|
|
21
|
+
*
|
|
22
|
+
* - Parameter targetPath: The Firebase path of the target whose queue overflowed
|
|
23
|
+
*/
|
|
24
|
+
func onQueueOverflow(targetPath: String)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Protocol for checking network connectivity status.
|
|
29
|
+
* Allows dependency injection for testing.
|
|
30
|
+
*/
|
|
31
|
+
protocol NetworkStatusProvider: AnyObject {
|
|
32
|
+
func isOnline() -> Bool
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Protocol for offline queue operations.
|
|
37
|
+
* Allows dependency injection for testing and per-target queue management.
|
|
38
|
+
*/
|
|
39
|
+
protocol OfflineQueueProvider: AnyObject {
|
|
40
|
+
func enqueueForTarget(targetPath: String, location: LocationDataPoint)
|
|
41
|
+
func countForTarget(targetPath: String) -> Int
|
|
42
|
+
func evictOldestForTarget(targetPath: String)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* TargetHandler manages a single sync target's write operations.
|
|
47
|
+
*
|
|
48
|
+
* Responsibilities:
|
|
49
|
+
* - Batch accumulation: Accumulates location data points in memory until batchSize is reached
|
|
50
|
+
* - Batch timeout: Flushes partial batches after 30 seconds of inactivity
|
|
51
|
+
* - Write dispatching: Executes Firebase writes using the configured method (set/push/update)
|
|
52
|
+
* - Retry with exponential backoff: Retries transient failures with configurable max attempts
|
|
53
|
+
* - Retry cancellation: For set/update targets, cancels in-progress retries when newer data arrives
|
|
54
|
+
* - Offline queue delegation: Persists data via CoreData when offline and offlineQueue is enabled
|
|
55
|
+
*
|
|
56
|
+
* Requirements: 3.1, 3.2, 3.3, 3.4, 4.1, 4.2, 4.3, 4.5, 4.7, 5.3, 7.7
|
|
57
|
+
*/
|
|
58
|
+
class TargetHandler {
|
|
59
|
+
|
|
60
|
+
// MARK: - Constants
|
|
61
|
+
|
|
62
|
+
private static let baseDelayMs: Int = 1000
|
|
63
|
+
private static let multiplier: Double = 2.0
|
|
64
|
+
private static let jitterMs: Int = 200
|
|
65
|
+
private static let maxRetriesSetUpdate: Int = 3
|
|
66
|
+
private static let maxRetriesPush: Int = 5
|
|
67
|
+
private static let batchTimeoutSeconds: TimeInterval = 30.0
|
|
68
|
+
private static let maxQueueSize: Int = 10000
|
|
69
|
+
|
|
70
|
+
// MARK: - Properties
|
|
71
|
+
|
|
72
|
+
let config: SyncTargetConfig
|
|
73
|
+
let service: String
|
|
74
|
+
|
|
75
|
+
weak var delegate: TargetHandlerDelegate?
|
|
76
|
+
weak var networkStatusProvider: NetworkStatusProvider?
|
|
77
|
+
weak var offlineQueueProvider: OfflineQueueProvider?
|
|
78
|
+
|
|
79
|
+
/// In-memory batch buffer
|
|
80
|
+
private var buffer: [LocationDataPoint] = []
|
|
81
|
+
|
|
82
|
+
/// Serial queue for thread-safe access to buffer and retry state
|
|
83
|
+
private let handlerQueue: DispatchQueue
|
|
84
|
+
|
|
85
|
+
/// Timer for 30-second batch timeout flush
|
|
86
|
+
private var batchTimeoutTimer: DispatchSourceTimer?
|
|
87
|
+
|
|
88
|
+
/// Flag indicating whether a retry is currently in progress
|
|
89
|
+
private var retryInProgress: Bool = false
|
|
90
|
+
|
|
91
|
+
/// Work item for the current retry delay (used for cancellation)
|
|
92
|
+
private var currentRetryWorkItem: DispatchWorkItem?
|
|
93
|
+
|
|
94
|
+
/// Generation counter for retry cancellation on set/update targets
|
|
95
|
+
private var writeGeneration: UInt64 = 0
|
|
96
|
+
|
|
97
|
+
// MARK: - Initialization
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Initialize a TargetHandler for a specific sync target.
|
|
101
|
+
*
|
|
102
|
+
* - Parameter config: The sync target configuration
|
|
103
|
+
* - Parameter service: Firebase service type ("RTDB" or "Firestore")
|
|
104
|
+
*/
|
|
105
|
+
init(config: SyncTargetConfig, service: String) {
|
|
106
|
+
self.config = config
|
|
107
|
+
self.service = service
|
|
108
|
+
self.handlerQueue = DispatchQueue(
|
|
109
|
+
label: "com.livetracking.target.\(config.path)",
|
|
110
|
+
qos: .utility
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// MARK: - Public Methods
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Dispatch a new location data point to this target.
|
|
118
|
+
*
|
|
119
|
+
* If the target has batchSize > 1, the point is accumulated in the buffer.
|
|
120
|
+
* If the buffer reaches batchSize, a write is triggered.
|
|
121
|
+
* If batchSize is 1 (immediate), the point is written immediately.
|
|
122
|
+
*
|
|
123
|
+
* For set/update targets with an active retry, the retry is cancelled
|
|
124
|
+
* and only the newest data is written.
|
|
125
|
+
*
|
|
126
|
+
* If the device is offline and offlineQueue is enabled, data is persisted locally.
|
|
127
|
+
*
|
|
128
|
+
* - Parameter location: The location data point to dispatch
|
|
129
|
+
*/
|
|
130
|
+
func dispatchLocation(_ location: LocationDataPoint) {
|
|
131
|
+
handlerQueue.async { [weak self] in
|
|
132
|
+
guard let self = self else { return }
|
|
133
|
+
self.handleDispatch(location)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Flush any partially-filled batch immediately.
|
|
139
|
+
* Called when tracking is stopped to ensure no data is lost.
|
|
140
|
+
*
|
|
141
|
+
* - Parameter completion: Called when flush is complete
|
|
142
|
+
*/
|
|
143
|
+
func flush(completion: (() -> Void)? = nil) {
|
|
144
|
+
handlerQueue.async { [weak self] in
|
|
145
|
+
guard let self = self else {
|
|
146
|
+
completion?()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
self.cancelBatchTimeout()
|
|
150
|
+
if !self.buffer.isEmpty {
|
|
151
|
+
let batch = self.buffer
|
|
152
|
+
self.buffer = []
|
|
153
|
+
self.executeBatchWrite(batch: batch)
|
|
154
|
+
}
|
|
155
|
+
completion?()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Write a batch of locations from the offline queue with a completion callback.
|
|
161
|
+
* Used during offline queue flush to confirm write success before removing entries.
|
|
162
|
+
*
|
|
163
|
+
* - Parameter batch: Array of location data points to write
|
|
164
|
+
* - Parameter completion: Called with `true` on success, `false` on failure (after all retries exhausted)
|
|
165
|
+
*
|
|
166
|
+
* Requirements: 5.2, 5.5, 5.6
|
|
167
|
+
*/
|
|
168
|
+
func writeOfflineQueueBatch(_ batch: [LocationDataPoint], completion: @escaping (Bool) -> Void) {
|
|
169
|
+
handlerQueue.async { [weak self] in
|
|
170
|
+
guard let self = self else {
|
|
171
|
+
completion(false)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let maxRetries = (self.config.method == "push")
|
|
176
|
+
? TargetHandler.maxRetriesPush
|
|
177
|
+
: TargetHandler.maxRetriesSetUpdate
|
|
178
|
+
|
|
179
|
+
self.executeOfflineQueueWriteWithRetry(
|
|
180
|
+
batch: batch,
|
|
181
|
+
attempt: 1,
|
|
182
|
+
maxRetries: maxRetries,
|
|
183
|
+
completion: completion
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Shut down the handler, cancelling timers and pending retries.
|
|
190
|
+
*/
|
|
191
|
+
func shutdown() {
|
|
192
|
+
handlerQueue.async { [weak self] in
|
|
193
|
+
guard let self = self else { return }
|
|
194
|
+
self.cancelBatchTimeout()
|
|
195
|
+
self.cancelCurrentRetry()
|
|
196
|
+
self.buffer = []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// MARK: - Private Methods — Dispatch Logic
|
|
201
|
+
|
|
202
|
+
private func handleDispatch(_ location: LocationDataPoint) {
|
|
203
|
+
// Check if device is offline
|
|
204
|
+
let isOnline = networkStatusProvider?.isOnline() ?? true
|
|
205
|
+
|
|
206
|
+
if !isOnline {
|
|
207
|
+
if config.offlineQueue {
|
|
208
|
+
enqueueOffline(location: location)
|
|
209
|
+
}
|
|
210
|
+
// If offlineQueue is disabled, discard the data (Requirement 5.3)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// For set/update targets: cancel in-progress retry when newer data arrives (Requirement 7.7)
|
|
215
|
+
if (config.method == "set" || config.method == "update") && retryInProgress {
|
|
216
|
+
cancelCurrentRetry()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Batch accumulation logic
|
|
220
|
+
if config.batchSize > 1 {
|
|
221
|
+
buffer.append(location)
|
|
222
|
+
resetBatchTimeout()
|
|
223
|
+
|
|
224
|
+
if buffer.count >= config.batchSize {
|
|
225
|
+
// Batch is full — flush it
|
|
226
|
+
cancelBatchTimeout()
|
|
227
|
+
let batch = buffer
|
|
228
|
+
buffer = []
|
|
229
|
+
executeBatchWrite(batch: batch)
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// Immediate write (batchSize == 1)
|
|
233
|
+
executeBatchWrite(batch: [location])
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Private Methods — Batch Timeout
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reset the 30-second batch timeout timer.
|
|
241
|
+
* If a timer is already running, it is cancelled and restarted.
|
|
242
|
+
* When the timer fires, the partial batch is flushed.
|
|
243
|
+
*
|
|
244
|
+
* Requirement 4.7: Flush partial batch after 30 seconds of inactivity.
|
|
245
|
+
*/
|
|
246
|
+
private func resetBatchTimeout() {
|
|
247
|
+
cancelBatchTimeout()
|
|
248
|
+
|
|
249
|
+
let timer = DispatchSource.makeTimerSource(queue: handlerQueue)
|
|
250
|
+
timer.schedule(deadline: .now() + TargetHandler.batchTimeoutSeconds)
|
|
251
|
+
timer.setEventHandler { [weak self] in
|
|
252
|
+
guard let self = self else { return }
|
|
253
|
+
if !self.buffer.isEmpty {
|
|
254
|
+
let batch = self.buffer
|
|
255
|
+
self.buffer = []
|
|
256
|
+
self.executeBatchWrite(batch: batch)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
timer.resume()
|
|
260
|
+
batchTimeoutTimer = timer
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func cancelBatchTimeout() {
|
|
264
|
+
batchTimeoutTimer?.cancel()
|
|
265
|
+
batchTimeoutTimer = nil
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// MARK: - Private Methods — Write Execution
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Execute a batch write to Firebase with retry logic.
|
|
272
|
+
*
|
|
273
|
+
* - Parameter batch: Array of location data points to write
|
|
274
|
+
*/
|
|
275
|
+
private func executeBatchWrite(batch: [LocationDataPoint]) {
|
|
276
|
+
let maxRetries = (config.method == "push")
|
|
277
|
+
? TargetHandler.maxRetriesPush
|
|
278
|
+
: TargetHandler.maxRetriesSetUpdate
|
|
279
|
+
|
|
280
|
+
// Increment generation for retry cancellation
|
|
281
|
+
writeGeneration += 1
|
|
282
|
+
let currentGeneration = writeGeneration
|
|
283
|
+
|
|
284
|
+
retryInProgress = true
|
|
285
|
+
executeWithRetry(
|
|
286
|
+
batch: batch,
|
|
287
|
+
attempt: 1,
|
|
288
|
+
maxRetries: maxRetries,
|
|
289
|
+
generation: currentGeneration
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Execute a write operation with exponential backoff retry.
|
|
295
|
+
*
|
|
296
|
+
* Attempt 1 is the initial write. Attempts 2 through maxRetries+1 are retries.
|
|
297
|
+
* So for maxRetries=3 (set/update), there are up to 4 total attempts (1 initial + 3 retries).
|
|
298
|
+
* For maxRetries=5 (push), there are up to 6 total attempts (1 initial + 5 retries).
|
|
299
|
+
*
|
|
300
|
+
* Requirements: 7.1, 7.2, 7.3
|
|
301
|
+
*
|
|
302
|
+
* - Parameter batch: The batch of locations to write
|
|
303
|
+
* - Parameter attempt: Current attempt number (1-indexed, where 1 = initial attempt)
|
|
304
|
+
* - Parameter maxRetries: Maximum number of retry attempts after the initial attempt
|
|
305
|
+
* - Parameter generation: Write generation for cancellation detection
|
|
306
|
+
*/
|
|
307
|
+
private func executeWithRetry(
|
|
308
|
+
batch: [LocationDataPoint],
|
|
309
|
+
attempt: Int,
|
|
310
|
+
maxRetries: Int,
|
|
311
|
+
generation: UInt64
|
|
312
|
+
) {
|
|
313
|
+
// Check if this retry has been superseded by newer data
|
|
314
|
+
guard generation == writeGeneration else {
|
|
315
|
+
retryInProgress = false
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
performFirebaseWrite(batch: batch) { [weak self] success, errorCode, message in
|
|
320
|
+
guard let self = self else { return }
|
|
321
|
+
|
|
322
|
+
self.handlerQueue.async {
|
|
323
|
+
// Check generation again after async callback
|
|
324
|
+
guard generation == self.writeGeneration else {
|
|
325
|
+
self.retryInProgress = false
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if success {
|
|
330
|
+
self.retryInProgress = false
|
|
331
|
+
} else {
|
|
332
|
+
// Check for non-transient errors — do not retry (Requirement 7.6)
|
|
333
|
+
if self.isNonTransientError(errorCode: errorCode) {
|
|
334
|
+
self.retryInProgress = false
|
|
335
|
+
self.delegate?.onWriteError(
|
|
336
|
+
targetPath: self.config.path,
|
|
337
|
+
method: self.config.method,
|
|
338
|
+
errorCode: errorCode ?? "FIREBASE_WRITE_FAILED",
|
|
339
|
+
message: message ?? "Non-transient error"
|
|
340
|
+
)
|
|
341
|
+
// Queue if offlineQueue enabled
|
|
342
|
+
if self.config.offlineQueue {
|
|
343
|
+
for location in batch {
|
|
344
|
+
self.enqueueOffline(location: location)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// attempt counts total attempts (initial + retries).
|
|
351
|
+
// maxRetries represents the number of retries allowed after the initial attempt.
|
|
352
|
+
// So we exhaust when attempt > maxRetries (attempt 1 = initial, 2..maxRetries+1 = retries).
|
|
353
|
+
if attempt > maxRetries {
|
|
354
|
+
// All retries exhausted (Requirement 7.5)
|
|
355
|
+
self.retryInProgress = false
|
|
356
|
+
self.delegate?.onWriteError(
|
|
357
|
+
targetPath: self.config.path,
|
|
358
|
+
method: self.config.method,
|
|
359
|
+
errorCode: errorCode ?? "FIREBASE_WRITE_FAILED",
|
|
360
|
+
message: "Failed after \(maxRetries) retries: \(message ?? "Unknown error")"
|
|
361
|
+
)
|
|
362
|
+
// Queue if offlineQueue enabled (Requirement 4.6, 7.5)
|
|
363
|
+
if self.config.offlineQueue {
|
|
364
|
+
for location in batch {
|
|
365
|
+
self.enqueueOffline(location: location)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// Schedule retry with exponential backoff (Requirement 7.1)
|
|
370
|
+
let delay = self.calculateBackoffDelay(attempt: attempt)
|
|
371
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
372
|
+
guard let self = self else { return }
|
|
373
|
+
self.executeWithRetry(
|
|
374
|
+
batch: batch,
|
|
375
|
+
attempt: attempt + 1,
|
|
376
|
+
maxRetries: maxRetries,
|
|
377
|
+
generation: generation
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
self.currentRetryWorkItem = workItem
|
|
381
|
+
self.handlerQueue.asyncAfter(
|
|
382
|
+
deadline: .now() + .milliseconds(delay),
|
|
383
|
+
execute: workItem
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Cancel the current in-progress retry.
|
|
393
|
+
*/
|
|
394
|
+
private func cancelCurrentRetry() {
|
|
395
|
+
currentRetryWorkItem?.cancel()
|
|
396
|
+
currentRetryWorkItem = nil
|
|
397
|
+
retryInProgress = false
|
|
398
|
+
writeGeneration += 1
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// MARK: - Private Methods — Offline Queue Write with Retry
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Execute a write operation for offline queue data with exponential backoff retry.
|
|
405
|
+
* Unlike the regular executeWithRetry, this calls a completion handler with the result
|
|
406
|
+
* so the caller can decide whether to remove entries from the queue.
|
|
407
|
+
*
|
|
408
|
+
* - Parameter batch: The batch of locations to write
|
|
409
|
+
* - Parameter attempt: Current attempt number (1-indexed)
|
|
410
|
+
* - Parameter maxRetries: Maximum number of retry attempts after the initial attempt
|
|
411
|
+
* - Parameter completion: Called with `true` on success, `false` on failure
|
|
412
|
+
*
|
|
413
|
+
* Requirements: 5.2, 5.6, 7.1
|
|
414
|
+
*/
|
|
415
|
+
private func executeOfflineQueueWriteWithRetry(
|
|
416
|
+
batch: [LocationDataPoint],
|
|
417
|
+
attempt: Int,
|
|
418
|
+
maxRetries: Int,
|
|
419
|
+
completion: @escaping (Bool) -> Void
|
|
420
|
+
) {
|
|
421
|
+
performFirebaseWrite(batch: batch) { [weak self] success, errorCode, message in
|
|
422
|
+
guard let self = self else {
|
|
423
|
+
completion(false)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
self.handlerQueue.async {
|
|
428
|
+
if success {
|
|
429
|
+
completion(true)
|
|
430
|
+
} else {
|
|
431
|
+
// Check for non-transient errors — do not retry
|
|
432
|
+
if self.isNonTransientError(errorCode: errorCode) {
|
|
433
|
+
self.delegate?.onWriteError(
|
|
434
|
+
targetPath: self.config.path,
|
|
435
|
+
method: self.config.method,
|
|
436
|
+
errorCode: errorCode ?? "FIREBASE_WRITE_FAILED",
|
|
437
|
+
message: message ?? "Non-transient error"
|
|
438
|
+
)
|
|
439
|
+
completion(false)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if attempt > maxRetries {
|
|
444
|
+
// All retries exhausted
|
|
445
|
+
self.delegate?.onWriteError(
|
|
446
|
+
targetPath: self.config.path,
|
|
447
|
+
method: self.config.method,
|
|
448
|
+
errorCode: errorCode ?? "FIREBASE_WRITE_FAILED",
|
|
449
|
+
message: "Offline queue flush failed after \(maxRetries) retries: \(message ?? "Unknown error")"
|
|
450
|
+
)
|
|
451
|
+
completion(false)
|
|
452
|
+
} else {
|
|
453
|
+
// Schedule retry with exponential backoff
|
|
454
|
+
let delay = self.calculateBackoffDelay(attempt: attempt)
|
|
455
|
+
self.handlerQueue.asyncAfter(deadline: .now() + .milliseconds(delay)) { [weak self] in
|
|
456
|
+
guard let self = self else {
|
|
457
|
+
completion(false)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
self.executeOfflineQueueWriteWithRetry(
|
|
461
|
+
batch: batch,
|
|
462
|
+
attempt: attempt + 1,
|
|
463
|
+
maxRetries: maxRetries,
|
|
464
|
+
completion: completion
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// MARK: - Private Methods — Firebase Write Operations
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Perform the actual Firebase write operation based on the configured method.
|
|
477
|
+
*
|
|
478
|
+
* - Parameter batch: Array of location data points to write
|
|
479
|
+
* - Parameter completion: Callback with (success, errorCode, message)
|
|
480
|
+
*/
|
|
481
|
+
private func performFirebaseWrite(
|
|
482
|
+
batch: [LocationDataPoint],
|
|
483
|
+
completion: @escaping (Bool, String?, String?) -> Void
|
|
484
|
+
) {
|
|
485
|
+
switch config.method {
|
|
486
|
+
case "set":
|
|
487
|
+
performSetWrite(batch: batch, completion: completion)
|
|
488
|
+
case "push":
|
|
489
|
+
performPushWrite(batch: batch, completion: completion)
|
|
490
|
+
case "update":
|
|
491
|
+
performUpdateWrite(batch: batch, completion: completion)
|
|
492
|
+
default:
|
|
493
|
+
completion(false, "INVALID_METHOD", "Unknown method: \(config.method)")
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Perform a set (overwrite) write operation.
|
|
499
|
+
* For set, only the latest data point is written (overwrites previous).
|
|
500
|
+
*
|
|
501
|
+
* Requirement 3.2: Overwrite data at the target path.
|
|
502
|
+
*/
|
|
503
|
+
private func performSetWrite(
|
|
504
|
+
batch: [LocationDataPoint],
|
|
505
|
+
completion: @escaping (Bool, String?, String?) -> Void
|
|
506
|
+
) {
|
|
507
|
+
// For set method, use the latest point (overwrite semantics)
|
|
508
|
+
guard let latestPoint = batch.last else {
|
|
509
|
+
completion(true, nil, nil)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let data = serializeLocationDataPoint(latestPoint)
|
|
514
|
+
|
|
515
|
+
switch service {
|
|
516
|
+
case "RTDB":
|
|
517
|
+
let reference = Database.database().reference(withPath: config.path)
|
|
518
|
+
reference.setValue(data) { error, _ in
|
|
519
|
+
if let error = error {
|
|
520
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
521
|
+
} else {
|
|
522
|
+
completion(true, nil, nil)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
case "Firestore":
|
|
526
|
+
let document = Firestore.firestore().document(config.path)
|
|
527
|
+
document.setData(data) { error in
|
|
528
|
+
if let error = error {
|
|
529
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
530
|
+
} else {
|
|
531
|
+
completion(true, nil, nil)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
default:
|
|
535
|
+
completion(false, "INVALID_SERVICE", "Unknown service: \(service)")
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Perform a push (append) write operation.
|
|
541
|
+
* Each data point in the batch gets a unique auto-generated key.
|
|
542
|
+
*
|
|
543
|
+
* Requirement 3.3: Append location data as new child nodes/documents.
|
|
544
|
+
*/
|
|
545
|
+
private func performPushWrite(
|
|
546
|
+
batch: [LocationDataPoint],
|
|
547
|
+
completion: @escaping (Bool, String?, String?) -> Void
|
|
548
|
+
) {
|
|
549
|
+
if batch.isEmpty {
|
|
550
|
+
completion(true, nil, nil)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
switch service {
|
|
555
|
+
case "RTDB":
|
|
556
|
+
let reference = Database.database().reference(withPath: config.path)
|
|
557
|
+
var updates: [String: Any] = [:]
|
|
558
|
+
|
|
559
|
+
for point in batch {
|
|
560
|
+
let key = reference.childByAutoId().key ?? UUID().uuidString
|
|
561
|
+
updates[key] = serializeLocationDataPoint(point)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
reference.updateChildValues(updates) { error, _ in
|
|
565
|
+
if let error = error {
|
|
566
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
567
|
+
} else {
|
|
568
|
+
completion(true, nil, nil)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
case "Firestore":
|
|
573
|
+
let firestore = Firestore.firestore()
|
|
574
|
+
let collection = firestore.collection(config.path)
|
|
575
|
+
let writeBatch = firestore.batch()
|
|
576
|
+
|
|
577
|
+
for point in batch {
|
|
578
|
+
let docRef = collection.document()
|
|
579
|
+
writeBatch.setData(serializeLocationDataPoint(point), forDocument: docRef)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
writeBatch.commit { error in
|
|
583
|
+
if let error = error {
|
|
584
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
585
|
+
} else {
|
|
586
|
+
completion(true, nil, nil)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
default:
|
|
591
|
+
completion(false, "INVALID_SERVICE", "Unknown service: \(service)")
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Perform an update (merge) write operation.
|
|
597
|
+
* Merges location data fields into existing data without removing existing fields.
|
|
598
|
+
*
|
|
599
|
+
* Requirement 3.4: Merge location data fields into existing data.
|
|
600
|
+
*/
|
|
601
|
+
private func performUpdateWrite(
|
|
602
|
+
batch: [LocationDataPoint],
|
|
603
|
+
completion: @escaping (Bool, String?, String?) -> Void
|
|
604
|
+
) {
|
|
605
|
+
// For update method, use the latest point (merge semantics)
|
|
606
|
+
guard let latestPoint = batch.last else {
|
|
607
|
+
completion(true, nil, nil)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
let data = serializeLocationDataPoint(latestPoint)
|
|
612
|
+
|
|
613
|
+
switch service {
|
|
614
|
+
case "RTDB":
|
|
615
|
+
let reference = Database.database().reference(withPath: config.path)
|
|
616
|
+
reference.updateChildValues(data) { error, _ in
|
|
617
|
+
if let error = error {
|
|
618
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
619
|
+
} else {
|
|
620
|
+
completion(true, nil, nil)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
case "Firestore":
|
|
624
|
+
let document = Firestore.firestore().document(config.path)
|
|
625
|
+
document.updateData(data) { error in
|
|
626
|
+
if let error = error {
|
|
627
|
+
completion(false, "FIREBASE_WRITE_FAILED", error.localizedDescription)
|
|
628
|
+
} else {
|
|
629
|
+
completion(true, nil, nil)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
default:
|
|
633
|
+
completion(false, "INVALID_SERVICE", "Unknown service: \(service)")
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// MARK: - Private Methods — Offline Queue
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Enqueue a location data point to the offline queue.
|
|
641
|
+
* Enforces the 10,000 cap per target with oldest-eviction.
|
|
642
|
+
*
|
|
643
|
+
* Requirement 5.1: Persist up to 10,000 data points per target.
|
|
644
|
+
* Requirement 5.7: Evict oldest when at capacity.
|
|
645
|
+
*/
|
|
646
|
+
private func enqueueOffline(location: LocationDataPoint) {
|
|
647
|
+
guard let queueProvider = offlineQueueProvider else { return }
|
|
648
|
+
|
|
649
|
+
let currentCount = queueProvider.countForTarget(targetPath: config.path)
|
|
650
|
+
if currentCount >= TargetHandler.maxQueueSize {
|
|
651
|
+
queueProvider.evictOldestForTarget(targetPath: config.path)
|
|
652
|
+
delegate?.onQueueOverflow(targetPath: config.path)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
queueProvider.enqueueForTarget(targetPath: config.path, location: location)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// MARK: - Private Methods — Backoff Calculation
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Calculate exponential backoff delay with jitter.
|
|
662
|
+
*
|
|
663
|
+
* Formula: baseDelay × 2^(attempt-1) ± random jitter
|
|
664
|
+
* Result is never negative.
|
|
665
|
+
*
|
|
666
|
+
* - Parameter attempt: Current attempt number (1-indexed)
|
|
667
|
+
* - Returns: Delay in milliseconds
|
|
668
|
+
*/
|
|
669
|
+
func calculateBackoffDelay(attempt: Int) -> Int {
|
|
670
|
+
let exponentialDelay = Double(TargetHandler.baseDelayMs) * pow(TargetHandler.multiplier, Double(attempt - 1))
|
|
671
|
+
let jitter = Int.random(in: -TargetHandler.jitterMs...TargetHandler.jitterMs)
|
|
672
|
+
return max(0, Int(exponentialDelay) + jitter)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// MARK: - Private Methods — Helpers
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Check if an error code represents a non-transient error that should not be retried.
|
|
679
|
+
*
|
|
680
|
+
* Requirement 7.6: Do not retry auth/permission errors.
|
|
681
|
+
*/
|
|
682
|
+
private func isNonTransientError(errorCode: String?) -> Bool {
|
|
683
|
+
guard let code = errorCode else { return false }
|
|
684
|
+
return code == "PERMISSION_DENIED" || code == "UNAUTHENTICATED"
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Serialize a LocationDataPoint to a dictionary for Firebase write.
|
|
689
|
+
* Omits optional fields that are nil (never writes placeholder values).
|
|
690
|
+
*
|
|
691
|
+
* Requirement 3.7: Omit or null unavailable sensor fields.
|
|
692
|
+
*/
|
|
693
|
+
private func serializeLocationDataPoint(_ point: LocationDataPoint) -> [String: Any] {
|
|
694
|
+
var data: [String: Any] = [
|
|
695
|
+
"latitude": point.latitude,
|
|
696
|
+
"longitude": point.longitude,
|
|
697
|
+
"timestamp": point.timestamp,
|
|
698
|
+
"accuracy": point.accuracy
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
if let speed = point.speed {
|
|
702
|
+
data["speed"] = speed
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if let altitude = point.altitude {
|
|
706
|
+
data["altitude"] = altitude
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if let bearing = point.bearing {
|
|
710
|
+
data["bearing"] = bearing
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return data
|
|
714
|
+
}
|
|
715
|
+
}
|