@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,503 @@
1
+ import Foundation
2
+ import CoreData
3
+
4
+ /**
5
+ * CDQueuedLocationV2 represents a queued location entry stored in CoreData
6
+ * with per-target isolation via the `targetPath` attribute.
7
+ *
8
+ * This replaces the original CDQueuedLocation entity by adding targetPath
9
+ * to support independent offline queues per sync target.
10
+ *
11
+ * Requirements: 5.1, 5.4, 5.7, 10.2
12
+ */
13
+ @objc(CDQueuedLocationV2)
14
+ class CDQueuedLocationV2: NSManagedObject {
15
+ @NSManaged var id: String
16
+ @NSManaged var latitude: Double
17
+ @NSManaged var longitude: Double
18
+ @NSManaged var timestamp: Int64
19
+ @NSManaged var accuracy: Double
20
+ @NSManaged var speed: Double
21
+ @NSManaged var altitude: Double
22
+ @NSManaged var bearing: Double
23
+ @NSManaged var createdAt: Int64
24
+ @NSManaged var targetPath: String
25
+
26
+ /// Whether speed value is available (non-placeholder)
27
+ @NSManaged var hasSpeed: Bool
28
+ /// Whether altitude value is available (non-placeholder)
29
+ @NSManaged var hasAltitude: Bool
30
+ /// Whether bearing value is available (non-placeholder)
31
+ @NSManaged var hasBearing: Bool
32
+ }
33
+
34
+ /**
35
+ * OfflineQueueManager manages the CoreData-based offline queue with per-target isolation.
36
+ *
37
+ * Each sync target that has `offlineQueue: true` gets its own isolated queue.
38
+ * The manager enforces a 10,000 data point cap per target with oldest-eviction
39
+ * when the cap is reached.
40
+ *
41
+ * Key responsibilities:
42
+ * - Enqueue location data points for a specific target path
43
+ * - Dequeue oldest batch for a specific target (chronological order)
44
+ * - Remove successfully synced entries
45
+ * - Enforce 10,000 cap per target with oldest-eviction
46
+ * - Provide per-target and total count queries
47
+ *
48
+ * Requirements: 5.1, 5.4, 5.7, 10.2
49
+ */
50
+ class OfflineQueueManager {
51
+
52
+ // MARK: - Constants
53
+
54
+ /// Maximum number of queued data points per target
55
+ static let maxQueueSizePerTarget: Int = 10_000
56
+
57
+ /// CoreData entity name
58
+ private static let entityName = "CDQueuedLocationV2"
59
+
60
+ // MARK: - Properties
61
+
62
+ private let persistentContainer: NSPersistentContainer
63
+ private let queue = DispatchQueue(label: "com.livetracking.offlinequeue", qos: .utility)
64
+
65
+ /// Callback for queue overflow warnings
66
+ var onQueueOverflow: ((_ targetPath: String) -> Void)?
67
+
68
+ // MARK: - Initialization
69
+
70
+ init() {
71
+ let model = OfflineQueueManager.createManagedObjectModel()
72
+ persistentContainer = NSPersistentContainer(
73
+ name: "LiveTrackingOfflineQueue",
74
+ managedObjectModel: model
75
+ )
76
+
77
+ persistentContainer.loadPersistentStores { _, error in
78
+ if let error = error {
79
+ print("[OfflineQueueManager] Failed to load persistent store: \(error.localizedDescription)")
80
+ }
81
+ }
82
+ }
83
+
84
+ /// Initializer for testing with a custom persistent container
85
+ init(persistentContainer: NSPersistentContainer) {
86
+ self.persistentContainer = persistentContainer
87
+ }
88
+
89
+ // MARK: - Public Methods
90
+
91
+ /**
92
+ * Enqueue a location data point for a specific target path.
93
+ *
94
+ * If the queue for the target has reached the 10,000 cap, the oldest
95
+ * entry is evicted to make room for the new data point, and a warning
96
+ * is emitted via the `onQueueOverflow` callback.
97
+ *
98
+ * - Parameter location: The location data point to enqueue
99
+ * - Parameter targetPath: The sync target path this location belongs to
100
+ *
101
+ * Requirements: 5.1, 5.7
102
+ */
103
+ func enqueue(location: LocationDataPoint, targetPath: String) {
104
+ let context = persistentContainer.newBackgroundContext()
105
+ context.performAndWait {
106
+ // Check current count for this target
107
+ let currentCount = self.fetchCount(for: targetPath, in: context)
108
+
109
+ // Enforce 10,000 cap with oldest-eviction
110
+ if currentCount >= OfflineQueueManager.maxQueueSizePerTarget {
111
+ self.evictOldest(for: targetPath, in: context)
112
+ self.onQueueOverflow?(targetPath)
113
+ }
114
+
115
+ // Insert new entry
116
+ guard let entity = NSEntityDescription.entity(
117
+ forEntityName: OfflineQueueManager.entityName,
118
+ in: context
119
+ ) else {
120
+ print("[OfflineQueueManager] Failed to get entity description")
121
+ return
122
+ }
123
+
124
+ let entry = CDQueuedLocationV2(entity: entity, insertInto: context)
125
+ entry.id = UUID().uuidString
126
+ entry.latitude = location.latitude
127
+ entry.longitude = location.longitude
128
+ entry.timestamp = location.timestamp
129
+ entry.accuracy = location.accuracy
130
+ entry.speed = location.speed ?? 0
131
+ entry.altitude = location.altitude ?? 0
132
+ entry.bearing = location.bearing ?? 0
133
+ entry.hasSpeed = location.speed != nil
134
+ entry.hasAltitude = location.altitude != nil
135
+ entry.hasBearing = location.bearing != nil
136
+ entry.createdAt = Int64(Date().timeIntervalSince1970 * 1000)
137
+ entry.targetPath = targetPath
138
+
139
+ do {
140
+ try context.save()
141
+ } catch {
142
+ print("[OfflineQueueManager] Failed to enqueue location: \(error.localizedDescription)")
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Dequeue the oldest batch of locations for a specific target.
149
+ * Returns locations ordered by createdAt ascending (FIFO / chronological).
150
+ *
151
+ * - Parameter targetPath: The sync target path to dequeue from
152
+ * - Parameter size: Maximum number of locations to retrieve
153
+ * - Returns: Array of LocationDataPoint entries in chronological order
154
+ *
155
+ * Requirements: 5.2, 5.4
156
+ */
157
+ func dequeueBatch(targetPath: String, size: Int) -> [(id: String, location: LocationDataPoint)] {
158
+ let context = persistentContainer.viewContext
159
+ var results: [(id: String, location: LocationDataPoint)] = []
160
+
161
+ context.performAndWait {
162
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
163
+ fetchRequest.predicate = NSPredicate(format: "targetPath == %@", targetPath)
164
+ fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
165
+ fetchRequest.fetchLimit = size
166
+
167
+ do {
168
+ let entries = try context.fetch(fetchRequest)
169
+ results = entries.map { entry in
170
+ let location = LocationDataPoint(
171
+ latitude: entry.latitude,
172
+ longitude: entry.longitude,
173
+ timestamp: entry.timestamp,
174
+ accuracy: entry.accuracy,
175
+ speed: entry.hasSpeed ? entry.speed : nil,
176
+ altitude: entry.hasAltitude ? entry.altitude : nil,
177
+ bearing: entry.hasBearing ? entry.bearing : nil
178
+ )
179
+ return (id: entry.id, location: location)
180
+ }
181
+ } catch {
182
+ print("[OfflineQueueManager] Failed to dequeue batch: \(error.localizedDescription)")
183
+ }
184
+ }
185
+
186
+ return results
187
+ }
188
+
189
+ /**
190
+ * Remove entries from the queue by their IDs.
191
+ * Called after successful Firebase write confirmation.
192
+ *
193
+ * - Parameter ids: Array of entry IDs to remove
194
+ *
195
+ * Requirements: 5.2
196
+ */
197
+ func removeBatch(ids: [String]) {
198
+ let context = persistentContainer.newBackgroundContext()
199
+ context.performAndWait {
200
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
201
+ fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
202
+
203
+ do {
204
+ let entries = try context.fetch(fetchRequest)
205
+ for entry in entries {
206
+ context.delete(entry)
207
+ }
208
+ try context.save()
209
+ } catch {
210
+ print("[OfflineQueueManager] Failed to remove batch: \(error.localizedDescription)")
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Get the count of queued locations for a specific target.
217
+ *
218
+ * - Parameter targetPath: The sync target path to count
219
+ * - Returns: Number of queued locations for the target
220
+ *
221
+ * Requirements: 10.2
222
+ */
223
+ func countForTarget(_ targetPath: String) -> Int {
224
+ let context = persistentContainer.viewContext
225
+ return fetchCount(for: targetPath, in: context)
226
+ }
227
+
228
+ /**
229
+ * Get the total count of all queued locations across all targets.
230
+ *
231
+ * - Returns: Total number of queued locations
232
+ *
233
+ * Requirements: 10.1
234
+ */
235
+ func totalCount() -> Int {
236
+ let context = persistentContainer.viewContext
237
+ var count = 0
238
+
239
+ context.performAndWait {
240
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
241
+ do {
242
+ count = try context.count(for: fetchRequest)
243
+ } catch {
244
+ print("[OfflineQueueManager] Failed to get total count: \(error.localizedDescription)")
245
+ }
246
+ }
247
+
248
+ return count
249
+ }
250
+
251
+ /**
252
+ * Get queued location counts grouped by target path.
253
+ * Returns a dictionary mapping each target path to its queue count.
254
+ *
255
+ * - Returns: Dictionary of [targetPath: count]
256
+ *
257
+ * Requirements: 10.2
258
+ */
259
+ func countsByTarget() -> [String: Int] {
260
+ let context = persistentContainer.viewContext
261
+ var counts: [String: Int] = [:]
262
+
263
+ context.performAndWait {
264
+ let fetchRequest = NSFetchRequest<NSDictionary>(entityName: OfflineQueueManager.entityName)
265
+ fetchRequest.resultType = .dictionaryResultType
266
+
267
+ let countExpression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: "id")])
268
+ let countDescription = NSExpressionDescription()
269
+ countDescription.name = "count"
270
+ countDescription.expression = countExpression
271
+ countDescription.expressionResultType = .integer64AttributeType
272
+
273
+ fetchRequest.propertiesToFetch = ["targetPath", countDescription]
274
+ fetchRequest.propertiesToGroupBy = ["targetPath"]
275
+
276
+ do {
277
+ let results = try context.fetch(fetchRequest)
278
+ for result in results {
279
+ if let path = result["targetPath"] as? String,
280
+ let count = result["count"] as? Int {
281
+ counts[path] = count
282
+ }
283
+ }
284
+ } catch {
285
+ print("[OfflineQueueManager] Failed to get counts by target: \(error.localizedDescription)")
286
+ }
287
+ }
288
+
289
+ return counts
290
+ }
291
+
292
+ /**
293
+ * Remove all queued entries for a specific target.
294
+ * Useful when a target is removed from configuration.
295
+ *
296
+ * - Parameter targetPath: The sync target path to clear
297
+ */
298
+ func clearTarget(_ targetPath: String) {
299
+ let context = persistentContainer.newBackgroundContext()
300
+ context.performAndWait {
301
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
302
+ fetchRequest.predicate = NSPredicate(format: "targetPath == %@", targetPath)
303
+
304
+ do {
305
+ let entries = try context.fetch(fetchRequest)
306
+ for entry in entries {
307
+ context.delete(entry)
308
+ }
309
+ try context.save()
310
+ } catch {
311
+ print("[OfflineQueueManager] Failed to clear target: \(error.localizedDescription)")
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Remove all queued entries across all targets.
318
+ */
319
+ func clearAll() {
320
+ let context = persistentContainer.newBackgroundContext()
321
+ context.performAndWait {
322
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
323
+
324
+ do {
325
+ let entries = try context.fetch(fetchRequest)
326
+ for entry in entries {
327
+ context.delete(entry)
328
+ }
329
+ try context.save()
330
+ } catch {
331
+ print("[OfflineQueueManager] Failed to clear all: \(error.localizedDescription)")
332
+ }
333
+ }
334
+ }
335
+
336
+ // MARK: - Private Methods
337
+
338
+ /**
339
+ * Fetch the count of entries for a specific target path.
340
+ */
341
+ private func fetchCount(for targetPath: String, in context: NSManagedObjectContext) -> Int {
342
+ var count = 0
343
+ context.performAndWait {
344
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
345
+ fetchRequest.predicate = NSPredicate(format: "targetPath == %@", targetPath)
346
+
347
+ do {
348
+ count = try context.count(for: fetchRequest)
349
+ } catch {
350
+ print("[OfflineQueueManager] Failed to fetch count for target: \(error.localizedDescription)")
351
+ }
352
+ }
353
+ return count
354
+ }
355
+
356
+ /**
357
+ * Evict the oldest entry for a specific target path.
358
+ * Called when the queue reaches the 10,000 cap.
359
+ *
360
+ * Requirements: 5.7
361
+ */
362
+ private func evictOldest(for targetPath: String, in context: NSManagedObjectContext) {
363
+ let fetchRequest = NSFetchRequest<CDQueuedLocationV2>(entityName: OfflineQueueManager.entityName)
364
+ fetchRequest.predicate = NSPredicate(format: "targetPath == %@", targetPath)
365
+ fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
366
+ fetchRequest.fetchLimit = 1
367
+
368
+ do {
369
+ let results = try context.fetch(fetchRequest)
370
+ if let oldest = results.first {
371
+ context.delete(oldest)
372
+ try context.save()
373
+ }
374
+ } catch {
375
+ print("[OfflineQueueManager] Failed to evict oldest entry: \(error.localizedDescription)")
376
+ }
377
+ }
378
+
379
+ // MARK: - CoreData Model Definition
380
+
381
+ /**
382
+ * Creates the NSManagedObjectModel programmatically with the targetPath attribute.
383
+ * This defines the CDQueuedLocationV2 entity with all required attributes
384
+ * including the new `targetPath` field for per-target queue isolation.
385
+ */
386
+ static func createManagedObjectModel() -> NSManagedObjectModel {
387
+ let model = NSManagedObjectModel()
388
+
389
+ // Define CDQueuedLocationV2 entity
390
+ let entity = NSEntityDescription()
391
+ entity.name = "CDQueuedLocationV2"
392
+ entity.managedObjectClassName = NSStringFromClass(CDQueuedLocationV2.self)
393
+
394
+ // Define attributes
395
+ let idAttribute = NSAttributeDescription()
396
+ idAttribute.name = "id"
397
+ idAttribute.attributeType = .stringAttributeType
398
+ idAttribute.isOptional = false
399
+
400
+ let latitudeAttribute = NSAttributeDescription()
401
+ latitudeAttribute.name = "latitude"
402
+ latitudeAttribute.attributeType = .doubleAttributeType
403
+ latitudeAttribute.isOptional = false
404
+
405
+ let longitudeAttribute = NSAttributeDescription()
406
+ longitudeAttribute.name = "longitude"
407
+ longitudeAttribute.attributeType = .doubleAttributeType
408
+ longitudeAttribute.isOptional = false
409
+
410
+ let timestampAttribute = NSAttributeDescription()
411
+ timestampAttribute.name = "timestamp"
412
+ timestampAttribute.attributeType = .integer64AttributeType
413
+ timestampAttribute.isOptional = false
414
+
415
+ let accuracyAttribute = NSAttributeDescription()
416
+ accuracyAttribute.name = "accuracy"
417
+ accuracyAttribute.attributeType = .doubleAttributeType
418
+ accuracyAttribute.isOptional = false
419
+
420
+ let speedAttribute = NSAttributeDescription()
421
+ speedAttribute.name = "speed"
422
+ speedAttribute.attributeType = .doubleAttributeType
423
+ speedAttribute.isOptional = false
424
+ speedAttribute.defaultValue = 0.0
425
+
426
+ let altitudeAttribute = NSAttributeDescription()
427
+ altitudeAttribute.name = "altitude"
428
+ altitudeAttribute.attributeType = .doubleAttributeType
429
+ altitudeAttribute.isOptional = false
430
+ altitudeAttribute.defaultValue = 0.0
431
+
432
+ let bearingAttribute = NSAttributeDescription()
433
+ bearingAttribute.name = "bearing"
434
+ bearingAttribute.attributeType = .doubleAttributeType
435
+ bearingAttribute.isOptional = false
436
+ bearingAttribute.defaultValue = 0.0
437
+
438
+ let createdAtAttribute = NSAttributeDescription()
439
+ createdAtAttribute.name = "createdAt"
440
+ createdAtAttribute.attributeType = .integer64AttributeType
441
+ createdAtAttribute.isOptional = false
442
+
443
+ let targetPathAttribute = NSAttributeDescription()
444
+ targetPathAttribute.name = "targetPath"
445
+ targetPathAttribute.attributeType = .stringAttributeType
446
+ targetPathAttribute.isOptional = false
447
+ targetPathAttribute.defaultValue = ""
448
+
449
+ let hasSpeedAttribute = NSAttributeDescription()
450
+ hasSpeedAttribute.name = "hasSpeed"
451
+ hasSpeedAttribute.attributeType = .booleanAttributeType
452
+ hasSpeedAttribute.isOptional = false
453
+ hasSpeedAttribute.defaultValue = false
454
+
455
+ let hasAltitudeAttribute = NSAttributeDescription()
456
+ hasAltitudeAttribute.name = "hasAltitude"
457
+ hasAltitudeAttribute.attributeType = .booleanAttributeType
458
+ hasAltitudeAttribute.isOptional = false
459
+ hasAltitudeAttribute.defaultValue = false
460
+
461
+ let hasBearingAttribute = NSAttributeDescription()
462
+ hasBearingAttribute.name = "hasBearing"
463
+ hasBearingAttribute.attributeType = .booleanAttributeType
464
+ hasBearingAttribute.isOptional = false
465
+ hasBearingAttribute.defaultValue = false
466
+
467
+ entity.properties = [
468
+ idAttribute,
469
+ latitudeAttribute,
470
+ longitudeAttribute,
471
+ timestampAttribute,
472
+ accuracyAttribute,
473
+ speedAttribute,
474
+ altitudeAttribute,
475
+ bearingAttribute,
476
+ createdAtAttribute,
477
+ targetPathAttribute,
478
+ hasSpeedAttribute,
479
+ hasAltitudeAttribute,
480
+ hasBearingAttribute
481
+ ]
482
+
483
+ // Add index on targetPath + createdAt for efficient per-target queries
484
+ let targetPathIndex = NSFetchIndexDescription(
485
+ name: "idx_targetPath_createdAt",
486
+ elements: [
487
+ NSFetchIndexElementDescription(
488
+ property: targetPathAttribute,
489
+ collationType: .binary
490
+ ),
491
+ NSFetchIndexElementDescription(
492
+ property: createdAtAttribute,
493
+ collationType: .binary
494
+ )
495
+ ]
496
+ )
497
+ entity.indexes = [targetPathIndex]
498
+
499
+ model.entities = [entity]
500
+
501
+ return model
502
+ }
503
+ }
@@ -0,0 +1,148 @@
1
+ import Foundation
2
+ import CoreLocation
3
+
4
+ /**
5
+ * Represents the result of a permission or service check.
6
+ */
7
+ enum PermissionResult {
8
+ /// All required permissions/services are available.
9
+ case granted
10
+ /// A permission is denied or a required service is disabled.
11
+ case denied(errorCode: String, message: String)
12
+ }
13
+
14
+ /**
15
+ * Handles location permission checks for the LiveTracking library on iOS.
16
+ * Provides clear error codes when permissions are denied or GPS is disabled.
17
+ *
18
+ * Validates: Requirements 10.1, 10.2
19
+ */
20
+ class PermissionHandler {
21
+
22
+ // MARK: - Error Code Constants
23
+
24
+ static let ERROR_PERMISSION_DENIED = "PERMISSION_DENIED"
25
+ static let ERROR_GPS_DISABLED = "GPS_DISABLED"
26
+
27
+ // MARK: - Private Properties
28
+
29
+ private let locationManager: CLLocationManager
30
+
31
+ // MARK: - Initialization
32
+
33
+ init(locationManager: CLLocationManager = CLLocationManager()) {
34
+ self.locationManager = locationManager
35
+ }
36
+
37
+ // MARK: - Permission Checks
38
+
39
+ /**
40
+ * Checks the current location permission status.
41
+ *
42
+ * Uses `CLLocationManager.authorizationStatus()` for iOS < 14,
43
+ * and `locationManager.authorizationStatus` for iOS 14+.
44
+ *
45
+ * - Returns: `.granted` if authorized (whenInUse or always),
46
+ * `.denied` with PERMISSION_DENIED error code otherwise.
47
+ */
48
+ func checkLocationPermission() -> PermissionResult {
49
+ let status: CLAuthorizationStatus
50
+
51
+ if #available(iOS 14.0, *) {
52
+ status = locationManager.authorizationStatus
53
+ } else {
54
+ status = CLLocationManager.authorizationStatus()
55
+ }
56
+
57
+ switch status {
58
+ case .authorizedWhenInUse, .authorizedAlways:
59
+ return .granted
60
+
61
+ case .denied:
62
+ return .denied(
63
+ errorCode: PermissionHandler.ERROR_PERMISSION_DENIED,
64
+ message: "Location permission denied by user. Please grant location permission in Settings to enable tracking."
65
+ )
66
+
67
+ case .restricted:
68
+ return .denied(
69
+ errorCode: PermissionHandler.ERROR_PERMISSION_DENIED,
70
+ message: "Location permission restricted. Location services may be restricted by parental controls or device policy."
71
+ )
72
+
73
+ case .notDetermined:
74
+ return .denied(
75
+ errorCode: PermissionHandler.ERROR_PERMISSION_DENIED,
76
+ message: "Location permission not determined. Please request location permission before starting tracking."
77
+ )
78
+
79
+ @unknown default:
80
+ return .denied(
81
+ errorCode: PermissionHandler.ERROR_PERMISSION_DENIED,
82
+ message: "Unknown location authorization status. Please check location permissions in Settings."
83
+ )
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Checks if location services are enabled on the device.
89
+ *
90
+ * - Returns: `.granted` if location services are enabled,
91
+ * `.denied` with GPS_DISABLED error code if disabled.
92
+ */
93
+ func checkLocationServicesEnabled() -> PermissionResult {
94
+ if CLLocationManager.locationServicesEnabled() {
95
+ return .granted
96
+ } else {
97
+ return .denied(
98
+ errorCode: PermissionHandler.ERROR_GPS_DISABLED,
99
+ message: "GPS/Location services are disabled. Please enable location services in device Settings."
100
+ )
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Performs a comprehensive check of all requirements for location tracking.
106
+ * Checks location services enabled first, then permission status.
107
+ *
108
+ * - Returns: `.granted` if all checks pass, or the first `.denied` encountered.
109
+ */
110
+ func checkAllRequirements() -> PermissionResult {
111
+ // Check location services enabled first
112
+ let servicesResult = checkLocationServicesEnabled()
113
+ if case .denied = servicesResult {
114
+ return servicesResult
115
+ }
116
+
117
+ // Then check permission status
118
+ let permissionResult = checkLocationPermission()
119
+ if case .denied = permissionResult {
120
+ return permissionResult
121
+ }
122
+
123
+ return .granted
124
+ }
125
+
126
+ /**
127
+ * Requests "Always" location authorization from the user.
128
+ * This enables background location updates.
129
+ *
130
+ * Note: NSLocationAlwaysAndWhenInUseUsageDescription must be configured in Info.plist.
131
+ *
132
+ * - Parameter locationManager: The CLLocationManager instance to request authorization on.
133
+ */
134
+ func requestAlwaysAuthorization(locationManager: CLLocationManager) {
135
+ locationManager.requestAlwaysAuthorization()
136
+ }
137
+
138
+ /**
139
+ * Requests "When In Use" location authorization from the user.
140
+ *
141
+ * Note: NSLocationWhenInUseUsageDescription must be configured in Info.plist.
142
+ *
143
+ * - Parameter locationManager: The CLLocationManager instance to request authorization on.
144
+ */
145
+ func requestWhenInUseAuthorization(locationManager: CLLocationManager) {
146
+ locationManager.requestWhenInUseAuthorization()
147
+ }
148
+ }