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.
- package/BgGeolocation.podspec +39 -0
- package/LICENSE +20 -0
- package/README.md +366 -0
- package/android/build.gradle +69 -0
- package/android/src/main/AndroidManifest.xml +53 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationActivityRecognitionReceiver.kt +116 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationBootReceiver.kt +44 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationForegroundService.kt +373 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationGeofenceReceiver.kt +55 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationHeadlessTask.kt +138 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationModule.kt +1030 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationMotionStateMachine.kt +159 -0
- package/android/src/main/java/com/bggeolocation/BgGeolocationPackage.kt +31 -0
- package/android/src/main/res/drawable/bg_geo_notification.xml +9 -0
- package/ios/BgGeolocation.h +14 -0
- package/ios/BgGeolocation.mm +709 -0
- package/ios/engine/AtomicBoolean.swift +48 -0
- package/ios/engine/BGActivityChangeEvent.swift +20 -0
- package/ios/engine/BGActivityConfig.swift +71 -0
- package/ios/engine/BGAppConfig.swift +92 -0
- package/ios/engine/BGAppState.swift +147 -0
- package/ios/engine/BGAuthorization.swift +85 -0
- package/ios/engine/BGAuthorizationAlertPresenter.swift +39 -0
- package/ios/engine/BGAuthorizationConfig.swift +50 -0
- package/ios/engine/BGAuthorizationEvent.swift +40 -0
- package/ios/engine/BGBackgroundTaskManager.swift +143 -0
- package/ios/engine/BGCLRouter.swift +101 -0
- package/ios/engine/BGCallback.swift +19 -0
- package/ios/engine/BGConfig.swift +440 -0
- package/ios/engine/BGConfigModuleBase.swift +180 -0
- package/ios/engine/BGConfigOLD.swift +582 -0
- package/ios/engine/BGConnectivityChangeEvent.swift +15 -0
- package/ios/engine/BGCrashDetector.swift +122 -0
- package/ios/engine/BGCurrentPositionRequest.swift +87 -0
- package/ios/engine/BGDataStore.swift +75 -0
- package/ios/engine/BGDatabase.swift +677 -0
- package/ios/engine/BGDatabasePool.swift +220 -0
- package/ios/engine/BGDatabaseQueue.swift +215 -0
- package/ios/engine/BGDateUtils.swift +26 -0
- package/ios/engine/BGDeviceInfo.swift +54 -0
- package/ios/engine/BGDeviceManager.swift +65 -0
- package/ios/engine/BGEnabledChangeEvent.swift +11 -0
- package/ios/engine/BGEnv.swift +17 -0
- package/ios/engine/BGEventBus.swift +83 -0
- package/ios/engine/BGEventManager.swift +169 -0
- package/ios/engine/BGEventNames.swift +51 -0
- package/ios/engine/BGGeofence.swift +233 -0
- package/ios/engine/BGGeofenceDAO.swift +152 -0
- package/ios/engine/BGGeofenceEvent.swift +42 -0
- package/ios/engine/BGGeofenceLocationRequest.swift +94 -0
- package/ios/engine/BGGeofenceManager.swift +315 -0
- package/ios/engine/BGGeofenceTransition.swift +97 -0
- package/ios/engine/BGGeofencesChangeEvent.swift +26 -0
- package/ios/engine/BGGeolocationConfig.swift +136 -0
- package/ios/engine/BGHeartbeatEvent.swift +31 -0
- package/ios/engine/BGHeartbeatService.swift +51 -0
- package/ios/engine/BGHttpConfig.swift +105 -0
- package/ios/engine/BGHttpErrorCodes.swift +63 -0
- package/ios/engine/BGHttpEvent.swift +34 -0
- package/ios/engine/BGHttpRequest.swift +126 -0
- package/ios/engine/BGHttpResponse.swift +93 -0
- package/ios/engine/BGHttpService.swift +428 -0
- package/ios/engine/BGKalmanFilter.swift +105 -0
- package/ios/engine/BGLMActionNames.swift +55 -0
- package/ios/engine/BGLicenseManager.swift +26 -0
- package/ios/engine/BGLiveActivityManager.swift +327 -0
- package/ios/engine/BGLocation.swift +311 -0
- package/ios/engine/BGLocationAuthorization.swift +427 -0
- package/ios/engine/BGLocationDAO.swift +252 -0
- package/ios/engine/BGLocationErrors.swift +28 -0
- package/ios/engine/BGLocationEvent.swift +43 -0
- package/ios/engine/BGLocationFilter.swift +82 -0
- package/ios/engine/BGLocationFilterConfig.swift +57 -0
- package/ios/engine/BGLocationHelper.swift +54 -0
- package/ios/engine/BGLocationManager.swift +662 -0
- package/ios/engine/BGLocationMetricsEngine.swift +116 -0
- package/ios/engine/BGLocationRequestService.swift +459 -0
- package/ios/engine/BGLocationSatisfier.swift +14 -0
- package/ios/engine/BGLocationStreamEvent.swift +27 -0
- package/ios/engine/BGLog.swift +337 -0
- package/ios/engine/BGLogLevel.swift +26 -0
- package/ios/engine/BGLoggerConfig.swift +60 -0
- package/ios/engine/BGMotionActivity.swift +31 -0
- package/ios/engine/BGMotionActivityClassifier.swift +108 -0
- package/ios/engine/BGMotionActivityManagerAdapter.swift +40 -0
- package/ios/engine/BGMotionActivitySource.swift +46 -0
- package/ios/engine/BGMotionDetector.swift +377 -0
- package/ios/engine/BGMotionPermissionManager.swift +50 -0
- package/ios/engine/BGNativeLogger.swift +48 -0
- package/ios/engine/BGNotificaitons.swift +37 -0
- package/ios/engine/BGOdometer.swift +66 -0
- package/ios/engine/BGPersistenceConfig.swift +29 -0
- package/ios/engine/BGPolygonStreamRequest.swift +48 -0
- package/ios/engine/BGPowerSaveChangeEvent.swift +12 -0
- package/ios/engine/BGPropertySpec.swift +29 -0
- package/ios/engine/BGProviderChangeEvent.swift +31 -0
- package/ios/engine/BGQueue.swift +50 -0
- package/ios/engine/BGRPC.swift +194 -0
- package/ios/engine/BGReachability.swift +58 -0
- package/ios/engine/BGResultSet.swift +157 -0
- package/ios/engine/BGSchedule.swift +228 -0
- package/ios/engine/BGScheduleEvent.swift +13 -0
- package/ios/engine/BGScheduler.swift +116 -0
- package/ios/engine/BGSingleLocationRequest.swift +49 -0
- package/ios/engine/BGStreamLocationRequest.swift +42 -0
- package/ios/engine/BGTemplate.swift +54 -0
- package/ios/engine/BGTimerService.swift +46 -0
- package/ios/engine/BGTrackingAudioManager.swift +286 -0
- package/ios/engine/BGTrackingService.swift +879 -0
- package/ios/engine/BGWatchPositionRequest.swift +63 -0
- package/ios/engine/DatabaseQueue.swift +47 -0
- package/ios/engine/LogQuery.swift +10 -0
- package/ios/engine/SQLQuery.swift +65 -0
- package/ios/engine/TransistorAuthorizationToken.swift +182 -0
- package/ios/liveactivity/BGLiveTrackingAttributes.swift +52 -0
- package/ios/locationpush/BGLocationPushDeliverer.swift +260 -0
- package/ios/locationpush/BGLocationPushService.swift +161 -0
- package/ios/locationpush/BGLocationPushShared.swift +98 -0
- package/ios/locationpush/BGLocationPushSocketClient.swift +198 -0
- package/lib/module/NativeBgGeolocation.js +5 -0
- package/lib/module/NativeBgGeolocation.js.map +1 -0
- package/lib/module/events.js +20 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +706 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeBgGeolocation.d.ts +57 -0
- package/lib/typescript/src/NativeBgGeolocation.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +18 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +238 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +229 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +141 -0
- package/src/NativeBgGeolocation.ts +236 -0
- package/src/events.ts +17 -0
- package/src/index.tsx +935 -0
- 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
|
+
}
|