@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,728 @@
1
+ package com.livetracking
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.location.Location
8
+ import android.location.LocationManager
9
+ import android.os.Build
10
+ import com.facebook.react.bridge.Arguments
11
+ import com.facebook.react.bridge.ReactApplicationContext
12
+ import com.facebook.react.bridge.Promise
13
+ import com.facebook.react.modules.core.DeviceEventManagerModule
14
+ import com.livetracking.location.LocationEngine
15
+ import com.livetracking.network.NetworkListener
16
+ import com.livetracking.network.NetworkStateListener
17
+ import com.livetracking.optimizer.ActivityRecognitionHandler
18
+ import com.livetracking.optimizer.MotionSleepManager
19
+ import com.livetracking.permissions.PermissionHandler
20
+ import com.livetracking.permissions.PermissionResult
21
+ import com.livetracking.queue.QueueEngine
22
+ import com.livetracking.receiver.TrackingStateStore
23
+ import com.livetracking.service.TrackingForegroundService
24
+ import com.livetracking.sync.LocationDataPoint
25
+ import com.livetracking.sync.SyncEngineController
26
+ import com.livetracking.sync.TargetEventListener
27
+ import org.json.JSONObject
28
+
29
+ /**
30
+ * Shared implementation for both old and new architecture.
31
+ * Contains the actual business logic that is shared between Bridge and TurboModule.
32
+ *
33
+ * Wires all engines together:
34
+ * - LocationEngine: GPS location updates via FusedLocationProvider
35
+ * - QueueEngine: Offline queue (Room database) with per-target support
36
+ * - SyncEngineController: Multi-target Firebase writes (RTDB or Firestore)
37
+ * - NetworkListener: Connectivity monitoring
38
+ * - ActivityRecognitionHandler: Activity detection
39
+ * - MotionSleepManager: Battery optimization via motion sleep
40
+ * - PermissionHandler: Permission checks
41
+ * - TrackingForegroundService: Foreground service for background tracking
42
+ *
43
+ * Requirements: 3.1, 3.5, 4.4, 9.2, 9.4, 10.1, 10.2, 10.3, 10.4
44
+ */
45
+ class LiveTrackingModuleImpl(private val reactContext: ReactApplicationContext) {
46
+
47
+ companion object {
48
+ const val NAME = "LiveTracking"
49
+
50
+ // Event names emitted to JavaScript
51
+ const val EVENT_LOCATION_UPDATE = "onLocationUpdate"
52
+ const val EVENT_TRACKING_ERROR = "onTrackingError"
53
+
54
+ // Default configuration values
55
+ private const val DEFAULT_INTERVAL_MS = 10000L
56
+ private const val DEFAULT_DISTANCE_FILTER_METERS = 10f
57
+ private const val DEFAULT_STOP_WHEN_STILL = true
58
+ private const val DEFAULT_OPTIMIZATION_MODE = "both"
59
+
60
+ // Tracking states
61
+ private const val STATE_IDLE = "idle"
62
+ private const val STATE_CONFIGURED = "configured"
63
+ private const val STATE_TRACKING = "tracking"
64
+ private const val STATE_MOTION_SLEEP = "motion_sleep"
65
+ private const val STATE_PAUSED_GPS = "paused_gps"
66
+
67
+ // Error codes
68
+ const val ERROR_GPS_DISABLED = "GPS_DISABLED"
69
+ const val ERROR_GPS_ENABLED = "GPS_ENABLED"
70
+ const val ERROR_PERMISSION_REVOKED = "PERMISSION_REVOKED"
71
+ }
72
+
73
+ // Engines
74
+ private var locationEngine: LocationEngine? = null
75
+ private var queueEngine: QueueEngine? = null
76
+ private var syncEngineController: SyncEngineController? = null
77
+ private var networkListener: NetworkListener? = null
78
+ private var activityRecognitionHandler: ActivityRecognitionHandler? = null
79
+ private var motionSleepManager: MotionSleepManager? = null
80
+ private val permissionHandler = PermissionHandler()
81
+
82
+ // Configuration
83
+ private var intervalMs: Long = DEFAULT_INTERVAL_MS
84
+ private var distanceFilterMeters: Float = DEFAULT_DISTANCE_FILTER_METERS
85
+ private var stopWhenStill: Boolean = DEFAULT_STOP_WHEN_STILL
86
+ private var optimizationMode: String = DEFAULT_OPTIMIZATION_MODE
87
+ private var firebaseService: String? = null
88
+ private var notificationEnabled: Boolean = true
89
+ private var notificationTitle: String = "Live Tracking"
90
+ private var notificationText: String = "Tracking your location"
91
+ private var notificationIcon: String? = null
92
+ private var notificationChannelId: String = TrackingForegroundService.DEFAULT_CHANNEL_ID
93
+ private var notificationChannelName: String = TrackingForegroundService.DEFAULT_CHANNEL_NAME
94
+ private var iosNotificationEnabled: Boolean = true
95
+
96
+ // State
97
+ private var trackingState: String = STATE_IDLE
98
+ private var lastLocation: Location? = null
99
+ private var lastUpdateTimestamp: Long = 0L
100
+ private var wasTrackingBeforeGpsDisabled: Boolean = false
101
+ private var gpsStatusReceiver: android.content.BroadcastReceiver? = null
102
+
103
+ /**
104
+ * Parse JSON config string and create all engines with config values.
105
+ * Validates required fields and applies defaults for optional ones.
106
+ *
107
+ * Parses the `firebase.targets` array and instantiates a SyncEngineController
108
+ * with one TargetHandler per configured sync target.
109
+ *
110
+ * @param config JSON string containing TrackingConfig
111
+ * @param promise Promise to resolve/reject
112
+ */
113
+ fun configure(config: String, promise: Promise) {
114
+ try {
115
+ val json = JSONObject(config)
116
+
117
+ // Parse optimization config
118
+ val optimization = json.optJSONObject("optimization")
119
+ if (optimization != null) {
120
+ intervalMs = optimization.optLong("intervalMs", DEFAULT_INTERVAL_MS)
121
+ distanceFilterMeters = optimization.optDouble("distanceFilterMeters", DEFAULT_DISTANCE_FILTER_METERS.toDouble()).toFloat()
122
+ stopWhenStill = optimization.optBoolean("stopWhenStill", DEFAULT_STOP_WHEN_STILL)
123
+ optimizationMode = optimization.optString("mode", DEFAULT_OPTIMIZATION_MODE)
124
+ }
125
+
126
+ // Validate optimization values
127
+ if (intervalMs <= 0) {
128
+ promise.reject("INVALID_CONFIG", "optimization.intervalMs must be greater than 0")
129
+ return
130
+ }
131
+ if (distanceFilterMeters <= 0) {
132
+ promise.reject("INVALID_CONFIG", "optimization.distanceFilterMeters must be greater than 0")
133
+ return
134
+ }
135
+ if (optimizationMode != "interval" && optimizationMode != "distance" && optimizationMode != "both") {
136
+ promise.reject("INVALID_CONFIG", "optimization.mode must be 'interval', 'distance', or 'both'")
137
+ return
138
+ }
139
+
140
+ // Parse firebase config
141
+ val firebase = json.optJSONObject("firebase")
142
+ if (firebase == null) {
143
+ promise.reject("INVALID_CONFIG", "firebase configuration is required")
144
+ return
145
+ }
146
+
147
+ firebaseService = firebase.optString("service", "")
148
+ if (firebaseService != "RTDB" && firebaseService != "Firestore") {
149
+ promise.reject("INVALID_CONFIG", "firebase.service must be 'RTDB' or 'Firestore'")
150
+ return
151
+ }
152
+
153
+ // Parse targets array from firebase config
154
+ val targetsArray = firebase.optJSONArray("targets")
155
+ if (targetsArray == null || targetsArray.length() == 0) {
156
+ promise.reject("INVALID_CONFIG", "firebase.targets array is required and must contain at least one sync target")
157
+ return
158
+ }
159
+
160
+ val targetsJson = targetsArray.toString()
161
+
162
+ // Parse Android notification config
163
+ val androidNotification = json.optJSONObject("androidNotification")
164
+ if (androidNotification != null) {
165
+ notificationEnabled = androidNotification.optBoolean("enabled", true)
166
+ notificationTitle = androidNotification.optString("title", "Live Tracking")
167
+ notificationText = androidNotification.optString("text", "Tracking your location")
168
+ notificationIcon = androidNotification.optString("icon", "").ifEmpty { null }
169
+ notificationChannelId = androidNotification.optString("channelId", TrackingForegroundService.DEFAULT_CHANNEL_ID)
170
+ notificationChannelName = androidNotification.optString("channelName", TrackingForegroundService.DEFAULT_CHANNEL_NAME)
171
+ }
172
+
173
+ // Parse iOS notification config
174
+ val iosNotification = json.optJSONObject("iosNotification")
175
+ if (iosNotification != null) {
176
+ iosNotificationEnabled = iosNotification.optBoolean("enabled", true)
177
+ }
178
+
179
+ // Initialize engines (including SyncEngineController with targets)
180
+ initializeEngines(targetsJson)
181
+
182
+ trackingState = STATE_CONFIGURED
183
+ promise.resolve(null)
184
+ } catch (e: IllegalArgumentException) {
185
+ // SyncEngineController throws IllegalArgumentException for invalid targets JSON
186
+ promise.reject("INVALID_CONFIG", "Failed to parse targets configuration: ${e.message}")
187
+ } catch (e: Exception) {
188
+ promise.reject("INVALID_CONFIG", "Failed to parse configuration: ${e.message}")
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Start tracking: check permissions, start foreground service, start location engine,
194
+ * start activity recognition, start network listener, save tracking state.
195
+ *
196
+ * @param promise Promise to resolve/reject
197
+ */
198
+ fun start(promise: Promise) {
199
+ if (trackingState == STATE_IDLE) {
200
+ promise.reject("NOT_CONFIGURED", "Call configure() before start()")
201
+ return
202
+ }
203
+
204
+ if (trackingState == STATE_TRACKING || trackingState == STATE_MOTION_SLEEP) {
205
+ promise.resolve(null) // Already tracking
206
+ return
207
+ }
208
+
209
+ // Check permissions
210
+ val permissionResult = permissionHandler.checkAllTrackingRequirements(reactContext)
211
+ if (permissionResult is PermissionResult.Denied) {
212
+ promise.reject(permissionResult.errorCode, permissionResult.message)
213
+ emitError(permissionResult.errorCode, permissionResult.message)
214
+ return
215
+ }
216
+
217
+ try {
218
+ // Start foreground service (always required for background tracking on Android)
219
+ val foregroundTitle = if (notificationEnabled) notificationTitle else "Live Tracking"
220
+ val foregroundText = if (notificationEnabled) notificationText else "Tracking your location"
221
+ TrackingForegroundService.start(
222
+ context = reactContext,
223
+ title = foregroundTitle,
224
+ text = foregroundText,
225
+ icon = if (notificationEnabled) notificationIcon else null,
226
+ channelId = notificationChannelId,
227
+ channelName = notificationChannelName
228
+ )
229
+
230
+ // Start tracking engines
231
+ startLocationTracking()
232
+
233
+ // Monitor GPS status changes so we can pause/resume automatically
234
+ registerGpsStatusListener()
235
+
236
+ // Save tracking state for boot receiver
237
+ TrackingStateStore.saveTrackingActive(reactContext, true)
238
+
239
+ trackingState = STATE_TRACKING
240
+ promise.resolve(null)
241
+ } catch (e: SecurityException) {
242
+ promise.reject("PERMISSION_DENIED", "Location permission denied: ${e.message}")
243
+ emitError("PERMISSION_DENIED", "Location permission denied: ${e.message}")
244
+ } catch (e: Exception) {
245
+ promise.reject("SERVICE_UNAVAILABLE", "Failed to start tracking: ${e.message}")
246
+ emitError("SERVICE_UNAVAILABLE", "Failed to start tracking: ${e.message}")
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Stop all engines, stop foreground service, save tracking state as inactive.
252
+ * Flushes all partially-filled batches via SyncEngineController before stopping.
253
+ *
254
+ * @param promise Promise to resolve/reject
255
+ */
256
+ fun stop(promise: Promise) {
257
+ try {
258
+ // Flush all partially-filled batches before stopping (Requirement 4.4)
259
+ syncEngineController?.flushAll()
260
+
261
+ // Stop tracking engines
262
+ stopLocationTracking()
263
+
264
+ // Stop foreground service
265
+ TrackingForegroundService.stop(reactContext)
266
+
267
+ // Unregister GPS status listener
268
+ unregisterGpsStatusListener()
269
+
270
+ // Save tracking state as inactive
271
+ TrackingStateStore.saveTrackingActive(reactContext, false)
272
+
273
+ // Reset state
274
+ trackingState = STATE_CONFIGURED
275
+ lastLocation = null
276
+ lastUpdateTimestamp = 0L
277
+ wasTrackingBeforeGpsDisabled = false
278
+
279
+ promise.resolve(null)
280
+ } catch (e: Exception) {
281
+ promise.reject("STOP_FAILED", "Failed to stop tracking: ${e.message}")
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Start location engine, activity recognition, and network listener.
287
+ */
288
+ private fun startLocationTracking() {
289
+ // Start location engine
290
+ // When mode is 'interval', pass 0f distance filter so FusedLocationProvider
291
+ // delivers updates purely on interval without distance gating
292
+ val effectiveDistanceFilter = if (optimizationMode == "interval") 0f else distanceFilterMeters
293
+ locationEngine?.startLocationUpdates(intervalMs, effectiveDistanceFilter)
294
+
295
+ // Start activity recognition (for motion sleep mode)
296
+ if (stopWhenStill) {
297
+ try {
298
+ activityRecognitionHandler?.startActivityRecognition()
299
+ } catch (e: SecurityException) {
300
+ // Activity recognition permission not granted, continue without it
301
+ }
302
+ }
303
+
304
+ // Start network listener
305
+ networkListener?.startListening()
306
+ }
307
+
308
+ /**
309
+ * Stop location engine, activity recognition, and network listener.
310
+ */
311
+ private fun stopLocationTracking() {
312
+ // Stop location engine
313
+ locationEngine?.stopLocationUpdates()
314
+
315
+ // Stop activity recognition
316
+ activityRecognitionHandler?.stopActivityRecognition()
317
+
318
+ // Stop network listener
319
+ networkListener?.stopListening()
320
+ }
321
+
322
+ /**
323
+ * Register a BroadcastReceiver that listens for GPS/location mode changes.
324
+ * When GPS is disabled during an active tracking session, tracking is paused.
325
+ * When GPS is re-enabled, tracking resumes automatically.
326
+ */
327
+ private fun registerGpsStatusListener() {
328
+ if (gpsStatusReceiver != null) return
329
+
330
+ gpsStatusReceiver = object : BroadcastReceiver() {
331
+ override fun onReceive(context: Context?, intent: Intent?) {
332
+ checkGpsStatus()
333
+ }
334
+ }
335
+
336
+ val filter = IntentFilter().apply {
337
+ addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
338
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
339
+ addAction(LocationManager.MODE_CHANGED_ACTION)
340
+ }
341
+ }
342
+
343
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
344
+ reactContext.registerReceiver(
345
+ gpsStatusReceiver,
346
+ filter,
347
+ Context.RECEIVER_NOT_EXPORTED
348
+ )
349
+ } else {
350
+ reactContext.registerReceiver(gpsStatusReceiver, filter)
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Unregister the GPS status BroadcastReceiver.
356
+ */
357
+ private fun unregisterGpsStatusListener() {
358
+ gpsStatusReceiver?.let { receiver ->
359
+ try {
360
+ reactContext.unregisterReceiver(receiver)
361
+ } catch (e: IllegalArgumentException) {
362
+ // Receiver was not registered, ignore
363
+ }
364
+ gpsStatusReceiver = null
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Check current GPS status and pause/resume tracking accordingly.
370
+ */
371
+ private fun checkGpsStatus() {
372
+ val gpsEnabled = permissionHandler.isGpsEnabled(reactContext)
373
+
374
+ if (!gpsEnabled && (trackingState == STATE_TRACKING || trackingState == STATE_MOTION_SLEEP)) {
375
+ pauseTrackingDueToGps()
376
+ } else if (gpsEnabled && trackingState == STATE_PAUSED_GPS && wasTrackingBeforeGpsDisabled) {
377
+ resumeTrackingAfterGps()
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Pause tracking when GPS is disabled while tracking is active.
383
+ * Keeps the foreground service running so tracking can resume automatically.
384
+ */
385
+ private fun pauseTrackingDueToGps() {
386
+ wasTrackingBeforeGpsDisabled = true
387
+ stopLocationTracking()
388
+ trackingState = STATE_PAUSED_GPS
389
+ emitError(
390
+ ERROR_GPS_DISABLED,
391
+ "GPS/Location services were disabled. Tracking paused and will resume automatically when GPS is enabled."
392
+ )
393
+ }
394
+
395
+ /**
396
+ * Resume tracking after GPS has been re-enabled.
397
+ */
398
+ private fun resumeTrackingAfterGps() {
399
+ try {
400
+ startLocationTracking()
401
+ trackingState = STATE_TRACKING
402
+ emitError(
403
+ ERROR_GPS_ENABLED,
404
+ "GPS/Location services are enabled. Tracking resumed."
405
+ )
406
+ } catch (e: SecurityException) {
407
+ trackingState = STATE_CONFIGURED
408
+ wasTrackingBeforeGpsDisabled = false
409
+ emitError(
410
+ ERROR_PERMISSION_REVOKED,
411
+ "Location permission was revoked while tracking was paused. Please grant permission to resume."
412
+ )
413
+ } catch (e: Exception) {
414
+ emitError("GPS_RESUME_FAILED", "Failed to resume tracking after GPS enabled: ${e.message}")
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Return JSON with state, isOnline, queuedLocations, lastLocation, batteryOptimization.
420
+ *
421
+ * @param promise Promise to resolve with JSON string
422
+ */
423
+ fun getStatus(promise: Promise) {
424
+ try {
425
+ val status = JSONObject()
426
+ status.put("state", trackingState)
427
+ status.put("isOnline", networkListener?.isOnline() ?: true)
428
+
429
+ // Get queued locations count from SyncEngineController (sum of all targets)
430
+ val queuedCount = syncEngineController?.getQueuedCounts()?.values?.sum() ?: 0
431
+ status.put("queuedLocations", queuedCount)
432
+
433
+ // Last location
434
+ val last = lastLocation
435
+ if (last != null) {
436
+ val locationJson = JSONObject()
437
+ locationJson.put("latitude", last.latitude)
438
+ locationJson.put("longitude", last.longitude)
439
+ locationJson.put("timestamp", last.time)
440
+ locationJson.put("accuracy", last.accuracy.toDouble())
441
+ locationJson.put("speed", if (last.hasSpeed()) last.speed.toDouble() else JSONObject.NULL)
442
+ locationJson.put("altitude", if (last.hasAltitude()) last.altitude else JSONObject.NULL)
443
+ locationJson.put("bearing", if (last.hasBearing()) last.bearing.toDouble() else JSONObject.NULL)
444
+ status.put("lastLocation", locationJson)
445
+ } else {
446
+ status.put("lastLocation", JSONObject.NULL)
447
+ }
448
+
449
+ // Battery optimization state
450
+ val batteryOptimization = when {
451
+ !stopWhenStill -> "disabled"
452
+ motionSleepManager?.isInSleepMode() == true -> "low_power"
453
+ else -> "full_accuracy"
454
+ }
455
+ status.put("batteryOptimization", batteryOptimization)
456
+
457
+ promise.resolve(status.toString())
458
+ } catch (e: Exception) {
459
+ promise.reject("STATUS_ERROR", "Failed to get status: ${e.message}")
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Return the total count of queued locations across all targets.
465
+ * Rejects with NOT_CONFIGURED if called before configure().
466
+ *
467
+ * @param promise Promise to resolve with queue count
468
+ */
469
+ fun getQueuedLocations(promise: Promise) {
470
+ if (trackingState == STATE_IDLE) {
471
+ promise.reject("NOT_CONFIGURED", "Call configure() before getQueuedLocations()")
472
+ return
473
+ }
474
+ try {
475
+ val count = syncEngineController?.getQueuedCounts()?.values?.sum() ?: 0
476
+ promise.resolve(count)
477
+ } catch (e: Exception) {
478
+ promise.resolve(0)
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Return a JSON string mapping each configured target path to its queued location count.
484
+ * Targets with offlineQueue disabled report 0.
485
+ * Rejects with NOT_CONFIGURED if called before configure().
486
+ *
487
+ * @param promise Promise to resolve with JSON string (e.g., {"path1": 5, "path2": 0})
488
+ * Requirements: 10.2, 10.3, 10.4
489
+ */
490
+ fun getQueuedLocationsByTarget(promise: Promise) {
491
+ if (trackingState == STATE_IDLE) {
492
+ promise.reject("NOT_CONFIGURED", "Call configure() before getQueuedLocationsByTarget()")
493
+ return
494
+ }
495
+ try {
496
+ val counts = syncEngineController?.getQueuedCounts() ?: emptyMap()
497
+ val result = JSONObject()
498
+ for ((path, count) in counts) {
499
+ result.put(path, count)
500
+ }
501
+ promise.resolve(result.toString())
502
+ } catch (e: Exception) {
503
+ promise.reject("STATUS_ERROR", "Failed to get queued locations by target: ${e.message}")
504
+ }
505
+ }
506
+
507
+ // --- Private Implementation ---
508
+
509
+ /**
510
+ * Initialize all engines with the current configuration.
511
+ *
512
+ * @param targetsJson JSON array string of sync target configurations
513
+ * @throws IllegalArgumentException if targetsJson fails to parse or contains invalid targets
514
+ */
515
+ private fun initializeEngines(targetsJson: String) {
516
+ // Location Engine
517
+ locationEngine = LocationEngine(reactContext).apply {
518
+ setLocationListener(object : LocationEngine.LocationUpdateListener {
519
+ override fun onLocationReceived(location: Location) {
520
+ handleLocationUpdate(location)
521
+ }
522
+ })
523
+ }
524
+
525
+ // Queue Engine (provides per-target offline queue operations)
526
+ queueEngine = QueueEngine(reactContext)
527
+
528
+ // Network Listener
529
+ networkListener = NetworkListener(reactContext).apply {
530
+ setNetworkStateListener(object : NetworkStateListener {
531
+ override fun onNetworkAvailable() {
532
+ handleNetworkRestored()
533
+ }
534
+
535
+ override fun onNetworkLost() {
536
+ // No action needed - offline queue will hold data for targets with offlineQueue enabled
537
+ }
538
+ })
539
+ }
540
+
541
+ // SyncEngineController — replaces FirebaseSyncEngine
542
+ // Parses targets JSON and instantiates one TargetHandler per target.
543
+ // Throws IllegalArgumentException if targets JSON is invalid (Requirement 9.4)
544
+ syncEngineController = SyncEngineController(
545
+ targetsJson = targetsJson,
546
+ firebaseService = firebaseService!!,
547
+ networkChecker = { networkListener?.isOnline() ?: true },
548
+ offlineQueueProvider = queueEngine,
549
+ eventListener = object : TargetEventListener {
550
+ override fun onWriteError(targetPath: String, method: String, errorCode: String, message: String) {
551
+ emitError(errorCode, "Target '$targetPath' ($method): $message")
552
+ }
553
+
554
+ override fun onQueueOverflow(targetPath: String) {
555
+ emitError("QUEUE_OVERFLOW", "Offline queue overflow for target '$targetPath' — oldest data point evicted")
556
+ }
557
+ }
558
+ )
559
+
560
+ // Activity Recognition Handler
561
+ activityRecognitionHandler = ActivityRecognitionHandler(reactContext).apply {
562
+ stillThresholdMs = MotionSleepManager.STILL_THRESHOLD_MS
563
+ setActivityStateListener(object : ActivityRecognitionHandler.ActivityStateListener {
564
+ override fun onActivityChanged(activity: ActivityRecognitionHandler.ActivityType) {
565
+ handleActivityChanged(activity)
566
+ }
567
+
568
+ override fun onStillDurationExceeded(durationMs: Long) {
569
+ // MotionSleepManager handles this via onActivityDetected
570
+ }
571
+ })
572
+ }
573
+
574
+ // Motion Sleep Manager
575
+ motionSleepManager = MotionSleepManager(
576
+ locationEngine = locationEngine!!,
577
+ stopWhenStill = stopWhenStill,
578
+ intervalMs = intervalMs,
579
+ distanceFilter = distanceFilterMeters
580
+ ).apply {
581
+ setListener(object : MotionSleepManager.MotionSleepListener {
582
+ override fun onSleepModeActivated() {
583
+ trackingState = STATE_MOTION_SLEEP
584
+ }
585
+
586
+ override fun onSleepModeDeactivated() {
587
+ trackingState = STATE_TRACKING
588
+ }
589
+ })
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Handle a new location update from LocationEngine.
595
+ * Applies distance/time filter, emits event to JS, dispatches to all sync targets.
596
+ *
597
+ * Requirement 3.1: Dispatches to all configured sync targets in parallel via SyncEngineController.
598
+ */
599
+ private fun handleLocationUpdate(location: Location) {
600
+ // Apply distance/time filter
601
+ if (!shouldAcceptLocation(location)) {
602
+ return
603
+ }
604
+
605
+ // Update last location and timestamp
606
+ lastLocation = location
607
+ lastUpdateTimestamp = System.currentTimeMillis()
608
+
609
+ // Emit location event to JavaScript
610
+ emitLocationUpdate(location)
611
+
612
+ // Dispatch to all configured sync targets via SyncEngineController
613
+ val dataPoint = LocationDataPoint(
614
+ latitude = location.latitude,
615
+ longitude = location.longitude,
616
+ timestamp = location.time,
617
+ accuracy = location.accuracy,
618
+ speed = if (location.hasSpeed()) location.speed else null,
619
+ altitude = if (location.hasAltitude()) location.altitude else null,
620
+ bearing = if (location.hasBearing()) location.bearing else null
621
+ )
622
+ syncEngineController?.dispatchLocation(dataPoint)
623
+ }
624
+
625
+ /**
626
+ * Distance/Time Matrix filter.
627
+ * Accepts location based on the configured optimization mode:
628
+ * - 'interval': time since last update >= intervalMs
629
+ * - 'distance': distance from last location >= distanceFilterMeters
630
+ * - 'both': both conditions must be met (default)
631
+ *
632
+ * First location is always accepted.
633
+ * Invalid locations (accuracy < 0 or coordinate 0,0) are rejected.
634
+ */
635
+ private fun shouldAcceptLocation(newLocation: Location): Boolean {
636
+ // Reject invalid coordinates
637
+ if (newLocation.latitude == 0.0 && newLocation.longitude == 0.0) {
638
+ return false
639
+ }
640
+ if (newLocation.accuracy < 0) {
641
+ return false
642
+ }
643
+
644
+ val last = lastLocation ?: return true // First location always accepted
645
+
646
+ // Check time condition
647
+ val timeDiff = System.currentTimeMillis() - lastUpdateTimestamp
648
+ val timeMet = timeDiff >= intervalMs
649
+
650
+ // Check distance condition
651
+ val distance = last.distanceTo(newLocation)
652
+ val distanceMet = distance >= distanceFilterMeters
653
+
654
+ return when (optimizationMode) {
655
+ "interval" -> timeMet
656
+ "distance" -> distanceMet
657
+ else -> timeMet && distanceMet
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Emit location update event to JavaScript layer.
663
+ */
664
+ private fun emitLocationUpdate(location: Location) {
665
+ try {
666
+ val params = Arguments.createMap().apply {
667
+ putDouble("latitude", location.latitude)
668
+ putDouble("longitude", location.longitude)
669
+ putDouble("timestamp", location.time.toDouble())
670
+ putDouble("accuracy", location.accuracy.toDouble())
671
+ putDouble("speed", if (location.hasSpeed()) location.speed.toDouble() else 0.0)
672
+ putDouble("altitude", if (location.hasAltitude()) location.altitude else 0.0)
673
+ putDouble("bearing", if (location.hasBearing()) location.bearing.toDouble() else 0.0)
674
+ }
675
+
676
+ reactContext
677
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
678
+ .emit(EVENT_LOCATION_UPDATE, params)
679
+ } catch (e: Exception) {
680
+ // JS module may not be available yet, ignore
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Emit error event to JavaScript layer.
686
+ */
687
+ private fun emitError(code: String, message: String) {
688
+ try {
689
+ val params = Arguments.createMap().apply {
690
+ putString("code", code)
691
+ putString("message", message)
692
+ }
693
+
694
+ reactContext
695
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
696
+ .emit(EVENT_TRACKING_ERROR, params)
697
+ } catch (e: Exception) {
698
+ // JS module may not be available yet, ignore
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Handle network restored: flush offline queues for all targets with offlineQueue enabled.
704
+ */
705
+ private fun handleNetworkRestored() {
706
+ if (trackingState == STATE_TRACKING || trackingState == STATE_MOTION_SLEEP) {
707
+ syncEngineController?.flushOfflineQueues()
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Handle activity state change from ActivityRecognitionHandler.
713
+ * Delegates to MotionSleepManager for sleep mode management.
714
+ */
715
+ private fun handleActivityChanged(activity: ActivityRecognitionHandler.ActivityType) {
716
+ val detectedActivityType = when (activity) {
717
+ ActivityRecognitionHandler.ActivityType.STILL ->
718
+ com.google.android.gms.location.DetectedActivity.STILL
719
+ ActivityRecognitionHandler.ActivityType.ON_FOOT ->
720
+ com.google.android.gms.location.DetectedActivity.ON_FOOT
721
+ ActivityRecognitionHandler.ActivityType.IN_VEHICLE ->
722
+ com.google.android.gms.location.DetectedActivity.IN_VEHICLE
723
+ ActivityRecognitionHandler.ActivityType.UNKNOWN ->
724
+ com.google.android.gms.location.DetectedActivity.UNKNOWN
725
+ }
726
+ motionSleepManager?.onActivityDetected(detectedActivityType)
727
+ }
728
+ }