react-native-bg-geolocation 0.2.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 (142) hide show
  1. package/BgGeolocation.podspec +39 -0
  2. package/LICENSE +20 -0
  3. package/README.md +366 -0
  4. package/android/build.gradle +69 -0
  5. package/android/src/main/AndroidManifest.xml +53 -0
  6. package/android/src/main/java/com/bggeolocation/BgGeolocationActivityRecognitionReceiver.kt +116 -0
  7. package/android/src/main/java/com/bggeolocation/BgGeolocationBootReceiver.kt +44 -0
  8. package/android/src/main/java/com/bggeolocation/BgGeolocationForegroundService.kt +373 -0
  9. package/android/src/main/java/com/bggeolocation/BgGeolocationGeofenceReceiver.kt +55 -0
  10. package/android/src/main/java/com/bggeolocation/BgGeolocationHeadlessTask.kt +138 -0
  11. package/android/src/main/java/com/bggeolocation/BgGeolocationModule.kt +1030 -0
  12. package/android/src/main/java/com/bggeolocation/BgGeolocationMotionStateMachine.kt +159 -0
  13. package/android/src/main/java/com/bggeolocation/BgGeolocationPackage.kt +31 -0
  14. package/android/src/main/res/drawable/bg_geo_notification.xml +9 -0
  15. package/ios/BgGeolocation.h +14 -0
  16. package/ios/BgGeolocation.mm +709 -0
  17. package/ios/engine/AtomicBoolean.swift +48 -0
  18. package/ios/engine/BGActivityChangeEvent.swift +20 -0
  19. package/ios/engine/BGActivityConfig.swift +71 -0
  20. package/ios/engine/BGAppConfig.swift +92 -0
  21. package/ios/engine/BGAppState.swift +147 -0
  22. package/ios/engine/BGAuthorization.swift +85 -0
  23. package/ios/engine/BGAuthorizationAlertPresenter.swift +39 -0
  24. package/ios/engine/BGAuthorizationConfig.swift +50 -0
  25. package/ios/engine/BGAuthorizationEvent.swift +40 -0
  26. package/ios/engine/BGBackgroundTaskManager.swift +143 -0
  27. package/ios/engine/BGCLRouter.swift +101 -0
  28. package/ios/engine/BGCallback.swift +19 -0
  29. package/ios/engine/BGConfig.swift +440 -0
  30. package/ios/engine/BGConfigModuleBase.swift +180 -0
  31. package/ios/engine/BGConfigOLD.swift +582 -0
  32. package/ios/engine/BGConnectivityChangeEvent.swift +15 -0
  33. package/ios/engine/BGCrashDetector.swift +122 -0
  34. package/ios/engine/BGCurrentPositionRequest.swift +87 -0
  35. package/ios/engine/BGDataStore.swift +75 -0
  36. package/ios/engine/BGDatabase.swift +677 -0
  37. package/ios/engine/BGDatabasePool.swift +220 -0
  38. package/ios/engine/BGDatabaseQueue.swift +215 -0
  39. package/ios/engine/BGDateUtils.swift +26 -0
  40. package/ios/engine/BGDeviceInfo.swift +54 -0
  41. package/ios/engine/BGDeviceManager.swift +65 -0
  42. package/ios/engine/BGEnabledChangeEvent.swift +11 -0
  43. package/ios/engine/BGEnv.swift +17 -0
  44. package/ios/engine/BGEventBus.swift +83 -0
  45. package/ios/engine/BGEventManager.swift +169 -0
  46. package/ios/engine/BGEventNames.swift +51 -0
  47. package/ios/engine/BGGeofence.swift +233 -0
  48. package/ios/engine/BGGeofenceDAO.swift +152 -0
  49. package/ios/engine/BGGeofenceEvent.swift +42 -0
  50. package/ios/engine/BGGeofenceLocationRequest.swift +94 -0
  51. package/ios/engine/BGGeofenceManager.swift +315 -0
  52. package/ios/engine/BGGeofenceTransition.swift +97 -0
  53. package/ios/engine/BGGeofencesChangeEvent.swift +26 -0
  54. package/ios/engine/BGGeolocationConfig.swift +136 -0
  55. package/ios/engine/BGHeartbeatEvent.swift +31 -0
  56. package/ios/engine/BGHeartbeatService.swift +51 -0
  57. package/ios/engine/BGHttpConfig.swift +105 -0
  58. package/ios/engine/BGHttpErrorCodes.swift +63 -0
  59. package/ios/engine/BGHttpEvent.swift +34 -0
  60. package/ios/engine/BGHttpRequest.swift +126 -0
  61. package/ios/engine/BGHttpResponse.swift +93 -0
  62. package/ios/engine/BGHttpService.swift +428 -0
  63. package/ios/engine/BGKalmanFilter.swift +105 -0
  64. package/ios/engine/BGLMActionNames.swift +55 -0
  65. package/ios/engine/BGLicenseManager.swift +26 -0
  66. package/ios/engine/BGLiveActivityManager.swift +327 -0
  67. package/ios/engine/BGLocation.swift +311 -0
  68. package/ios/engine/BGLocationAuthorization.swift +427 -0
  69. package/ios/engine/BGLocationDAO.swift +252 -0
  70. package/ios/engine/BGLocationErrors.swift +28 -0
  71. package/ios/engine/BGLocationEvent.swift +43 -0
  72. package/ios/engine/BGLocationFilter.swift +82 -0
  73. package/ios/engine/BGLocationFilterConfig.swift +57 -0
  74. package/ios/engine/BGLocationHelper.swift +54 -0
  75. package/ios/engine/BGLocationManager.swift +662 -0
  76. package/ios/engine/BGLocationMetricsEngine.swift +116 -0
  77. package/ios/engine/BGLocationRequestService.swift +459 -0
  78. package/ios/engine/BGLocationSatisfier.swift +14 -0
  79. package/ios/engine/BGLocationStreamEvent.swift +27 -0
  80. package/ios/engine/BGLog.swift +337 -0
  81. package/ios/engine/BGLogLevel.swift +26 -0
  82. package/ios/engine/BGLoggerConfig.swift +60 -0
  83. package/ios/engine/BGMotionActivity.swift +31 -0
  84. package/ios/engine/BGMotionActivityClassifier.swift +108 -0
  85. package/ios/engine/BGMotionActivityManagerAdapter.swift +40 -0
  86. package/ios/engine/BGMotionActivitySource.swift +46 -0
  87. package/ios/engine/BGMotionDetector.swift +377 -0
  88. package/ios/engine/BGMotionPermissionManager.swift +50 -0
  89. package/ios/engine/BGNativeLogger.swift +48 -0
  90. package/ios/engine/BGNotificaitons.swift +37 -0
  91. package/ios/engine/BGOdometer.swift +66 -0
  92. package/ios/engine/BGPersistenceConfig.swift +29 -0
  93. package/ios/engine/BGPolygonStreamRequest.swift +48 -0
  94. package/ios/engine/BGPowerSaveChangeEvent.swift +12 -0
  95. package/ios/engine/BGPropertySpec.swift +29 -0
  96. package/ios/engine/BGProviderChangeEvent.swift +31 -0
  97. package/ios/engine/BGQueue.swift +50 -0
  98. package/ios/engine/BGRPC.swift +194 -0
  99. package/ios/engine/BGReachability.swift +58 -0
  100. package/ios/engine/BGResultSet.swift +157 -0
  101. package/ios/engine/BGSchedule.swift +228 -0
  102. package/ios/engine/BGScheduleEvent.swift +13 -0
  103. package/ios/engine/BGScheduler.swift +116 -0
  104. package/ios/engine/BGSingleLocationRequest.swift +49 -0
  105. package/ios/engine/BGStreamLocationRequest.swift +42 -0
  106. package/ios/engine/BGTemplate.swift +54 -0
  107. package/ios/engine/BGTimerService.swift +46 -0
  108. package/ios/engine/BGTrackingAudioManager.swift +286 -0
  109. package/ios/engine/BGTrackingService.swift +879 -0
  110. package/ios/engine/BGWatchPositionRequest.swift +63 -0
  111. package/ios/engine/DatabaseQueue.swift +47 -0
  112. package/ios/engine/LogQuery.swift +10 -0
  113. package/ios/engine/SQLQuery.swift +65 -0
  114. package/ios/engine/TransistorAuthorizationToken.swift +182 -0
  115. package/ios/liveactivity/BGLiveTrackingAttributes.swift +52 -0
  116. package/ios/locationpush/BGLocationPushDeliverer.swift +260 -0
  117. package/ios/locationpush/BGLocationPushService.swift +161 -0
  118. package/ios/locationpush/BGLocationPushShared.swift +98 -0
  119. package/ios/locationpush/BGLocationPushSocketClient.swift +198 -0
  120. package/lib/module/NativeBgGeolocation.js +5 -0
  121. package/lib/module/NativeBgGeolocation.js.map +1 -0
  122. package/lib/module/events.js +20 -0
  123. package/lib/module/events.js.map +1 -0
  124. package/lib/module/index.js +706 -0
  125. package/lib/module/index.js.map +1 -0
  126. package/lib/module/package.json +1 -0
  127. package/lib/module/types.js +2 -0
  128. package/lib/module/types.js.map +1 -0
  129. package/lib/typescript/package.json +1 -0
  130. package/lib/typescript/src/NativeBgGeolocation.d.ts +57 -0
  131. package/lib/typescript/src/NativeBgGeolocation.d.ts.map +1 -0
  132. package/lib/typescript/src/events.d.ts +18 -0
  133. package/lib/typescript/src/events.d.ts.map +1 -0
  134. package/lib/typescript/src/index.d.ts +238 -0
  135. package/lib/typescript/src/index.d.ts.map +1 -0
  136. package/lib/typescript/src/types.d.ts +229 -0
  137. package/lib/typescript/src/types.d.ts.map +1 -0
  138. package/package.json +141 -0
  139. package/src/NativeBgGeolocation.ts +236 -0
  140. package/src/events.ts +17 -0
  141. package/src/index.tsx +935 -0
  142. package/src/types.ts +254 -0
@@ -0,0 +1,1030 @@
1
+ package com.bggeolocation
2
+
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.content.pm.PackageManager
7
+ import android.location.Location
8
+ import android.os.Build
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+ import androidx.core.content.ContextCompat
12
+ import com.facebook.react.bridge.*
13
+ import com.facebook.react.common.LifecycleState
14
+ import com.facebook.react.modules.core.DeviceEventManagerModule
15
+ import com.google.android.gms.location.*
16
+ import kotlinx.coroutines.*
17
+ import okhttp3.OkHttpClient
18
+ import okhttp3.Request
19
+ import okhttp3.RequestBody
20
+ import okhttp3.MediaType.Companion.toMediaType
21
+ import okhttp3.RequestBody.Companion.toRequestBody
22
+ import org.json.JSONArray
23
+ import org.json.JSONObject
24
+ import java.io.IOException
25
+ import java.util.UUID
26
+ import java.util.concurrent.TimeUnit
27
+
28
+ class BgGeolocationModule(reactContext: ReactApplicationContext) :
29
+ NativeBgGeolocationSpec(reactContext) {
30
+
31
+ companion object {
32
+ const val NAME = NativeBgGeolocationSpec.NAME
33
+ const val TEST_TAG = "BgGeoTest"
34
+
35
+ /**
36
+ * True while the JS bridge / React context is alive. The ForegroundService
37
+ * uses this to decide whether to dispatch to the HeadlessTask: when the app
38
+ * is alive the module's own listener already emits the event, so the service
39
+ * must NOT also fire a headless task (that would double-process each location).
40
+ * When the app is killed this is false (fresh process), so the service routes
41
+ * locations through the headless task.
42
+ */
43
+ @Volatile
44
+ @JvmStatic
45
+ var isReactContextAlive = false
46
+
47
+ @Volatile
48
+ private var currentReactContext: ReactApplicationContext? = null
49
+
50
+ @JvmStatic
51
+ fun getReactContext(): ReactApplicationContext? = currentReactContext
52
+ }
53
+
54
+ // ─── Location (Fused) ─────────────────────────────────────────────────────
55
+ // Used only for getCurrentPosition / watchPosition. Continuous tracking is
56
+ // owned by the ForegroundService.
57
+ private lateinit var fusedClient: FusedLocationProviderClient
58
+ private var watchPositionCallback: LocationCallback? = null
59
+
60
+ // ─── Activity Recognition ─────────────────────────────────────────────────
61
+ private lateinit var activityRecognitionClient: ActivityRecognitionClient
62
+ private var activityPendingIntent: android.app.PendingIntent? = null
63
+
64
+ // ─── Geofencing ─────────────────────────────────────────────────────────────
65
+ private var geofencingClient: GeofencingClient? = null
66
+ private var geofencePendingIntent: android.app.PendingIntent? = null
67
+
68
+ // ─── Heartbeat ──────────────────────────────────────────────────────────────
69
+ private var heartbeatHandler: Handler? = null
70
+ private var heartbeatRunnable: Runnable? = null
71
+
72
+ // ─── State ────────────────────────────────────────────────────────────────
73
+ private var isTracking = false
74
+ private var isMoving = false
75
+ private var config = JSONObject()
76
+ private val locationStore = mutableListOf<WritableMap>()
77
+ private val geofenceStore = mutableListOf<WritableMap>()
78
+ private var odometer = 0.0
79
+ private var lastLocation: Location? = null
80
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
81
+
82
+ // ─── Disk persistence ─────────────────────────────────────────────────────
83
+ private val prefs by lazy {
84
+ reactApplicationContext.getSharedPreferences("bg_geolocation_prefs", Context.MODE_PRIVATE)
85
+ }
86
+ private val maxPersistedLocations = 500
87
+
88
+ // ─── HTTP ──────────────────────────────────────────────────────────────────
89
+ private val httpClient: OkHttpClient by lazy {
90
+ OkHttpClient.Builder()
91
+ .connectTimeout(30, TimeUnit.SECONDS)
92
+ .readTimeout(30, TimeUnit.SECONDS)
93
+ .writeTimeout(30, TimeUnit.SECONDS)
94
+ .build()
95
+ }
96
+
97
+ init {
98
+ // The React context is alive as soon as this module is constructed
99
+ isReactContextAlive = true
100
+ currentReactContext = reactApplicationContext
101
+ // Warm the in-memory store with anything persisted while the bridge was dead
102
+ loadPersistedLocations()
103
+ // Wire geofence transitions to JS events
104
+ startGeofenceCallback()
105
+ }
106
+
107
+ override fun getName() = NAME
108
+
109
+ // The generated Android TurboModule base declares these abstract (RN reserves
110
+ // addListener/removeListeners for NativeEventEmitter). No-op — events are
111
+ // delivered via RCTDeviceEventEmitter.emit().
112
+ override fun addListener(eventName: String) = Unit
113
+ override fun removeListeners(count: Double) = Unit
114
+
115
+ // ─── Helpers ──────────────────────────────────────────────────────────────
116
+
117
+ private fun hasLocationPermission(): Boolean {
118
+ return ContextCompat.checkSelfPermission(
119
+ reactApplicationContext, Manifest.permission.ACCESS_FINE_LOCATION
120
+ ) == PackageManager.PERMISSION_GRANTED ||
121
+ ContextCompat.checkSelfPermission(
122
+ reactApplicationContext, Manifest.permission.ACCESS_COARSE_LOCATION
123
+ ) == PackageManager.PERMISSION_GRANTED
124
+ }
125
+
126
+ /** "FOREGROUND" when the UI is resumed, otherwise "BACKGROUND". */
127
+ private fun currentAppState(): String {
128
+ return if (reactApplicationContext.lifecycleState == LifecycleState.RESUMED)
129
+ "FOREGROUND" else "BACKGROUND"
130
+ }
131
+
132
+ private fun sendEvent(eventName: String, params: Any?) {
133
+ try {
134
+ reactApplicationContext
135
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
136
+ .emit(eventName, params)
137
+ } catch (e: Exception) {
138
+ android.util.Log.w(NAME, "sendEvent($eventName) failed: ${e.message}")
139
+ }
140
+ }
141
+
142
+ private fun locationToMap(location: Location): WritableMap {
143
+ val coords = Arguments.createMap().apply {
144
+ putDouble("latitude", location.latitude)
145
+ putDouble("longitude", location.longitude)
146
+ putDouble("accuracy", location.accuracy.toDouble())
147
+ putDouble("altitude", location.altitude)
148
+ putDouble("altitudeAccuracy",
149
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
150
+ location.verticalAccuracyMeters.toDouble() else -1.0)
151
+ putDouble("heading", location.bearing.toDouble())
152
+ putDouble("speed", location.speed.toDouble())
153
+ }
154
+ lastLocation?.let { odometer += it.distanceTo(location).toDouble() }
155
+ lastLocation = location
156
+
157
+ // Read the latest detected activity (works in every state — the receiver
158
+ // persists it to prefs even when the app is killed).
159
+ var activityType = BgGeolocationActivityRecognitionReceiver.readActivityType(reactApplicationContext)
160
+ val activityConf = BgGeolocationActivityRecognitionReceiver.readActivityConfidence(reactApplicationContext)
161
+ val detectedMoving = BgGeolocationActivityRecognitionReceiver.readIsMoving(reactApplicationContext)
162
+ val moving = detectedMoving || location.speed > 0.5f
163
+ if (!detectedMoving && moving && activityType == "unknown") {
164
+ activityType = "moving"
165
+ }
166
+ isMoving = moving
167
+
168
+ return Arguments.createMap().apply {
169
+ putString("uuid", UUID.randomUUID().toString())
170
+ putString("timestamp",
171
+ java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
172
+ java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
173
+ .format(java.util.Date(location.time)))
174
+ putMap("coords", coords)
175
+ putMap("activity", Arguments.createMap().apply {
176
+ putString("type", activityType)
177
+ putInt("confidence", activityConf)
178
+ })
179
+ putMap("battery", Arguments.createMap().apply {
180
+ putDouble("level", getBatteryLevel())
181
+ putBoolean("is_charging", isCharging())
182
+ })
183
+ putBoolean("is_moving", moving)
184
+ putDouble("odometer", odometer)
185
+ }
186
+ }
187
+
188
+ private fun getBatteryLevel(): Double {
189
+ return try {
190
+ val bm = reactApplicationContext.getSystemService(Context.BATTERY_SERVICE) as android.os.BatteryManager
191
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
192
+ bm.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) / 100.0
193
+ } else -1.0
194
+ } catch (e: Exception) { -1.0 }
195
+ }
196
+
197
+ private fun isCharging(): Boolean {
198
+ return try {
199
+ val intentFilter = android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED)
200
+ val batteryStatus = reactApplicationContext.registerReceiver(null, intentFilter)
201
+ val status = batteryStatus?.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1) ?: -1
202
+ status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
203
+ status == android.os.BatteryManager.BATTERY_STATUS_FULL
204
+ } catch (e: Exception) { false }
205
+ }
206
+
207
+ private fun stateMap(): WritableMap = Arguments.createMap().apply {
208
+ putBoolean("enabled", isTracking)
209
+ putBoolean("schedulerEnabled", false)
210
+ putInt("trackingMode", 1)
211
+ putDouble("odometer", odometer)
212
+ putBoolean("debug", config.optBoolean("debug", false))
213
+ putInt("logLevel", config.optInt("logLevel", 3))
214
+ }
215
+
216
+ private fun startForegroundService() {
217
+ val notifConfig = config.optJSONObject("notification")
218
+ val title = notifConfig?.optString("title") ?: "BG Geolocation"
219
+ val text = notifConfig?.optString("text") ?: "Tracking location in background"
220
+ BgGeolocationForegroundService.start(reactApplicationContext, title, text)
221
+ }
222
+
223
+ private fun stopForegroundService() {
224
+ BgGeolocationForegroundService.stop(reactApplicationContext)
225
+ }
226
+
227
+ private fun persistTrackingState(enabled: Boolean) {
228
+ BgGeolocationBootReceiver.setTrackingEnabled(reactApplicationContext, enabled)
229
+ }
230
+
231
+ /** Persist runtime config needed by the ForegroundService + MotionStateMachine. */
232
+ private fun persistServiceConfig() {
233
+ // The "tracking period" is how often we force a FRESH fix in all states
234
+ // (including kill). Prefer heartbeatInterval (seconds); fall back to
235
+ // locationUpdateInterval (ms); default 60s.
236
+ val periodMs = when {
237
+ config.has("heartbeatInterval") -> config.optLong("heartbeatInterval", 60L) * 1000L
238
+ config.has("locationUpdateInterval") -> config.optLong("locationUpdateInterval", 60000L)
239
+ else -> 60000L
240
+ }
241
+ prefs.edit()
242
+ .putLong("trackingPeriodMs", periodMs)
243
+ .putLong("locationUpdateInterval", config.optLong("locationUpdateInterval", periodMs))
244
+ .putFloat("distanceFilter", config.optDouble("distanceFilter", 10.0).toFloat())
245
+ .putLong("stopTimeout", config.optLong("stopTimeout", 60L))
246
+ .putBoolean("autoSync", config.optBoolean("autoSync", true))
247
+ .putString("url", config.optString("url", ""))
248
+ .putString("method", config.optString("method", "POST"))
249
+ .putString("headers", config.optJSONObject("headers")?.toString() ?: "{}")
250
+ .putString("params", config.optJSONObject("params")?.toString() ?: "{}")
251
+ .apply()
252
+ }
253
+
254
+ // ─── Location disk persistence ──────────────────────────────────────────────
255
+ // Mirrors iOS persistLocationToDisk / loadPersistedLocations so getLocations()
256
+ // returns records even after the app is killed and relaunched.
257
+
258
+ private fun persistLocationToDisk(map: WritableMap) {
259
+ try {
260
+ val arr = JSONArray(prefs.getString("persistedLocations", "[]"))
261
+ arr.put(JSONObject(map.toHashMap()))
262
+ // Trim to cap
263
+ while (arr.length() > maxPersistedLocations) arr.remove(0)
264
+ prefs.edit().putString("persistedLocations", arr.toString()).apply()
265
+ } catch (e: Exception) {
266
+ android.util.Log.w(NAME, "persistLocationToDisk failed: ${e.message}")
267
+ }
268
+ }
269
+
270
+ private fun loadPersistedLocations() {
271
+ try {
272
+ locationStore.clear()
273
+ val arr = JSONArray(prefs.getString("persistedLocations", "[]"))
274
+ for (i in 0 until arr.length()) {
275
+ val obj = arr.getJSONObject(i)
276
+ locationStore.add(jsonToWritableMap(obj))
277
+ }
278
+ if (arr.length() > 0) {
279
+ android.util.Log.d(NAME, "Loaded ${arr.length()} persisted locations from disk")
280
+ }
281
+ } catch (e: Exception) {
282
+ android.util.Log.w(NAME, "loadPersistedLocations failed: ${e.message}")
283
+ }
284
+ }
285
+
286
+ private fun clearPersistedLocations() {
287
+ prefs.edit().remove("persistedLocations").apply()
288
+ }
289
+
290
+ private fun rewritePersistedLocations() {
291
+ try {
292
+ val arr = JSONArray()
293
+ locationStore.forEach { arr.put(JSONObject(it.toHashMap())) }
294
+ prefs.edit().putString("persistedLocations", arr.toString()).apply()
295
+ } catch (e: Exception) {
296
+ android.util.Log.w(NAME, "rewritePersistedLocations failed: ${e.message}")
297
+ }
298
+ }
299
+
300
+ /** Convert a JSONObject (possibly nested) into a WritableMap. */
301
+ private fun jsonToWritableMap(json: JSONObject): WritableMap {
302
+ val map = Arguments.createMap()
303
+ json.keys().forEach { key ->
304
+ when (val value = json.get(key)) {
305
+ is JSONObject -> map.putMap(key, jsonToWritableMap(value))
306
+ is JSONArray -> map.putArray(key, jsonToWritableArray(value))
307
+ is Boolean -> map.putBoolean(key, value)
308
+ is Int -> map.putInt(key, value)
309
+ is Long -> map.putDouble(key, value.toDouble())
310
+ is Double -> map.putDouble(key, value)
311
+ is String -> map.putString(key, value)
312
+ else -> map.putString(key, value.toString())
313
+ }
314
+ }
315
+ return map
316
+ }
317
+
318
+ private fun jsonToWritableArray(json: JSONArray): WritableArray {
319
+ val arr = Arguments.createArray()
320
+ for (i in 0 until json.length()) {
321
+ when (val value = json.get(i)) {
322
+ is JSONObject -> arr.pushMap(jsonToWritableMap(value))
323
+ is JSONArray -> arr.pushArray(jsonToWritableArray(value))
324
+ is Boolean -> arr.pushBoolean(value)
325
+ is Int -> arr.pushInt(value)
326
+ is Long -> arr.pushDouble(value.toDouble())
327
+ is Double -> arr.pushDouble(value)
328
+ is String -> arr.pushString(value)
329
+ else -> arr.pushString(value.toString())
330
+ }
331
+ }
332
+ return arr
333
+ }
334
+
335
+ // ─── Heartbeat ────────────────────────────────────────────────────────────
336
+ // Mirrors iOS heartbeat timer — emits a `heartbeat` event on a fixed interval.
337
+
338
+ private fun startHeartbeat() {
339
+ val intervalSec = config.optLong("heartbeatInterval", 0L)
340
+ if (intervalSec <= 0L) return
341
+ stopHeartbeat()
342
+ heartbeatHandler = Handler(Looper.getMainLooper())
343
+ heartbeatRunnable = object : Runnable {
344
+ override fun run() {
345
+ val event = Arguments.createMap().apply {
346
+ putInt("shakes", 0)
347
+ lastLocation?.let { putMap("location", locationToMap(it)) }
348
+ }
349
+ sendEvent("heartbeat", event)
350
+ heartbeatHandler?.postDelayed(this, intervalSec * 1000L)
351
+ }
352
+ }
353
+ heartbeatHandler?.postDelayed(heartbeatRunnable!!, intervalSec * 1000L)
354
+ }
355
+
356
+ private fun stopHeartbeat() {
357
+ heartbeatRunnable?.let { heartbeatHandler?.removeCallbacks(it) }
358
+ heartbeatRunnable = null
359
+ heartbeatHandler = null
360
+ }
361
+
362
+ // ─── Geofencing (GeofencingClient) ──────────────────────────────────────────
363
+ // Mirrors iOS CLCircularRegion monitoring — fires ENTER / EXIT / DWELL even in
364
+ // background & kill state via an OS-owned PendingIntent.
365
+
366
+ private fun getGeofencePendingIntent(): android.app.PendingIntent {
367
+ geofencePendingIntent?.let { return it }
368
+ val intent = android.content.Intent(
369
+ reactApplicationContext, BgGeolocationGeofenceReceiver::class.java
370
+ )
371
+ val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
372
+ android.app.PendingIntent.FLAG_MUTABLE
373
+ val pi = android.app.PendingIntent.getBroadcast(reactApplicationContext, 3001, intent, flags)
374
+ geofencePendingIntent = pi
375
+ return pi
376
+ }
377
+
378
+ @SuppressLint("MissingPermission")
379
+ private fun registerGeofence(gf: ReadableMap) {
380
+ val identifier = gf.getString("identifier") ?: return
381
+ if (!gf.hasKey("latitude") || !gf.hasKey("longitude") || !gf.hasKey("radius")) return
382
+
383
+ if (geofencingClient == null) {
384
+ geofencingClient = LocationServices.getGeofencingClient(reactApplicationContext)
385
+ }
386
+
387
+ var transitions = 0
388
+ val notifyEntry = !gf.hasKey("notifyOnEntry") || gf.getBoolean("notifyOnEntry")
389
+ val notifyExit = !gf.hasKey("notifyOnExit") || gf.getBoolean("notifyOnExit")
390
+ val notifyDwell = gf.hasKey("notifyOnDwell") && gf.getBoolean("notifyOnDwell")
391
+ if (notifyEntry) transitions = transitions or Geofence.GEOFENCE_TRANSITION_ENTER
392
+ if (notifyExit) transitions = transitions or Geofence.GEOFENCE_TRANSITION_EXIT
393
+ if (notifyDwell) transitions = transitions or Geofence.GEOFENCE_TRANSITION_DWELL
394
+
395
+ val builder = Geofence.Builder()
396
+ .setRequestId(identifier)
397
+ .setCircularRegion(gf.getDouble("latitude"), gf.getDouble("longitude"), gf.getDouble("radius").toFloat())
398
+ .setExpirationDuration(Geofence.NEVER_EXPIRE)
399
+ .setTransitionTypes(transitions)
400
+ if (notifyDwell) {
401
+ builder.setLoiteringDelay(if (gf.hasKey("loiteringDelay")) gf.getInt("loiteringDelay") else 30000)
402
+ }
403
+
404
+ val request = GeofencingRequest.Builder()
405
+ .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
406
+ .addGeofence(builder.build())
407
+ .build()
408
+
409
+ geofencingClient?.addGeofences(request, getGeofencePendingIntent())
410
+ ?.addOnFailureListener { e ->
411
+ android.util.Log.w(NAME, "addGeofence '$identifier' failed: ${e.message}")
412
+ }
413
+ }
414
+
415
+ /** Wire up the receiver so geofence transitions emit a `geofence` event to JS. */
416
+ private fun startGeofenceCallback() {
417
+ BgGeolocationGeofenceReceiver.setCallback { identifier, action ->
418
+ val event = Arguments.createMap().apply {
419
+ putString("identifier", identifier)
420
+ putString("action", action)
421
+ lastLocation?.let { putMap("location", locationToMap(it)) }
422
+ }
423
+ // Bridge is alive in this callback — emit straight to JS.
424
+ // (Kill-state geofence delivery is handled by the OS-owned geofence
425
+ // PendingIntent + receiver, which can route through the headless task.)
426
+ sendEvent("geofence", event)
427
+ }
428
+ }
429
+
430
+ // ─── HTTP Sync ────────────────────────────────────────────────────────────
431
+
432
+ /**
433
+ * POST a single location (or batch) to the configured URL using OkHttp.
434
+ * Reads `url`, `method`, `headers`, and `params` from config.
435
+ */
436
+ private fun syncLocation(locationMap: WritableMap) {
437
+ val url = config.optString("url", "")
438
+ if (url.isEmpty()) return
439
+
440
+ scope.launch {
441
+ try {
442
+ val method = config.optString("method", "POST").uppercase()
443
+ val extraHeaders = config.optJSONObject("headers")
444
+ val extraParams = config.optJSONObject("params")
445
+
446
+ // Build JSON body: wrap location in configured params if any
447
+ val body = JSONObject().apply {
448
+ put("location", JSONObject().apply {
449
+ put("uuid", locationMap.getString("uuid") ?: "")
450
+ put("timestamp", locationMap.getString("timestamp") ?: "")
451
+ val coords = locationMap.getMap("coords")
452
+ if (coords != null) {
453
+ put("coords", JSONObject().apply {
454
+ put("latitude", coords.getDouble("latitude"))
455
+ put("longitude", coords.getDouble("longitude"))
456
+ put("accuracy", coords.getDouble("accuracy"))
457
+ put("altitude", coords.getDouble("altitude"))
458
+ put("heading", coords.getDouble("heading"))
459
+ put("speed", coords.getDouble("speed"))
460
+ })
461
+ }
462
+ put("is_moving", locationMap.getBoolean("is_moving"))
463
+ put("odometer", locationMap.getDouble("odometer"))
464
+ })
465
+ extraParams?.keys()?.forEach { key -> put(key, extraParams[key]) }
466
+ }
467
+
468
+ val mediaType = "application/json; charset=utf-8".toMediaType()
469
+ val requestBody = body.toString().toRequestBody(mediaType)
470
+
471
+ val requestBuilder = Request.Builder().url(url)
472
+ extraHeaders?.keys()?.forEach { key ->
473
+ requestBuilder.addHeader(key, extraHeaders.optString(key))
474
+ }
475
+
476
+ when (method) {
477
+ "PUT" -> requestBuilder.put(requestBody)
478
+ "PATCH" -> requestBuilder.patch(requestBody)
479
+ else -> requestBuilder.post(requestBody)
480
+ }
481
+
482
+ httpClient.newCall(requestBuilder.build()).execute().use { response ->
483
+ if (config.optBoolean("debug", false)) {
484
+ android.util.Log.d(NAME, "HTTP sync: ${response.code} ${response.message}")
485
+ }
486
+ }
487
+ } catch (e: IOException) {
488
+ android.util.Log.w(NAME, "HTTP sync failed: ${e.message}")
489
+ }
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Batch sync all stored locations to the server.
495
+ */
496
+ private fun syncBatch(locations: List<WritableMap>, onSuccess: () -> Unit, onFailure: (String) -> Unit) {
497
+ val url = config.optString("url", "")
498
+ if (url.isEmpty()) { onFailure("NO_URL_CONFIGURED"); return }
499
+
500
+ scope.launch {
501
+ try {
502
+ val method = config.optString("method", "POST").uppercase()
503
+ val extraHeaders = config.optJSONObject("headers")
504
+ val extraParams = config.optJSONObject("params")
505
+
506
+ val locationsArray = JSONArray()
507
+ locations.forEach { locationMap ->
508
+ val obj = JSONObject().apply {
509
+ put("uuid", locationMap.getString("uuid") ?: "")
510
+ put("timestamp", locationMap.getString("timestamp") ?: "")
511
+ val coords = locationMap.getMap("coords")
512
+ if (coords != null) {
513
+ put("coords", JSONObject().apply {
514
+ put("latitude", coords.getDouble("latitude"))
515
+ put("longitude", coords.getDouble("longitude"))
516
+ put("accuracy", coords.getDouble("accuracy"))
517
+ put("altitude", coords.getDouble("altitude"))
518
+ put("heading", coords.getDouble("heading"))
519
+ put("speed", coords.getDouble("speed"))
520
+ })
521
+ }
522
+ put("is_moving", locationMap.getBoolean("is_moving"))
523
+ put("odometer", locationMap.getDouble("odometer"))
524
+ }
525
+ locationsArray.put(obj)
526
+ }
527
+
528
+ val body = JSONObject().apply {
529
+ put("locations", locationsArray)
530
+ extraParams?.keys()?.forEach { key -> put(key, extraParams[key]) }
531
+ }
532
+
533
+ val mediaType = "application/json; charset=utf-8".toMediaType()
534
+ val requestBody = body.toString().toRequestBody(mediaType)
535
+
536
+ val requestBuilder = Request.Builder().url(url)
537
+ extraHeaders?.keys()?.forEach { key ->
538
+ requestBuilder.addHeader(key, extraHeaders.optString(key))
539
+ }
540
+ when (method) {
541
+ "PUT" -> requestBuilder.put(requestBody)
542
+ "PATCH" -> requestBuilder.patch(requestBody)
543
+ else -> requestBuilder.post(requestBody)
544
+ }
545
+
546
+ httpClient.newCall(requestBuilder.build()).execute().use { response ->
547
+ if (response.isSuccessful) {
548
+ onSuccess()
549
+ } else {
550
+ onFailure("HTTP ${response.code}: ${response.message}")
551
+ }
552
+ }
553
+ } catch (e: IOException) {
554
+ onFailure(e.message ?: "IO error")
555
+ }
556
+ }
557
+ }
558
+
559
+ // ─── Activity Recognition ─────────────────────────────────────────────────
560
+
561
+ @SuppressLint("MissingPermission")
562
+ private fun startActivityRecognition() {
563
+ if (ContextCompat.checkSelfPermission(
564
+ reactApplicationContext, Manifest.permission.ACTIVITY_RECOGNITION
565
+ ) != PackageManager.PERMISSION_GRANTED) return
566
+
567
+ activityRecognitionClient = ActivityRecognition.getClient(reactApplicationContext)
568
+ val intent = android.content.Intent(
569
+ reactApplicationContext,
570
+ BgGeolocationActivityRecognitionReceiver::class.java
571
+ )
572
+ activityPendingIntent = android.app.PendingIntent.getBroadcast(
573
+ reactApplicationContext,
574
+ 2001,
575
+ intent,
576
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
577
+ )
578
+ // The OS owns this PendingIntent, so updates keep arriving at the receiver
579
+ // even after the app is killed. The receiver persists + emits motionchange itself.
580
+ activityRecognitionClient.requestActivityUpdates(
581
+ config.optLong("activityRecognitionInterval", 10000L),
582
+ activityPendingIntent!!
583
+ )
584
+ android.util.Log.d(TEST_TAG, "Activity recognition requested")
585
+ }
586
+
587
+ private fun stopActivityRecognition() {
588
+ try {
589
+ activityPendingIntent?.let {
590
+ activityRecognitionClient.removeActivityUpdates(it)
591
+ it.cancel()
592
+ activityPendingIntent = null
593
+ }
594
+ } catch (e: Exception) {
595
+ android.util.Log.w(NAME, "stopActivityRecognition: ${e.message}")
596
+ }
597
+ }
598
+
599
+ // ─── Core ─────────────────────────────────────────────────────────────────
600
+
601
+ override fun ready(config: ReadableMap, success: Callback, failure: Callback) {
602
+ try {
603
+ this.config = JSONObject(config.toHashMap())
604
+ persistServiceConfig()
605
+ if (config.hasKey("startOnBoot") && config.getBoolean("startOnBoot")) {
606
+ val wasTracking = BgGeolocationBootReceiver.isTrackingEnabled(reactApplicationContext)
607
+ if (wasTracking && !isTracking) {
608
+ val noop = Callback { }
609
+ start(noop, noop)
610
+ }
611
+ }
612
+ success.invoke(stateMap())
613
+ } catch (e: Exception) {
614
+ failure.invoke(e.message)
615
+ }
616
+ }
617
+
618
+ override fun configure(config: ReadableMap, success: Callback, failure: Callback) {
619
+ ready(config, success, failure)
620
+ }
621
+
622
+ override fun reset(config: ReadableMap, success: Callback, failure: Callback) {
623
+ this.config = JSONObject()
624
+ persistTrackingState(false)
625
+ ready(config, success, failure)
626
+ }
627
+
628
+ override fun setConfig(config: ReadableMap, success: Callback, failure: Callback) {
629
+ try {
630
+ val newConfig = JSONObject(config.toHashMap())
631
+ newConfig.keys().forEach { key -> this.config.put(key, newConfig[key]) }
632
+ persistServiceConfig()
633
+ success.invoke(stateMap())
634
+ } catch (e: Exception) {
635
+ failure.invoke(e.message)
636
+ }
637
+ }
638
+
639
+ override fun getState(success: Callback, failure: Callback) {
640
+ success.invoke(stateMap())
641
+ }
642
+
643
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
644
+
645
+ @SuppressLint("MissingPermission")
646
+ override fun start(success: Callback, failure: Callback) {
647
+ try {
648
+ if (!hasLocationPermission()) {
649
+ failure.invoke("PERMISSION_DENIED")
650
+ return
651
+ }
652
+
653
+ isTracking = true
654
+ persistTrackingState(true)
655
+ persistServiceConfig()
656
+ // The ForegroundService is the SINGLE active location source. It requests
657
+ // FusedLocation updates itself (with foregroundServiceType=location), which
658
+ // is what gives continuous fresh GPS + the OS location indicator in the
659
+ // background and after kill. The module no longer registers its own updates.
660
+ startForegroundService()
661
+ android.util.Log.d(TEST_TAG, "▶️ START tracking — ForegroundService owns location (state=${currentAppState()})")
662
+
663
+ // Activity recognition for motionchange events
664
+ startActivityRecognition()
665
+
666
+ // Heartbeat timer (if configured)
667
+ startHeartbeat()
668
+
669
+ // Re-register any geofences added before start / surviving a relaunch
670
+ geofenceStore.forEach { registerGeofence(it) }
671
+
672
+ success.invoke(stateMap())
673
+ } catch (e: Exception) {
674
+ isTracking = false
675
+ persistTrackingState(false)
676
+ stopForegroundService()
677
+ stopActivityRecognition()
678
+ stopHeartbeat()
679
+ android.util.Log.e(TEST_TAG, "START failed: ${e.message}", e)
680
+ failure.invoke(e.message ?: "START_FAILED")
681
+ }
682
+ }
683
+
684
+ override fun stop(success: Callback, failure: Callback) {
685
+ isTracking = false
686
+ persistTrackingState(false)
687
+ // The ForegroundService owns location updates — stopping it stops tracking.
688
+ stopForegroundService()
689
+ stopActivityRecognition()
690
+ stopHeartbeat()
691
+ // Clear the motion state machine so it re-initializes on next start
692
+ prefs.edit().remove("motion_initialized").remove("motion_still_since").apply()
693
+ android.util.Log.d(TEST_TAG, "⏹️ STOP tracking")
694
+ success.invoke(stateMap())
695
+ }
696
+
697
+ override fun startSchedule(success: Callback, failure: Callback) {
698
+ success.invoke(stateMap())
699
+ }
700
+
701
+ override fun stopSchedule(success: Callback, failure: Callback) {
702
+ success.invoke(stateMap())
703
+ }
704
+
705
+ override fun startGeofences(success: Callback, failure: Callback) {
706
+ geofenceStore.forEach { registerGeofence(it) }
707
+ success.invoke(stateMap())
708
+ }
709
+
710
+ // ─── Background Task ──────────────────────────────────────────────────────
711
+
712
+ override fun beginBackgroundTask(success: Callback, failure: Callback) {
713
+ success.invoke(1)
714
+ }
715
+
716
+ override fun finish(taskId: Double, success: Callback, failure: Callback) {
717
+ success.invoke(taskId)
718
+ }
719
+
720
+ // ─── Motion / Location ────────────────────────────────────────────────────
721
+
722
+ override fun changePace(isMoving: Boolean, success: Callback, failure: Callback) {
723
+ this.isMoving = isMoving
724
+ val event = Arguments.createMap().apply {
725
+ putBoolean("isMoving", isMoving)
726
+ lastLocation?.let { putMap("location", locationToMap(it)) }
727
+ }
728
+ sendEvent("motionchange", event)
729
+ success.invoke()
730
+ }
731
+
732
+ @SuppressLint("MissingPermission")
733
+ override fun getCurrentPosition(options: ReadableMap, success: Callback, failure: Callback) {
734
+ if (!hasLocationPermission()) { failure.invoke("PERMISSION_DENIED"); return }
735
+ if (!::fusedClient.isInitialized) {
736
+ fusedClient = LocationServices.getFusedLocationProviderClient(reactApplicationContext)
737
+ }
738
+ fusedClient.lastLocation.addOnSuccessListener { location ->
739
+ if (location != null) {
740
+ success.invoke(locationToMap(location))
741
+ } else {
742
+ // Request a fresh single update
743
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 0)
744
+ .setMaxUpdates(1)
745
+ .build()
746
+ val cb = object : LocationCallback() {
747
+ override fun onLocationResult(result: LocationResult) {
748
+ fusedClient.removeLocationUpdates(this)
749
+ result.lastLocation?.let { success.invoke(locationToMap(it)) }
750
+ ?: failure.invoke("LOCATION_UNAVAILABLE")
751
+ }
752
+ }
753
+ fusedClient.requestLocationUpdates(request, cb, Looper.getMainLooper())
754
+ }
755
+ }.addOnFailureListener {
756
+ failure.invoke(it.message ?: "LOCATION_UNAVAILABLE")
757
+ }
758
+ }
759
+
760
+ @SuppressLint("MissingPermission")
761
+ override fun watchPosition(options: ReadableMap, success: Callback, failure: Callback) {
762
+ if (!hasLocationPermission()) { failure.invoke("PERMISSION_DENIED"); return }
763
+ if (!::fusedClient.isInitialized) {
764
+ fusedClient = LocationServices.getFusedLocationProviderClient(reactApplicationContext)
765
+ }
766
+ val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L)
767
+ .setMinUpdateDistanceMeters(0f)
768
+ .build()
769
+ watchPositionCallback = object : LocationCallback() {
770
+ override fun onLocationResult(result: LocationResult) {
771
+ result.lastLocation?.let { sendEvent("watchposition", locationToMap(it)) }
772
+ }
773
+ }
774
+ fusedClient.requestLocationUpdates(request, watchPositionCallback!!, Looper.getMainLooper())
775
+ success.invoke()
776
+ }
777
+
778
+ override fun stopWatchPosition(success: Callback, failure: Callback) {
779
+ watchPositionCallback?.let {
780
+ if (::fusedClient.isInitialized) fusedClient.removeLocationUpdates(it)
781
+ }
782
+ watchPositionCallback = null
783
+ success.invoke()
784
+ }
785
+
786
+ // ─── Permissions ──────────────────────────────────────────────────────────
787
+
788
+ override fun requestPermission(success: Callback, failure: Callback) {
789
+ if (hasLocationPermission()) success.invoke(3) else failure.invoke(2)
790
+ }
791
+
792
+ override fun requestMotionPermission(success: Callback, failure: Callback) {
793
+ val granted =
794
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
795
+ ContextCompat.checkSelfPermission(
796
+ reactApplicationContext,
797
+ Manifest.permission.ACTIVITY_RECOGNITION
798
+ ) == PackageManager.PERMISSION_GRANTED
799
+ if (granted) success.invoke(3) else failure.invoke(2)
800
+ }
801
+
802
+ override fun requestTemporaryFullAccuracy(purpose: String, success: Callback, failure: Callback) {
803
+ success.invoke(0)
804
+ }
805
+
806
+ override fun getProviderState(success: Callback, failure: Callback) {
807
+ val lm = reactApplicationContext.getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
808
+ val gpsEnabled = lm.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER)
809
+ val netEnabled = lm.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER)
810
+ success.invoke(Arguments.createMap().apply {
811
+ putBoolean("enabled", gpsEnabled || netEnabled)
812
+ putBoolean("gps", gpsEnabled)
813
+ putBoolean("network", netEnabled)
814
+ putInt("status", if (hasLocationPermission()) 3 else 2)
815
+ putInt("accuracyAuthorization", 0)
816
+ })
817
+ }
818
+
819
+ // ─── HTTP & Persistence ───────────────────────────────────────────────────
820
+
821
+ override fun getLocations(success: Callback, failure: Callback) {
822
+ loadPersistedLocations()
823
+ success.invoke(Arguments.createArray().also { arr -> locationStore.forEach { arr.pushMap(it) } })
824
+ }
825
+
826
+ override fun getCount(success: Callback, failure: Callback) {
827
+ loadPersistedLocations()
828
+ success.invoke(locationStore.size)
829
+ }
830
+
831
+ override fun destroyLocations(success: Callback, failure: Callback) {
832
+ locationStore.clear()
833
+ clearPersistedLocations()
834
+ success.invoke()
835
+ }
836
+
837
+ override fun destroyLocation(uuid: String, success: Callback, failure: Callback) {
838
+ locationStore.removeAll { it.getString("uuid") == uuid }
839
+ rewritePersistedLocations()
840
+ success.invoke()
841
+ }
842
+
843
+ override fun insertLocation(location: ReadableMap, success: Callback, failure: Callback) {
844
+ val map = Arguments.createMap().apply { merge(location) }
845
+ locationStore.add(map)
846
+ persistLocationToDisk(map)
847
+ success.invoke(map)
848
+ }
849
+
850
+ override fun sync(success: Callback, failure: Callback) {
851
+ loadPersistedLocations()
852
+ if (config.optString("url", "").isEmpty()) { failure.invoke("NO_URL_CONFIGURED"); return }
853
+ val snapshot = locationStore.toList()
854
+ locationStore.clear()
855
+ clearPersistedLocations()
856
+ syncBatch(snapshot,
857
+ onSuccess = {
858
+ val arr = Arguments.createArray().also { a -> snapshot.forEach { a.pushMap(it) } }
859
+ success.invoke(arr)
860
+ },
861
+ onFailure = { msg ->
862
+ // restore on failure
863
+ locationStore.addAll(0, snapshot)
864
+ rewritePersistedLocations()
865
+ failure.invoke(msg)
866
+ }
867
+ )
868
+ }
869
+
870
+ // ─── Odometer ─────────────────────────────────────────────────────────────
871
+
872
+ override fun getOdometer(success: Callback, failure: Callback) { success.invoke(odometer) }
873
+
874
+ override fun setOdometer(value: Double, success: Callback, failure: Callback) {
875
+ odometer = value; success.invoke(Arguments.createMap())
876
+ }
877
+
878
+ // ─── Geofences ────────────────────────────────────────────────────────────
879
+
880
+ override fun addGeofence(config: ReadableMap, success: Callback, failure: Callback) {
881
+ val map = Arguments.createMap().apply { merge(config) }
882
+ geofenceStore.removeAll { it.getString("identifier") == config.getString("identifier") }
883
+ geofenceStore.add(map)
884
+ // Actually monitor it via GeofencingClient (works in bg/kill state)
885
+ registerGeofence(config)
886
+ success.invoke()
887
+ }
888
+
889
+ override fun addGeofences(geofences: ReadableArray, success: Callback, failure: Callback) {
890
+ for (i in 0 until geofences.size()) {
891
+ val gf = geofences.getMap(i) ?: continue
892
+ val id = gf.getString("identifier")
893
+ geofenceStore.removeAll { it.getString("identifier") == id }
894
+ geofenceStore.add(Arguments.createMap().apply { merge(gf) })
895
+ registerGeofence(gf)
896
+ }
897
+ success.invoke()
898
+ }
899
+
900
+ override fun removeGeofence(identifier: String, success: Callback, failure: Callback) {
901
+ geofenceStore.removeAll { it.getString("identifier") == identifier }
902
+ geofencingClient?.removeGeofences(listOf(identifier))
903
+ success.invoke()
904
+ }
905
+
906
+ override fun removeGeofences(success: Callback, failure: Callback) {
907
+ geofenceStore.clear()
908
+ geofencePendingIntent?.let { geofencingClient?.removeGeofences(it) }
909
+ success.invoke()
910
+ }
911
+
912
+ override fun getGeofences(success: Callback, failure: Callback) {
913
+ success.invoke(Arguments.createArray().also { a -> geofenceStore.forEach { a.pushMap(it) } })
914
+ }
915
+
916
+ override fun getGeofence(identifier: String, success: Callback, failure: Callback) {
917
+ val found = geofenceStore.find { it.getString("identifier") == identifier }
918
+ if (found != null) success.invoke(found) else failure.invoke("NOT_FOUND: $identifier")
919
+ }
920
+
921
+ override fun geofenceExists(identifier: String, callback: Callback) {
922
+ callback.invoke(geofenceStore.any { it.getString("identifier") == identifier })
923
+ }
924
+
925
+ // ─── Logging ──────────────────────────────────────────────────────────────
926
+
927
+ override fun log(level: String, message: String) {
928
+ when (level) {
929
+ "error" -> android.util.Log.e(NAME, message)
930
+ "warn" -> android.util.Log.w(NAME, message)
931
+ "debug" -> android.util.Log.d(NAME, message)
932
+ else -> android.util.Log.i(NAME, message)
933
+ }
934
+ }
935
+
936
+ override fun setLogLevel(value: Double, success: Callback, failure: Callback) {
937
+ config.put("logLevel", value.toInt()); success.invoke(stateMap())
938
+ }
939
+
940
+ override fun getLog(success: Callback, failure: Callback) { success.invoke("") }
941
+ override fun destroyLog(success: Callback, failure: Callback) { success.invoke() }
942
+ override fun emailLog(email: String, success: Callback, failure: Callback) { success.invoke() }
943
+
944
+ // ─── Utility ──────────────────────────────────────────────────────────────
945
+
946
+ override fun isPowerSaveMode(success: Callback, failure: Callback) {
947
+ val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
948
+ success.invoke(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) pm.isPowerSaveMode else false)
949
+ }
950
+
951
+ override fun getSensors(success: Callback, failure: Callback) {
952
+ val sm = reactApplicationContext.getSystemService(Context.SENSOR_SERVICE) as android.hardware.SensorManager
953
+ val has = { type: Int -> sm.getDefaultSensor(type) != null }
954
+ success.invoke(Arguments.createMap().apply {
955
+ putString("platform", "android")
956
+ putBoolean("accelerometer", has(android.hardware.Sensor.TYPE_ACCELEROMETER))
957
+ putBoolean("gyroscope", has(android.hardware.Sensor.TYPE_GYROSCOPE))
958
+ putBoolean("magnetometer", has(android.hardware.Sensor.TYPE_MAGNETIC_FIELD))
959
+ putBoolean("motionHardware",has(android.hardware.Sensor.TYPE_STEP_DETECTOR))
960
+ putInt(
961
+ "motionAuthorizationStatus",
962
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
963
+ ContextCompat.checkSelfPermission(
964
+ reactApplicationContext,
965
+ Manifest.permission.ACTIVITY_RECOGNITION
966
+ ) == PackageManager.PERMISSION_GRANTED
967
+ ) 3 else 2
968
+ )
969
+ })
970
+ }
971
+
972
+ override fun getDeviceInfo(success: Callback, failure: Callback) {
973
+ success.invoke(Arguments.createMap().apply {
974
+ putString("uuid", android.provider.Settings.Secure.getString(
975
+ reactApplicationContext.contentResolver, android.provider.Settings.Secure.ANDROID_ID)
976
+ ?: UUID.randomUUID().toString())
977
+ putString("model", Build.MODEL)
978
+ putString("platform", "android")
979
+ putString("manufacturer", Build.MANUFACTURER)
980
+ putString("version", Build.VERSION.RELEASE)
981
+ putString("framework", "react-native")
982
+ putString("frameworkVersion", "unknown")
983
+ })
984
+ }
985
+
986
+ // iOS-only API (Location Push Service Extension). Android has no equivalent,
987
+ // so we always resolve null.
988
+ override fun getLocationPushToken(success: Callback, failure: Callback) {
989
+ success.invoke(null as String?)
990
+ }
991
+
992
+ // iOS-only API (standard APNs token). Android has FCM instead → null here.
993
+ override fun getApnsDeviceToken(success: Callback, failure: Callback) {
994
+ success.invoke(null as String?)
995
+ }
996
+
997
+ // iOS-only API (Location Push Service Extension). No-op on Android.
998
+ override fun setLocationPushConfig(config: ReadableMap, success: Callback, failure: Callback) {
999
+ success.invoke()
1000
+ }
1001
+
1002
+ // iOS-only API (background location-push JS handoff). No-op on Android.
1003
+ override fun finishLocationPush(requestId: String, success: Callback, failure: Callback) {
1004
+ success.invoke()
1005
+ }
1006
+
1007
+ override fun playSound(soundId: Double) {
1008
+ try {
1009
+ val uri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION)
1010
+ android.media.RingtoneManager.getRingtone(reactApplicationContext, uri)?.play()
1011
+ } catch (e: Exception) { android.util.Log.w(NAME, "playSound failed: ${e.message}") }
1012
+ }
1013
+
1014
+
1015
+ override fun invalidate() {
1016
+ isReactContextAlive = false
1017
+ if (currentReactContext === reactApplicationContext) {
1018
+ currentReactContext = null
1019
+ }
1020
+ scope.cancel()
1021
+ if (::fusedClient.isInitialized) {
1022
+ watchPositionCallback?.let { fusedClient.removeLocationUpdates(it) }
1023
+ }
1024
+ stopActivityRecognition()
1025
+ stopHeartbeat()
1026
+ // Do NOT stop the foreground service — it owns location updates and must keep
1027
+ // running after the JS bridge tears down (this is what enables kill-state tracking).
1028
+ super.invalidate()
1029
+ }
1030
+ }