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