@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,249 @@
1
+ import Foundation
2
+ import CoreData
3
+
4
+ /**
5
+ * CDQueuedLocation represents a queued location entry stored in CoreData.
6
+ * Used by the Offline Queue Engine to persist locations locally before syncing to Firebase.
7
+ *
8
+ * Requirements: 6.1
9
+ */
10
+ @objc(CDQueuedLocation)
11
+ class CDQueuedLocation: NSManagedObject {
12
+ @NSManaged var id: String
13
+ @NSManaged var latitude: Double
14
+ @NSManaged var longitude: Double
15
+ @NSManaged var timestamp: Int64
16
+ @NSManaged var accuracy: Double
17
+ @NSManaged var speed: Double
18
+ @NSManaged var altitude: Double
19
+ @NSManaged var bearing: Double
20
+ @NSManaged var createdAt: Int64
21
+ }
22
+
23
+ /**
24
+ * QueueEngine manages the offline location queue using CoreData.
25
+ * All operations are synchronous and intended to be called from a background thread.
26
+ *
27
+ * Responsibilities:
28
+ * - Enqueue new locations with unique IDs
29
+ * - Dequeue oldest batch for sending to Firebase
30
+ * - Remove successfully sent batches
31
+ * - Report current queue count
32
+ *
33
+ * Requirements: 5.1, 5.2, 5.3, 5.4, 6.1, 6.3
34
+ */
35
+ class QueueEngine {
36
+
37
+ private let persistentContainer: NSPersistentContainer
38
+
39
+ // MARK: - Initialization
40
+
41
+ init() {
42
+ let model = QueueEngine.createManagedObjectModel()
43
+ persistentContainer = NSPersistentContainer(name: "LiveTrackingQueue", managedObjectModel: model)
44
+
45
+ persistentContainer.loadPersistentStores { _, error in
46
+ if let error = error {
47
+ print("[QueueEngine] Failed to load persistent store: \(error.localizedDescription)")
48
+ }
49
+ }
50
+ }
51
+
52
+ // MARK: - Public Methods
53
+
54
+ /**
55
+ * Enqueue a new location into the local queue.
56
+ * Creates a CDQueuedLocation with a UUID and current system time as createdAt.
57
+ *
58
+ * - Parameter latitude: Location latitude (-90 to 90)
59
+ * - Parameter longitude: Location longitude (-180 to 180)
60
+ * - Parameter timestamp: Unix timestamp in milliseconds when location was captured
61
+ * - Parameter accuracy: Location accuracy in meters
62
+ * - Parameter speed: Speed in m/s
63
+ * - Parameter altitude: Altitude in meters
64
+ * - Parameter bearing: Bearing in degrees (0-360)
65
+ */
66
+ func enqueue(
67
+ latitude: Double,
68
+ longitude: Double,
69
+ timestamp: Int64,
70
+ accuracy: Double,
71
+ speed: Double,
72
+ altitude: Double,
73
+ bearing: Double
74
+ ) {
75
+ let context = persistentContainer.viewContext
76
+
77
+ let entity = NSEntityDescription.entity(forEntityName: "CDQueuedLocation", in: context)!
78
+ let location = CDQueuedLocation(entity: entity, insertInto: context)
79
+
80
+ location.id = UUID().uuidString
81
+ location.latitude = latitude
82
+ location.longitude = longitude
83
+ location.timestamp = timestamp
84
+ location.accuracy = accuracy
85
+ location.speed = speed
86
+ location.altitude = altitude
87
+ location.bearing = bearing
88
+ location.createdAt = Int64(Date().timeIntervalSince1970 * 1000)
89
+
90
+ do {
91
+ try context.save()
92
+ } catch {
93
+ print("[QueueEngine] Failed to enqueue location: \(error.localizedDescription)")
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Dequeue the oldest batch of locations from the queue.
99
+ * Returns locations ordered by createdAt ascending (FIFO).
100
+ *
101
+ * - Parameter size: Maximum number of locations to retrieve
102
+ * - Returns: Array of dictionaries containing location data
103
+ */
104
+ func dequeueBatch(size: Int) -> [[String: Any]] {
105
+ let context = persistentContainer.viewContext
106
+
107
+ let fetchRequest = NSFetchRequest<CDQueuedLocation>(entityName: "CDQueuedLocation")
108
+ fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
109
+ fetchRequest.fetchLimit = size
110
+
111
+ do {
112
+ let results = try context.fetch(fetchRequest)
113
+ return results.map { location in
114
+ return [
115
+ "id": location.id,
116
+ "latitude": location.latitude,
117
+ "longitude": location.longitude,
118
+ "timestamp": location.timestamp,
119
+ "accuracy": location.accuracy,
120
+ "speed": location.speed,
121
+ "altitude": location.altitude,
122
+ "bearing": location.bearing,
123
+ "createdAt": location.createdAt
124
+ ] as [String: Any]
125
+ }
126
+ } catch {
127
+ print("[QueueEngine] Failed to dequeue batch: \(error.localizedDescription)")
128
+ return []
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Remove a batch of locations from the queue by their IDs.
134
+ * Typically called after a successful Firebase sync.
135
+ *
136
+ * - Parameter ids: Array of location IDs to remove
137
+ */
138
+ func removeBatch(ids: [String]) {
139
+ let context = persistentContainer.viewContext
140
+
141
+ let fetchRequest = NSFetchRequest<CDQueuedLocation>(entityName: "CDQueuedLocation")
142
+ fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
143
+
144
+ do {
145
+ let results = try context.fetch(fetchRequest)
146
+ for object in results {
147
+ context.delete(object)
148
+ }
149
+ try context.save()
150
+ } catch {
151
+ print("[QueueEngine] Failed to remove batch: \(error.localizedDescription)")
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get the current number of locations waiting in the queue.
157
+ *
158
+ * - Returns: Count of queued locations
159
+ */
160
+ func count() -> Int {
161
+ let context = persistentContainer.viewContext
162
+
163
+ let fetchRequest = NSFetchRequest<CDQueuedLocation>(entityName: "CDQueuedLocation")
164
+
165
+ do {
166
+ return try context.count(for: fetchRequest)
167
+ } catch {
168
+ print("[QueueEngine] Failed to get count: \(error.localizedDescription)")
169
+ return 0
170
+ }
171
+ }
172
+
173
+ // MARK: - Private Methods
174
+
175
+ /**
176
+ * Creates the NSManagedObjectModel programmatically.
177
+ * This avoids the need for a .xcdatamodeld file and allows the model to be defined in code.
178
+ */
179
+ private static func createManagedObjectModel() -> NSManagedObjectModel {
180
+ let model = NSManagedObjectModel()
181
+
182
+ // Define CDQueuedLocation entity
183
+ let entity = NSEntityDescription()
184
+ entity.name = "CDQueuedLocation"
185
+ entity.managedObjectClassName = NSStringFromClass(CDQueuedLocation.self)
186
+
187
+ // Define attributes
188
+ let idAttribute = NSAttributeDescription()
189
+ idAttribute.name = "id"
190
+ idAttribute.attributeType = .stringAttributeType
191
+ idAttribute.isOptional = false
192
+
193
+ let latitudeAttribute = NSAttributeDescription()
194
+ latitudeAttribute.name = "latitude"
195
+ latitudeAttribute.attributeType = .doubleAttributeType
196
+ latitudeAttribute.isOptional = false
197
+
198
+ let longitudeAttribute = NSAttributeDescription()
199
+ longitudeAttribute.name = "longitude"
200
+ longitudeAttribute.attributeType = .doubleAttributeType
201
+ longitudeAttribute.isOptional = false
202
+
203
+ let timestampAttribute = NSAttributeDescription()
204
+ timestampAttribute.name = "timestamp"
205
+ timestampAttribute.attributeType = .integer64AttributeType
206
+ timestampAttribute.isOptional = false
207
+
208
+ let accuracyAttribute = NSAttributeDescription()
209
+ accuracyAttribute.name = "accuracy"
210
+ accuracyAttribute.attributeType = .doubleAttributeType
211
+ accuracyAttribute.isOptional = false
212
+
213
+ let speedAttribute = NSAttributeDescription()
214
+ speedAttribute.name = "speed"
215
+ speedAttribute.attributeType = .doubleAttributeType
216
+ speedAttribute.isOptional = false
217
+
218
+ let altitudeAttribute = NSAttributeDescription()
219
+ altitudeAttribute.name = "altitude"
220
+ altitudeAttribute.attributeType = .doubleAttributeType
221
+ altitudeAttribute.isOptional = false
222
+
223
+ let bearingAttribute = NSAttributeDescription()
224
+ bearingAttribute.name = "bearing"
225
+ bearingAttribute.attributeType = .doubleAttributeType
226
+ bearingAttribute.isOptional = false
227
+
228
+ let createdAtAttribute = NSAttributeDescription()
229
+ createdAtAttribute.name = "createdAt"
230
+ createdAtAttribute.attributeType = .integer64AttributeType
231
+ createdAtAttribute.isOptional = false
232
+
233
+ entity.properties = [
234
+ idAttribute,
235
+ latitudeAttribute,
236
+ longitudeAttribute,
237
+ timestampAttribute,
238
+ accuracyAttribute,
239
+ speedAttribute,
240
+ altitudeAttribute,
241
+ bearingAttribute,
242
+ createdAtAttribute
243
+ ]
244
+
245
+ model.entities = [entity]
246
+
247
+ return model
248
+ }
249
+ }
@@ -0,0 +1,396 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Protocol for receiving sync engine events (errors, warnings) to forward to JavaScript.
5
+ */
6
+ protocol SyncEngineControllerDelegate: AnyObject {
7
+ /**
8
+ * Called when a write operation fails after all retry attempts are exhausted.
9
+ *
10
+ * - Parameter targetPath: The Firebase path of the target that failed
11
+ * - Parameter method: The write method that was used
12
+ * - Parameter errorCode: Machine-readable error code
13
+ * - Parameter message: Human-readable error description
14
+ */
15
+ func onSyncError(targetPath: String, method: String, errorCode: String, message: String)
16
+
17
+ /**
18
+ * Called when the offline queue overflows for a target.
19
+ *
20
+ * - Parameter targetPath: The Firebase path of the target whose queue overflowed
21
+ */
22
+ func onSyncQueueOverflow(targetPath: String)
23
+ }
24
+
25
+ /**
26
+ * SyncEngineController replaces the monolithic FirebaseSyncEngine with a multi-target
27
+ * dispatch architecture. It parses the targets JSON configuration, instantiates one
28
+ * TargetHandler per sync target, and fans out location dispatches to all handlers
29
+ * concurrently.
30
+ *
31
+ * Key responsibilities:
32
+ * - Parse targets JSON and instantiate TargetHandlers
33
+ * - Fan out location dispatches to all handlers in parallel
34
+ * - Flush all partial batches on stop
35
+ * - Provide per-target queue status counts
36
+ * - Forward error/warning events from handlers to the JS layer via delegate
37
+ *
38
+ * Requirements: 3.1, 3.5, 4.4, 9.3, 10.1, 10.2
39
+ */
40
+ class SyncEngineController {
41
+
42
+ // MARK: - Properties
43
+
44
+ /// The Firebase service type ("RTDB" or "Firestore")
45
+ private let service: String
46
+
47
+ /// Array of target handlers, one per configured sync target
48
+ private(set) var handlers: [TargetHandler] = []
49
+
50
+ /// Offline queue manager for per-target persistence
51
+ private let offlineQueueManager: OfflineQueueManager
52
+
53
+ /// Network status provider for connectivity checks
54
+ private let networkStatusProvider: NetworkStatusProvider
55
+
56
+ /// Delegate for forwarding events to JS
57
+ weak var delegate: SyncEngineControllerDelegate?
58
+
59
+ /// Concurrent dispatch queue for parallel location fan-out
60
+ private let dispatchQueue = DispatchQueue(
61
+ label: "com.livetracking.syncengine.dispatch",
62
+ qos: .utility,
63
+ attributes: .concurrent
64
+ )
65
+
66
+ // MARK: - Initialization
67
+
68
+ /**
69
+ * Initialize the SyncEngineController by parsing a JSON configuration string.
70
+ *
71
+ * The JSON must contain:
72
+ * - "service": "RTDB" or "Firestore"
73
+ * - "targets": Array of target objects, each with "path" (string) and "method" (string),
74
+ * plus optional "batchSize" (int) and "offlineQueue" (bool)
75
+ *
76
+ * - Parameter jsonString: The full firebase configuration JSON string
77
+ * - Parameter networkStatus: Provider for checking network connectivity
78
+ * - Parameter queueManager: Offline queue manager for per-target persistence
79
+ * - Throws: SyncEngineError if JSON parsing fails or required fields are missing
80
+ *
81
+ * Requirements: 9.3
82
+ */
83
+ init(
84
+ jsonString: String,
85
+ networkStatus: NetworkStatusProvider,
86
+ queueManager: OfflineQueueManager
87
+ ) throws {
88
+ self.networkStatusProvider = networkStatus
89
+ self.offlineQueueManager = queueManager
90
+
91
+ // Parse JSON
92
+ guard let data = jsonString.data(using: .utf8) else {
93
+ throw SyncEngineError.invalidConfig("Configuration string is not valid UTF-8")
94
+ }
95
+
96
+ guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
97
+ throw SyncEngineError.invalidConfig("Configuration string is not valid JSON")
98
+ }
99
+
100
+ // Parse service
101
+ guard let parsedService = json["service"] as? String,
102
+ (parsedService == "RTDB" || parsedService == "Firestore") else {
103
+ throw SyncEngineError.invalidConfig("Invalid or missing 'service'. Must be 'RTDB' or 'Firestore'")
104
+ }
105
+ self.service = parsedService
106
+
107
+ // Parse targets array
108
+ guard let targetsArray = json["targets"] as? [[String: Any]] else {
109
+ throw SyncEngineError.invalidConfig("Missing or invalid 'targets' array")
110
+ }
111
+
112
+ if targetsArray.isEmpty {
113
+ throw SyncEngineError.invalidConfig("'targets' array must contain at least one target")
114
+ }
115
+
116
+ // Instantiate one TargetHandler per target
117
+ var parsedHandlers: [TargetHandler] = []
118
+ for (index, targetJson) in targetsArray.enumerated() {
119
+ guard let path = targetJson["path"] as? String, !path.isEmpty else {
120
+ throw SyncEngineError.invalidConfig("Target at index \(index) is missing required field 'path'")
121
+ }
122
+
123
+ guard let method = targetJson["method"] as? String,
124
+ (method == "set" || method == "push" || method == "update") else {
125
+ throw SyncEngineError.invalidConfig("Target at index \(index) is missing or has invalid 'method'. Must be 'set', 'push', or 'update'")
126
+ }
127
+
128
+ let batchSize = targetJson["batchSize"] as? Int ?? 1
129
+ let offlineQueue = targetJson["offlineQueue"] as? Bool ?? false
130
+
131
+ let config = SyncTargetConfig(
132
+ path: path,
133
+ method: method,
134
+ batchSize: batchSize,
135
+ offlineQueue: offlineQueue
136
+ )
137
+
138
+ let handler = TargetHandler(config: config, service: parsedService)
139
+ handler.delegate = self
140
+ handler.networkStatusProvider = networkStatus
141
+ handler.offlineQueueProvider = self
142
+ parsedHandlers.append(handler)
143
+ }
144
+
145
+ self.handlers = parsedHandlers
146
+ }
147
+
148
+ /**
149
+ * Initialize with pre-built components (for testing).
150
+ *
151
+ * - Parameter service: Firebase service type
152
+ * - Parameter handlers: Pre-configured target handlers
153
+ * - Parameter networkStatus: Network status provider
154
+ * - Parameter queueManager: Offline queue manager
155
+ */
156
+ init(
157
+ service: String,
158
+ handlers: [TargetHandler],
159
+ networkStatus: NetworkStatusProvider,
160
+ queueManager: OfflineQueueManager
161
+ ) {
162
+ self.service = service
163
+ self.handlers = handlers
164
+ self.networkStatusProvider = networkStatus
165
+ self.offlineQueueManager = queueManager
166
+
167
+ // Wire up delegates
168
+ for handler in handlers {
169
+ handler.delegate = self
170
+ handler.networkStatusProvider = networkStatus
171
+ handler.offlineQueueProvider = self
172
+ }
173
+ }
174
+
175
+ // MARK: - Public Methods
176
+
177
+ /**
178
+ * Dispatch a location data point to all configured sync targets concurrently.
179
+ *
180
+ * Each target handler receives the location independently and decides whether
181
+ * to accumulate (batch), write immediately, or queue offline based on its
182
+ * configuration and the current network state.
183
+ *
184
+ * Requirement 3.1: Initiate write operations to all targets in parallel.
185
+ * Requirement 3.5: Continue processing for all targets independently on failure.
186
+ *
187
+ * - Parameter location: The location data point to dispatch
188
+ */
189
+ func dispatchLocation(_ location: LocationDataPoint) {
190
+ for handler in handlers {
191
+ dispatchQueue.async {
192
+ handler.dispatchLocation(location)
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Flush all partially-filled batches for all sync targets.
199
+ * Called when tracking is stopped to ensure no data is lost.
200
+ *
201
+ * This method blocks until all handlers have completed their flush.
202
+ *
203
+ * Requirement 4.4: Flush all partial batches before stop completes.
204
+ *
205
+ * - Parameter completion: Called when all handlers have flushed
206
+ */
207
+ func flushAll(completion: (() -> Void)? = nil) {
208
+ let group = DispatchGroup()
209
+
210
+ for handler in handlers {
211
+ group.enter()
212
+ handler.flush {
213
+ group.leave()
214
+ }
215
+ }
216
+
217
+ group.notify(queue: .global(qos: .utility)) {
218
+ completion?()
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get the queued location counts per target path.
224
+ *
225
+ * Returns a dictionary mapping each configured target path to its offline queue count.
226
+ * Targets with `offlineQueue: false` report 0.
227
+ *
228
+ * Requirement 10.1: Total count across all targets.
229
+ * Requirement 10.2: Per-target queue counts.
230
+ *
231
+ * - Returns: Dictionary of [targetPath: queuedCount]
232
+ */
233
+ func getQueuedCounts() -> [String: Int] {
234
+ var counts: [String: Int] = [:]
235
+
236
+ for handler in handlers {
237
+ if handler.config.offlineQueue {
238
+ counts[handler.config.path] = offlineQueueManager.countForTarget(handler.config.path)
239
+ } else {
240
+ counts[handler.config.path] = 0
241
+ }
242
+ }
243
+
244
+ return counts
245
+ }
246
+
247
+ /**
248
+ * Get the total count of all queued locations across all targets.
249
+ *
250
+ * Requirement 10.1: Total queued location count.
251
+ *
252
+ * - Returns: Total number of queued locations
253
+ */
254
+ func getTotalQueuedCount() -> Int {
255
+ var total = 0
256
+ for handler in handlers {
257
+ if handler.config.offlineQueue {
258
+ total += offlineQueueManager.countForTarget(handler.config.path)
259
+ }
260
+ }
261
+ return total
262
+ }
263
+
264
+ /**
265
+ * Shut down the controller and all target handlers.
266
+ * Cancels all timers, pending retries, and clears buffers.
267
+ */
268
+ func shutdown() {
269
+ for handler in handlers {
270
+ handler.shutdown()
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Flush offline queues for all targets that have offlineQueue enabled.
276
+ * Called when network connectivity is restored.
277
+ *
278
+ * Each target's queue is flushed independently in chronological order.
279
+ * Respects batchSize when flushing. Stops flushing on failure and retains
280
+ * the failed point for reattempt via retry.
281
+ *
282
+ * Requirements: 5.2, 5.5, 5.6
283
+ */
284
+ func flushOfflineQueues() {
285
+ for handler in handlers {
286
+ guard handler.config.offlineQueue else { continue }
287
+
288
+ dispatchQueue.async { [weak self] in
289
+ self?.flushOfflineQueueForTarget(handler: handler)
290
+ }
291
+ }
292
+ }
293
+
294
+ // MARK: - Private Methods
295
+
296
+ /**
297
+ * Flush the offline queue for a specific target handler.
298
+ * Dequeues batches in chronological order and writes them to Firebase.
299
+ * Each batch is removed from the queue only after successful write confirmation.
300
+ * Stops flushing on failure; the failed data remains in the queue for retry.
301
+ *
302
+ * Requirements: 5.2, 5.5, 5.6
303
+ */
304
+ private func flushOfflineQueueForTarget(handler: TargetHandler) {
305
+ let batchSize = handler.config.batchSize > 1 ? handler.config.batchSize : 20
306
+
307
+ // Flush in a loop: dequeue a batch, write it, remove on success, stop on failure
308
+ flushNextBatch(handler: handler, batchSize: batchSize)
309
+ }
310
+
311
+ /**
312
+ * Recursively flush the next batch for a target.
313
+ * Stops when the queue is empty or a write fails.
314
+ */
315
+ private func flushNextBatch(handler: TargetHandler, batchSize: Int) {
316
+ let batch = offlineQueueManager.dequeueBatch(targetPath: handler.config.path, size: batchSize)
317
+
318
+ guard !batch.isEmpty else { return }
319
+
320
+ let locations = batch.map { $0.location }
321
+ let ids = batch.map { $0.id }
322
+
323
+ handler.writeOfflineQueueBatch(locations) { [weak self] success in
324
+ guard let self = self else { return }
325
+
326
+ if success {
327
+ // Remove from queue only after successful write confirmation
328
+ self.offlineQueueManager.removeBatch(ids: ids)
329
+
330
+ // Continue flushing the next batch
331
+ self.dispatchQueue.async {
332
+ self.flushNextBatch(handler: handler, batchSize: batchSize)
333
+ }
334
+ }
335
+ // On failure: stop flushing. The failed data remains in the queue.
336
+ // The TargetHandler's retry mechanism will have already been exhausted,
337
+ // and the data stays in the queue for the next network restore event.
338
+ }
339
+ }
340
+ }
341
+
342
+ // MARK: - TargetHandlerDelegate
343
+
344
+ extension SyncEngineController: TargetHandlerDelegate {
345
+ func onWriteError(targetPath: String, method: String, errorCode: String, message: String) {
346
+ delegate?.onSyncError(targetPath: targetPath, method: method, errorCode: errorCode, message: message)
347
+ }
348
+
349
+ func onQueueOverflow(targetPath: String) {
350
+ delegate?.onSyncQueueOverflow(targetPath: targetPath)
351
+ }
352
+ }
353
+
354
+ // MARK: - OfflineQueueProvider
355
+
356
+ extension SyncEngineController: OfflineQueueProvider {
357
+ func enqueueForTarget(targetPath: String, location: LocationDataPoint) {
358
+ offlineQueueManager.enqueue(location: location, targetPath: targetPath)
359
+ }
360
+
361
+ func countForTarget(targetPath: String) -> Int {
362
+ return offlineQueueManager.countForTarget(targetPath)
363
+ }
364
+
365
+ func evictOldestForTarget(targetPath: String) {
366
+ // The OfflineQueueManager handles eviction internally in enqueue
367
+ // when the cap is reached, but we provide this for explicit eviction
368
+ let batch = offlineQueueManager.dequeueBatch(targetPath: targetPath, size: 1)
369
+ if let oldest = batch.first {
370
+ offlineQueueManager.removeBatch(ids: [oldest.id])
371
+ }
372
+ }
373
+ }
374
+
375
+ // MARK: - SyncEngineError
376
+
377
+ /**
378
+ * Errors that can occur during SyncEngineController initialization.
379
+ */
380
+ enum SyncEngineError: Error, LocalizedError {
381
+ case invalidConfig(String)
382
+
383
+ var errorDescription: String? {
384
+ switch self {
385
+ case .invalidConfig(let message):
386
+ return message
387
+ }
388
+ }
389
+
390
+ var errorCode: String {
391
+ switch self {
392
+ case .invalidConfig:
393
+ return "INVALID_CONFIG"
394
+ }
395
+ }
396
+ }
@@ -0,0 +1,36 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * Configuration for a single sync target.
5
+ *
6
+ * Each sync target defines a Firebase path, write method, optional batch size,
7
+ * and optional offline queue preference. The native SyncEngineController
8
+ * instantiates one TargetHandler per SyncTargetConfig.
9
+ *
10
+ * - path: Firebase path to write location data to
11
+ * - method: Write method — "set" (overwrite), "push" (append), or "update" (merge)
12
+ * - batchSize: Number of location points to accumulate before writing. Default: 1 (immediate write)
13
+ * - offlineQueue: Whether to persist data locally when offline. Default: false
14
+ *
15
+ * Requirements: 9.3
16
+ */
17
+ struct SyncTargetConfig {
18
+ /// Firebase path to write location data to
19
+ let path: String
20
+
21
+ /// Write method: "set" (overwrite), "push" (append), or "update" (merge)
22
+ let method: String
23
+
24
+ /// Number of location points to accumulate before writing. Default: 1 (immediate write)
25
+ let batchSize: Int
26
+
27
+ /// Whether to persist data locally when offline. Default: false
28
+ let offlineQueue: Bool
29
+
30
+ init(path: String, method: String, batchSize: Int = 1, offlineQueue: Bool = false) {
31
+ self.path = path
32
+ self.method = method
33
+ self.batchSize = batchSize
34
+ self.offlineQueue = offlineQueue
35
+ }
36
+ }