@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,601 @@
|
|
|
1
|
+
package com.livetracking.sync
|
|
2
|
+
|
|
3
|
+
import com.google.firebase.database.FirebaseDatabase
|
|
4
|
+
import com.google.firebase.firestore.FirebaseFirestore
|
|
5
|
+
import com.livetracking.queue.QueuedLocation
|
|
6
|
+
import java.util.Timer
|
|
7
|
+
import java.util.TimerTask
|
|
8
|
+
import java.util.concurrent.ExecutorService
|
|
9
|
+
import java.util.concurrent.Executors
|
|
10
|
+
import java.util.concurrent.Future
|
|
11
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
12
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
13
|
+
import kotlin.math.pow
|
|
14
|
+
import kotlin.random.Random
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Interface for per-target offline queue operations.
|
|
18
|
+
* Implemented by QueueEngine (task 5.6) to provide target-scoped persistence.
|
|
19
|
+
*/
|
|
20
|
+
interface OfflineQueueProvider {
|
|
21
|
+
fun enqueueForTarget(
|
|
22
|
+
targetPath: String,
|
|
23
|
+
latitude: Double,
|
|
24
|
+
longitude: Double,
|
|
25
|
+
timestamp: Long,
|
|
26
|
+
accuracy: Float,
|
|
27
|
+
speed: Float?,
|
|
28
|
+
altitude: Double?,
|
|
29
|
+
bearing: Float?
|
|
30
|
+
): Future<Unit>
|
|
31
|
+
|
|
32
|
+
fun dequeueBatchForTarget(targetPath: String, size: Int): Future<List<QueuedLocation>>
|
|
33
|
+
fun countForTarget(targetPath: String): Future<Int>
|
|
34
|
+
fun evictOldestForTarget(targetPath: String): Future<Unit>
|
|
35
|
+
fun removeBatchForTarget(ids: List<String>): Future<Unit>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Callback interface for target write operations.
|
|
40
|
+
*/
|
|
41
|
+
interface TargetWriteCallback {
|
|
42
|
+
fun onSuccess()
|
|
43
|
+
fun onError(errorCode: String, message: String)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Listener interface for error/warning events emitted by a TargetHandler.
|
|
48
|
+
*/
|
|
49
|
+
interface TargetEventListener {
|
|
50
|
+
/**
|
|
51
|
+
* Called when a write operation fails after all retries are exhausted.
|
|
52
|
+
*/
|
|
53
|
+
fun onWriteError(targetPath: String, method: String, errorCode: String, message: String)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Called when the offline queue overflows (10,000 cap reached).
|
|
57
|
+
*/
|
|
58
|
+
fun onQueueOverflow(targetPath: String)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* TargetHandler manages a single sync target's write lifecycle.
|
|
63
|
+
*
|
|
64
|
+
* Responsibilities:
|
|
65
|
+
* - Batch accumulation: buffers location data points until batchSize is reached
|
|
66
|
+
* - 30-second batch timeout: flushes partial batches after 30s of inactivity
|
|
67
|
+
* - Write execution: dispatches writes to Firebase using the configured method (set/push/update)
|
|
68
|
+
* - Retry cancellation: for set/update targets, cancels in-progress retries when newer data arrives
|
|
69
|
+
* - Offline queue delegation: enqueues data when offline and offlineQueue is enabled, discards otherwise
|
|
70
|
+
*
|
|
71
|
+
* Each TargetHandler operates independently and does not block other targets.
|
|
72
|
+
*
|
|
73
|
+
* @param config The sync target configuration
|
|
74
|
+
* @param firebaseService The Firebase service type ("RTDB" or "Firestore")
|
|
75
|
+
* @param networkChecker Function that returns current online status
|
|
76
|
+
* @param offlineQueueProvider Optional provider for offline persistence (null if offlineQueue is disabled)
|
|
77
|
+
* @param eventListener Listener for error/warning events
|
|
78
|
+
*/
|
|
79
|
+
class TargetHandler(
|
|
80
|
+
private val config: SyncTargetConfig,
|
|
81
|
+
private val firebaseService: String,
|
|
82
|
+
private val networkChecker: () -> Boolean,
|
|
83
|
+
private val offlineQueueProvider: OfflineQueueProvider? = null,
|
|
84
|
+
private val eventListener: TargetEventListener? = null
|
|
85
|
+
) {
|
|
86
|
+
|
|
87
|
+
/** The Firebase path this handler writes to. */
|
|
88
|
+
val targetPath: String get() = config.path
|
|
89
|
+
|
|
90
|
+
companion object {
|
|
91
|
+
private const val BATCH_TIMEOUT_MS = 30_000L
|
|
92
|
+
private const val BASE_DELAY_MS = 1000L
|
|
93
|
+
private const val MULTIPLIER = 2.0
|
|
94
|
+
private const val JITTER_MS = 200L
|
|
95
|
+
private const val MAX_QUEUE_SIZE = 10_000
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
99
|
+
private val batch: MutableList<LocationDataPoint> = mutableListOf()
|
|
100
|
+
private var batchTimer: Timer? = null
|
|
101
|
+
private val retryGeneration = AtomicInteger(0)
|
|
102
|
+
private val isRetrying = AtomicBoolean(false)
|
|
103
|
+
private val isShutdown = AtomicBoolean(false)
|
|
104
|
+
|
|
105
|
+
private val maxRetries: Int
|
|
106
|
+
get() = if (config.method == "push") 5 else 3
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Dispatch a new location data point to this target.
|
|
110
|
+
*
|
|
111
|
+
* If the device is offline and offlineQueue is enabled, the point is queued.
|
|
112
|
+
* If the device is offline and offlineQueue is disabled, the point is discarded.
|
|
113
|
+
* If online, the point is accumulated in the batch buffer.
|
|
114
|
+
* When the batch reaches batchSize, it is flushed (written to Firebase).
|
|
115
|
+
* For set/update targets, any in-progress retry is cancelled when new data arrives.
|
|
116
|
+
*
|
|
117
|
+
* @param location The location data point to dispatch
|
|
118
|
+
*/
|
|
119
|
+
fun dispatch(location: LocationDataPoint) {
|
|
120
|
+
if (isShutdown.get()) return
|
|
121
|
+
|
|
122
|
+
executor.execute {
|
|
123
|
+
// For set/update targets, cancel any in-progress retry since newer data supersedes
|
|
124
|
+
if (config.method == "set" || config.method == "update") {
|
|
125
|
+
if (isRetrying.get()) {
|
|
126
|
+
retryGeneration.incrementAndGet()
|
|
127
|
+
isRetrying.set(false)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if device is offline
|
|
132
|
+
if (!networkChecker()) {
|
|
133
|
+
if (config.offlineQueue && offlineQueueProvider != null) {
|
|
134
|
+
enqueueOffline(location)
|
|
135
|
+
}
|
|
136
|
+
// If offlineQueue is disabled, discard the data point
|
|
137
|
+
return@execute
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Accumulate in batch
|
|
141
|
+
batch.add(location)
|
|
142
|
+
resetBatchTimer()
|
|
143
|
+
|
|
144
|
+
// Check if batch is full
|
|
145
|
+
if (batch.size >= config.batchSize) {
|
|
146
|
+
flushBatch()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Flush any partially-filled batch immediately.
|
|
153
|
+
* Called during stop() to ensure no data is lost.
|
|
154
|
+
* This method blocks until the flush is complete.
|
|
155
|
+
*/
|
|
156
|
+
fun flush() {
|
|
157
|
+
if (isShutdown.get()) return
|
|
158
|
+
|
|
159
|
+
val future = executor.submit {
|
|
160
|
+
if (batch.isNotEmpty()) {
|
|
161
|
+
flushBatch()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
future.get()
|
|
166
|
+
} catch (e: Exception) {
|
|
167
|
+
// Ignore interruption during shutdown
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Shutdown this handler, cancelling timers and releasing resources.
|
|
173
|
+
*/
|
|
174
|
+
fun shutdown() {
|
|
175
|
+
isShutdown.set(true)
|
|
176
|
+
cancelBatchTimer()
|
|
177
|
+
retryGeneration.incrementAndGet() // Cancel any in-progress retries
|
|
178
|
+
executor.shutdown()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the number of queued locations for this target's offline queue.
|
|
183
|
+
*
|
|
184
|
+
* @return The count of queued locations, or 0 if offlineQueue is disabled
|
|
185
|
+
*/
|
|
186
|
+
fun getQueuedCount(): Int {
|
|
187
|
+
if (!config.offlineQueue || offlineQueueProvider == null) return 0
|
|
188
|
+
return try {
|
|
189
|
+
offlineQueueProvider.countForTarget(config.path).get()
|
|
190
|
+
} catch (e: Exception) {
|
|
191
|
+
0
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Flush the offline queue for this target.
|
|
197
|
+
* Called when network connectivity is restored.
|
|
198
|
+
* Flushes in chronological order, respecting batchSize for batched writes.
|
|
199
|
+
*/
|
|
200
|
+
fun flushOfflineQueue() {
|
|
201
|
+
if (isShutdown.get()) return
|
|
202
|
+
if (!config.offlineQueue || offlineQueueProvider == null) return
|
|
203
|
+
|
|
204
|
+
executor.execute {
|
|
205
|
+
doFlushOfflineQueue()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Private implementation ---
|
|
210
|
+
|
|
211
|
+
private fun flushBatch() {
|
|
212
|
+
cancelBatchTimer()
|
|
213
|
+
if (batch.isEmpty()) return
|
|
214
|
+
|
|
215
|
+
val pointsToWrite = ArrayList(batch)
|
|
216
|
+
batch.clear()
|
|
217
|
+
|
|
218
|
+
executeWrite(pointsToWrite)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private fun executeWrite(points: List<LocationDataPoint>) {
|
|
222
|
+
val generation = retryGeneration.get()
|
|
223
|
+
|
|
224
|
+
when (config.method) {
|
|
225
|
+
"set" -> executeSet(points.last(), generation, points)
|
|
226
|
+
"push" -> executePush(points, generation)
|
|
227
|
+
"update" -> executeUpdate(points.last(), generation, points)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private fun executeSet(point: LocationDataPoint, generation: Int, allPoints: List<LocationDataPoint>) {
|
|
232
|
+
val data = locationToMap(point)
|
|
233
|
+
executeWithRetry(generation, allPoints) { callback ->
|
|
234
|
+
writeSet(data, callback)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private fun executePush(points: List<LocationDataPoint>, generation: Int) {
|
|
239
|
+
executeWithRetry(generation, points) { callback ->
|
|
240
|
+
writePush(points, callback)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun executeUpdate(point: LocationDataPoint, generation: Int, allPoints: List<LocationDataPoint>) {
|
|
245
|
+
val data = locationToMap(point)
|
|
246
|
+
executeWithRetry(generation, allPoints) { callback ->
|
|
247
|
+
writeUpdate(data, callback)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun writeSet(data: Map<String, Any?>, callback: TargetWriteCallback) {
|
|
252
|
+
when (firebaseService) {
|
|
253
|
+
"RTDB" -> {
|
|
254
|
+
val ref = FirebaseDatabase.getInstance().getReference(config.path)
|
|
255
|
+
ref.setValue(data)
|
|
256
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
257
|
+
.addOnFailureListener { e ->
|
|
258
|
+
callback.onError(classifyError(e), e.message ?: "RTDB set failed")
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
"Firestore" -> {
|
|
262
|
+
val doc = FirebaseFirestore.getInstance().document(config.path)
|
|
263
|
+
doc.set(data)
|
|
264
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
265
|
+
.addOnFailureListener { e ->
|
|
266
|
+
callback.onError(classifyError(e), e.message ?: "Firestore set failed")
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun writePush(points: List<LocationDataPoint>, callback: TargetWriteCallback) {
|
|
273
|
+
when (firebaseService) {
|
|
274
|
+
"RTDB" -> {
|
|
275
|
+
val ref = FirebaseDatabase.getInstance().getReference(config.path)
|
|
276
|
+
val updates = hashMapOf<String, Any>()
|
|
277
|
+
for (point in points) {
|
|
278
|
+
val key = ref.push().key ?: continue
|
|
279
|
+
val map = locationToMap(point)
|
|
280
|
+
@Suppress("UNCHECKED_CAST")
|
|
281
|
+
updates[key] = map as Any
|
|
282
|
+
}
|
|
283
|
+
if (updates.isEmpty()) {
|
|
284
|
+
callback.onSuccess()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
ref.updateChildren(updates)
|
|
288
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
289
|
+
.addOnFailureListener { e ->
|
|
290
|
+
callback.onError(classifyError(e), e.message ?: "RTDB push failed")
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
"Firestore" -> {
|
|
294
|
+
val firestore = FirebaseFirestore.getInstance()
|
|
295
|
+
val collection = firestore.collection(config.path)
|
|
296
|
+
val batch = firestore.batch()
|
|
297
|
+
for (point in points) {
|
|
298
|
+
val docRef = collection.document()
|
|
299
|
+
batch.set(docRef, locationToMap(point))
|
|
300
|
+
}
|
|
301
|
+
batch.commit()
|
|
302
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
303
|
+
.addOnFailureListener { e ->
|
|
304
|
+
callback.onError(classifyError(e), e.message ?: "Firestore push failed")
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun writeUpdate(data: Map<String, Any?>, callback: TargetWriteCallback) {
|
|
311
|
+
when (firebaseService) {
|
|
312
|
+
"RTDB" -> {
|
|
313
|
+
val ref = FirebaseDatabase.getInstance().getReference(config.path)
|
|
314
|
+
@Suppress("UNCHECKED_CAST")
|
|
315
|
+
ref.updateChildren(data as Map<String, Any>)
|
|
316
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
317
|
+
.addOnFailureListener { e ->
|
|
318
|
+
callback.onError(classifyError(e), e.message ?: "RTDB update failed")
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
"Firestore" -> {
|
|
322
|
+
val doc = FirebaseFirestore.getInstance().document(config.path)
|
|
323
|
+
@Suppress("UNCHECKED_CAST")
|
|
324
|
+
doc.update(data as Map<String, Any>)
|
|
325
|
+
.addOnSuccessListener { callback.onSuccess() }
|
|
326
|
+
.addOnFailureListener { e ->
|
|
327
|
+
callback.onError(classifyError(e), e.message ?: "Firestore update failed")
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Execute a write operation with exponential backoff retry.
|
|
335
|
+
*
|
|
336
|
+
* For set/update targets, retries are cancelled if a newer generation arrives
|
|
337
|
+
* (i.e., new data supersedes the in-progress write).
|
|
338
|
+
*
|
|
339
|
+
* After all retries are exhausted, if offlineQueue is enabled, the data points
|
|
340
|
+
* are queued for later flush upon connectivity restoration (Requirement 7.5).
|
|
341
|
+
*
|
|
342
|
+
* @param generation The retry generation at the time of dispatch
|
|
343
|
+
* @param points The data points being written (used for offline queuing on failure)
|
|
344
|
+
* @param operation The write operation to execute
|
|
345
|
+
*/
|
|
346
|
+
private fun executeWithRetry(generation: Int, points: List<LocationDataPoint>, operation: (TargetWriteCallback) -> Unit) {
|
|
347
|
+
isRetrying.set(true)
|
|
348
|
+
var attempt = 0
|
|
349
|
+
|
|
350
|
+
fun tryOperation() {
|
|
351
|
+
// Check if this retry has been superseded by newer data
|
|
352
|
+
if (retryGeneration.get() != generation) {
|
|
353
|
+
isRetrying.set(false)
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
attempt++
|
|
358
|
+
operation(object : TargetWriteCallback {
|
|
359
|
+
override fun onSuccess() {
|
|
360
|
+
isRetrying.set(false)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
override fun onError(errorCode: String, message: String) {
|
|
364
|
+
// Don't retry non-transient errors
|
|
365
|
+
if (isNonTransientError(errorCode)) {
|
|
366
|
+
isRetrying.set(false)
|
|
367
|
+
eventListener?.onWriteError(config.path, config.method, errorCode, message)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check if superseded before retrying
|
|
372
|
+
if (retryGeneration.get() != generation) {
|
|
373
|
+
isRetrying.set(false)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (attempt >= maxRetries) {
|
|
378
|
+
isRetrying.set(false)
|
|
379
|
+
eventListener?.onWriteError(
|
|
380
|
+
config.path,
|
|
381
|
+
config.method,
|
|
382
|
+
errorCode,
|
|
383
|
+
"Failed after $attempt attempts: $message"
|
|
384
|
+
)
|
|
385
|
+
// If offlineQueue is enabled, queue the data for later flush
|
|
386
|
+
if (config.offlineQueue && offlineQueueProvider != null) {
|
|
387
|
+
for (point in points) {
|
|
388
|
+
enqueueOffline(point)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Exponential backoff with jitter
|
|
395
|
+
val delay = calculateBackoffDelay(attempt)
|
|
396
|
+
try {
|
|
397
|
+
Thread.sleep(delay)
|
|
398
|
+
} catch (e: InterruptedException) {
|
|
399
|
+
Thread.currentThread().interrupt()
|
|
400
|
+
isRetrying.set(false)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check again after sleep
|
|
405
|
+
if (retryGeneration.get() != generation) {
|
|
406
|
+
isRetrying.set(false)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
tryOperation()
|
|
411
|
+
}
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
tryOperation()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Calculate exponential backoff delay with jitter.
|
|
420
|
+
*
|
|
421
|
+
* Formula: baseDelay × 2^(attempt-1) ± random jitter (±200ms)
|
|
422
|
+
* Result is never negative.
|
|
423
|
+
*
|
|
424
|
+
* @param attempt Current attempt number (1-indexed)
|
|
425
|
+
* @return Delay in milliseconds
|
|
426
|
+
*/
|
|
427
|
+
internal fun calculateBackoffDelay(attempt: Int): Long {
|
|
428
|
+
val exponentialDelay = (BASE_DELAY_MS * MULTIPLIER.pow((attempt - 1).toDouble())).toLong()
|
|
429
|
+
val jitter = Random.nextLong(-JITTER_MS, JITTER_MS + 1)
|
|
430
|
+
return maxOf(0L, exponentialDelay + jitter)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private fun enqueueOffline(location: LocationDataPoint) {
|
|
434
|
+
if (offlineQueueProvider == null) return
|
|
435
|
+
|
|
436
|
+
// Check queue size cap
|
|
437
|
+
try {
|
|
438
|
+
val currentCount = offlineQueueProvider.countForTarget(config.path).get()
|
|
439
|
+
if (currentCount >= MAX_QUEUE_SIZE) {
|
|
440
|
+
// Evict oldest to make room
|
|
441
|
+
offlineQueueProvider.evictOldestForTarget(config.path).get()
|
|
442
|
+
eventListener?.onQueueOverflow(config.path)
|
|
443
|
+
}
|
|
444
|
+
} catch (e: Exception) {
|
|
445
|
+
// If we can't check count, try to enqueue anyway
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
offlineQueueProvider.enqueueForTarget(
|
|
449
|
+
targetPath = config.path,
|
|
450
|
+
latitude = location.latitude,
|
|
451
|
+
longitude = location.longitude,
|
|
452
|
+
timestamp = location.timestamp,
|
|
453
|
+
accuracy = location.accuracy,
|
|
454
|
+
speed = location.speed,
|
|
455
|
+
altitude = location.altitude,
|
|
456
|
+
bearing = location.bearing
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private fun doFlushOfflineQueue() {
|
|
461
|
+
if (offlineQueueProvider == null) return
|
|
462
|
+
|
|
463
|
+
while (!isShutdown.get()) {
|
|
464
|
+
val batchSize = config.batchSize
|
|
465
|
+
val queuedBatch = try {
|
|
466
|
+
offlineQueueProvider.dequeueBatchForTarget(config.path, batchSize).get()
|
|
467
|
+
} catch (e: Exception) {
|
|
468
|
+
break
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (queuedBatch.isEmpty()) break
|
|
472
|
+
|
|
473
|
+
// Convert QueuedLocations to LocationDataPoints
|
|
474
|
+
val points = queuedBatch.map { queued ->
|
|
475
|
+
LocationDataPoint(
|
|
476
|
+
latitude = queued.latitude,
|
|
477
|
+
longitude = queued.longitude,
|
|
478
|
+
timestamp = queued.timestamp,
|
|
479
|
+
accuracy = queued.accuracy,
|
|
480
|
+
speed = queued.speed,
|
|
481
|
+
altitude = queued.altitude,
|
|
482
|
+
bearing = queued.bearing
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Write synchronously during flush
|
|
487
|
+
var writeSuccess = false
|
|
488
|
+
val generation = retryGeneration.get()
|
|
489
|
+
|
|
490
|
+
val latch = java.util.concurrent.CountDownLatch(1)
|
|
491
|
+
var writeError: String? = null
|
|
492
|
+
|
|
493
|
+
val callback = object : TargetWriteCallback {
|
|
494
|
+
override fun onSuccess() {
|
|
495
|
+
writeSuccess = true
|
|
496
|
+
latch.countDown()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
override fun onError(errorCode: String, message: String) {
|
|
500
|
+
writeError = message
|
|
501
|
+
latch.countDown()
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Execute the write based on method
|
|
506
|
+
when (config.method) {
|
|
507
|
+
"set" -> writeSet(locationToMap(points.last()), callback)
|
|
508
|
+
"push" -> writePush(points, callback)
|
|
509
|
+
"update" -> writeUpdate(locationToMap(points.last()), callback)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
latch.await()
|
|
514
|
+
} catch (e: InterruptedException) {
|
|
515
|
+
Thread.currentThread().interrupt()
|
|
516
|
+
break
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (writeSuccess) {
|
|
520
|
+
// Remove successfully written points from queue
|
|
521
|
+
val ids = queuedBatch.map { it.id }
|
|
522
|
+
try {
|
|
523
|
+
offlineQueueProvider.removeBatchForTarget(ids).get()
|
|
524
|
+
} catch (e: Exception) {
|
|
525
|
+
// Log but continue
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
// Stop flushing on failure - retain data for retry later
|
|
529
|
+
break
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private fun resetBatchTimer() {
|
|
535
|
+
cancelBatchTimer()
|
|
536
|
+
if (config.batchSize <= 1) return // No timer needed for immediate writes
|
|
537
|
+
|
|
538
|
+
batchTimer = Timer("BatchTimer-${config.path}", true).apply {
|
|
539
|
+
schedule(object : TimerTask() {
|
|
540
|
+
override fun run() {
|
|
541
|
+
if (!isShutdown.get()) {
|
|
542
|
+
executor.execute {
|
|
543
|
+
if (batch.isNotEmpty()) {
|
|
544
|
+
flushBatch()
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}, BATCH_TIMEOUT_MS)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private fun cancelBatchTimer() {
|
|
554
|
+
batchTimer?.cancel()
|
|
555
|
+
batchTimer = null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Convert a LocationDataPoint to a Firebase-compatible map.
|
|
560
|
+
* Null optional fields are included as null (not placeholder values).
|
|
561
|
+
*/
|
|
562
|
+
private fun locationToMap(point: LocationDataPoint): Map<String, Any?> {
|
|
563
|
+
val map = hashMapOf<String, Any?>(
|
|
564
|
+
"latitude" to point.latitude,
|
|
565
|
+
"longitude" to point.longitude,
|
|
566
|
+
"timestamp" to point.timestamp,
|
|
567
|
+
"accuracy" to point.accuracy.toDouble()
|
|
568
|
+
)
|
|
569
|
+
// Include optional fields as null when unavailable (never use placeholders)
|
|
570
|
+
if (point.speed != null) {
|
|
571
|
+
map["speed"] = point.speed.toDouble()
|
|
572
|
+
}
|
|
573
|
+
if (point.altitude != null) {
|
|
574
|
+
map["altitude"] = point.altitude
|
|
575
|
+
}
|
|
576
|
+
if (point.bearing != null) {
|
|
577
|
+
map["bearing"] = point.bearing.toDouble()
|
|
578
|
+
}
|
|
579
|
+
return map
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Classify a Firebase exception as transient or non-transient.
|
|
584
|
+
* Non-transient errors (permission denied, auth failures) should not be retried.
|
|
585
|
+
*/
|
|
586
|
+
private fun classifyError(exception: Exception): String {
|
|
587
|
+
val message = exception.message?.lowercase() ?: ""
|
|
588
|
+
return when {
|
|
589
|
+
message.contains("permission") || message.contains("denied") -> "PERMISSION_DENIED"
|
|
590
|
+
message.contains("unauthenticated") || message.contains("auth") -> "PERMISSION_DENIED"
|
|
591
|
+
else -> "FIREBASE_WRITE_FAILED"
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Check if an error code represents a non-transient error that should not be retried.
|
|
597
|
+
*/
|
|
598
|
+
private fun isNonTransientError(errorCode: String): Boolean {
|
|
599
|
+
return errorCode == "PERMISSION_DENIED"
|
|
600
|
+
}
|
|
601
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package com.livetracking
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
4
|
+
import com.facebook.react.bridge.ReactMethod
|
|
5
|
+
import com.facebook.react.bridge.Promise
|
|
6
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* New Architecture (TurboModule) implementation.
|
|
10
|
+
* This file is only compiled when the new architecture is enabled.
|
|
11
|
+
*
|
|
12
|
+
* Extends NativeLiveTrackingSpec (generated from TurboModule codegen) and delegates
|
|
13
|
+
* all logic to LiveTrackingModuleImpl.
|
|
14
|
+
* Exposes methods: configure, start, stop, getStatus, getQueuedLocations.
|
|
15
|
+
* Events are emitted directly from LiveTrackingModuleImpl via RCTDeviceEventEmitter.
|
|
16
|
+
*
|
|
17
|
+
* Requirements: 9.1, 9.2, 9.3, 9.4, 11.2
|
|
18
|
+
*/
|
|
19
|
+
@ReactModule(name = LiveTrackingModuleImpl.NAME)
|
|
20
|
+
class LiveTrackingModule(reactContext: ReactApplicationContext) :
|
|
21
|
+
NativeLiveTrackingSpec(reactContext) {
|
|
22
|
+
|
|
23
|
+
private val impl = LiveTrackingModuleImpl(reactContext)
|
|
24
|
+
|
|
25
|
+
companion object {
|
|
26
|
+
const val NAME = LiveTrackingModuleImpl.NAME
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override fun getName(): String = NAME
|
|
30
|
+
|
|
31
|
+
override fun configure(config: String, promise: Promise) {
|
|
32
|
+
impl.configure(config, promise)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override fun start(promise: Promise) {
|
|
36
|
+
impl.start(promise)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override fun stop(promise: Promise) {
|
|
40
|
+
impl.stop(promise)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override fun getStatus(promise: Promise) {
|
|
44
|
+
impl.getStatus(promise)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun getQueuedLocations(promise: Promise) {
|
|
48
|
+
impl.getQueuedLocations(promise)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override fun getQueuedLocationsByTarget(promise: Promise) {
|
|
52
|
+
impl.getQueuedLocationsByTarget(promise)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@ReactMethod
|
|
56
|
+
override fun addListener(eventName: String) {
|
|
57
|
+
// Required for RN event emitter - no-op
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@ReactMethod
|
|
61
|
+
override fun removeListeners(count: Double) {
|
|
62
|
+
// Required for RN event emitter - no-op
|
|
63
|
+
}
|
|
64
|
+
}
|