@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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +396 -0
  3. package/android/build.gradle +71 -0
  4. package/android/gradle.properties +7 -0
  5. package/android/src/main/AndroidManifest.xml +40 -0
  6. package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
  7. package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
  8. package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
  9. package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
  10. package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
  11. package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
  12. package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
  13. package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
  14. package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
  15. package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
  16. package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
  17. package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
  18. package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
  19. package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
  20. package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
  21. package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
  22. package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
  23. package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
  24. package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
  25. package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
  26. package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
  27. package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
  28. package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
  29. package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
  30. package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
  31. package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
  32. package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
  33. package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
  34. package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
  35. package/ios/ActivityRecognitionHandler.swift +196 -0
  36. package/ios/BackgroundModeHelper.swift +132 -0
  37. package/ios/FirebaseSyncEngine.swift +276 -0
  38. package/ios/LiveTracking-Bridging-Header.h +2 -0
  39. package/ios/LiveTracking.m +37 -0
  40. package/ios/LiveTracking.swift +773 -0
  41. package/ios/LocationDataPoint.swift +56 -0
  42. package/ios/LocationEngine.swift +160 -0
  43. package/ios/MotionSleepManager.swift +151 -0
  44. package/ios/NetworkListener.swift +105 -0
  45. package/ios/OfflineQueueManager.swift +503 -0
  46. package/ios/PermissionHandler.swift +148 -0
  47. package/ios/QueueEngine.swift +249 -0
  48. package/ios/SyncEngineController.swift +396 -0
  49. package/ios/SyncTargetConfig.swift +36 -0
  50. package/ios/TargetHandler.swift +715 -0
  51. package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
  52. package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
  53. package/ios/Tests/LocationEngineTests.swift +244 -0
  54. package/ios/Tests/MotionSleepManagerTests.swift +355 -0
  55. package/ios/Tests/NetworkListenerTests.swift +188 -0
  56. package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
  57. package/ios/Tests/PermissionHandlerTests.swift +238 -0
  58. package/ios/Tests/QueueEngineTests.swift +346 -0
  59. package/ios/TrackingCleanup.swift +93 -0
  60. package/ios/TrackingNotificationManager.swift +187 -0
  61. package/lib/commonjs/EventEmitter.js +113 -0
  62. package/lib/commonjs/EventEmitter.js.map +1 -0
  63. package/lib/commonjs/LiveTracking.js +134 -0
  64. package/lib/commonjs/LiveTracking.js.map +1 -0
  65. package/lib/commonjs/NativeLiveTracking.js +21 -0
  66. package/lib/commonjs/NativeLiveTracking.js.map +1 -0
  67. package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
  68. package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
  69. package/lib/commonjs/index.js +103 -0
  70. package/lib/commonjs/index.js.map +1 -0
  71. package/lib/commonjs/serialization/locationSerializer.js +51 -0
  72. package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
  73. package/lib/commonjs/types.js +77 -0
  74. package/lib/commonjs/types.js.map +1 -0
  75. package/lib/commonjs/utils/distance.js +63 -0
  76. package/lib/commonjs/utils/distance.js.map +1 -0
  77. package/lib/commonjs/utils/retry.js +80 -0
  78. package/lib/commonjs/utils/retry.js.map +1 -0
  79. package/lib/commonjs/validation.js +463 -0
  80. package/lib/commonjs/validation.js.map +1 -0
  81. package/lib/module/EventEmitter.js +105 -0
  82. package/lib/module/EventEmitter.js.map +1 -0
  83. package/lib/module/LiveTracking.js +127 -0
  84. package/lib/module/LiveTracking.js.map +1 -0
  85. package/lib/module/NativeLiveTracking.js +16 -0
  86. package/lib/module/NativeLiveTracking.js.map +1 -0
  87. package/lib/module/filters/distanceTimeFilter.js +58 -0
  88. package/lib/module/filters/distanceTimeFilter.js.map +1 -0
  89. package/lib/module/index.js +32 -0
  90. package/lib/module/index.js.map +1 -0
  91. package/lib/module/serialization/locationSerializer.js +45 -0
  92. package/lib/module/serialization/locationSerializer.js.map +1 -0
  93. package/lib/module/types.js +94 -0
  94. package/lib/module/types.js.map +1 -0
  95. package/lib/module/utils/distance.js +56 -0
  96. package/lib/module/utils/distance.js.map +1 -0
  97. package/lib/module/utils/retry.js +72 -0
  98. package/lib/module/utils/retry.js.map +1 -0
  99. package/lib/module/validation.js +456 -0
  100. package/lib/module/validation.js.map +1 -0
  101. package/lib/typescript/EventEmitter.d.ts +65 -0
  102. package/lib/typescript/EventEmitter.d.ts.map +1 -0
  103. package/lib/typescript/LiveTracking.d.ts +23 -0
  104. package/lib/typescript/LiveTracking.d.ts.map +1 -0
  105. package/lib/typescript/NativeLiveTracking.d.ts +25 -0
  106. package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
  107. package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
  108. package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
  109. package/lib/typescript/index.d.ts +21 -0
  110. package/lib/typescript/index.d.ts.map +1 -0
  111. package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
  112. package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
  113. package/lib/typescript/types.d.ts +217 -0
  114. package/lib/typescript/types.d.ts.map +1 -0
  115. package/lib/typescript/utils/distance.d.ts +38 -0
  116. package/lib/typescript/utils/distance.d.ts.map +1 -0
  117. package/lib/typescript/utils/retry.d.ts +60 -0
  118. package/lib/typescript/utils/retry.d.ts.map +1 -0
  119. package/lib/typescript/validation.d.ts +26 -0
  120. package/lib/typescript/validation.d.ts.map +1 -0
  121. package/package.json +126 -0
  122. package/react-native-live-tracking.podspec +47 -0
  123. package/src/EventEmitter.ts +118 -0
  124. package/src/LiveTracking.ts +159 -0
  125. package/src/NativeLiveTracking.ts +29 -0
  126. package/src/filters/distanceTimeFilter.ts +75 -0
  127. package/src/index.ts +51 -0
  128. package/src/serialization/locationSerializer.ts +57 -0
  129. package/src/types.ts +252 -0
  130. package/src/utils/distance.ts +68 -0
  131. package/src/utils/retry.ts +75 -0
  132. 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
+ }