@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,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
|
+
}
|