@tsachit/react-native-geo-service 1.0.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/README.md +250 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +48 -0
- package/android/src/main/java/com/geoservice/BootReceiver.kt +73 -0
- package/android/src/main/java/com/geoservice/GeoServiceConfig.kt +56 -0
- package/android/src/main/java/com/geoservice/GeoServiceModule.kt +291 -0
- package/android/src/main/java/com/geoservice/GeoServicePackage.kt +14 -0
- package/android/src/main/java/com/geoservice/HeadlessLocationTask.kt +76 -0
- package/android/src/main/java/com/geoservice/LocationService.kt +265 -0
- package/ios/RNGeoService.h +19 -0
- package/ios/RNGeoService.m +311 -0
- package/lib/index.d.ts +73 -0
- package/lib/index.js +155 -0
- package/lib/types.d.ts +139 -0
- package/lib/types.js +2 -0
- package/package.json +42 -0
- package/react-native-geo-service.podspec +19 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
package com.geoservice
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.BroadcastReceiver
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
|
11
|
+
import com.facebook.react.bridge.*
|
|
12
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
13
|
+
import com.google.android.gms.location.LocationServices
|
|
14
|
+
import org.json.JSONObject
|
|
15
|
+
|
|
16
|
+
class GeoServiceModule(private val reactContext: ReactApplicationContext) :
|
|
17
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
const val TAG = "GeoService:Module"
|
|
21
|
+
const val MODULE_NAME = "RNGeoService"
|
|
22
|
+
|
|
23
|
+
// Tracks whether the React context / JS engine is alive.
|
|
24
|
+
// LocationService reads this to decide whether to use EventEmitter or HeadlessJS.
|
|
25
|
+
@Volatile
|
|
26
|
+
var isReactContextActive = false
|
|
27
|
+
private set
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private var config: GeoServiceConfig = GeoServiceConfig()
|
|
31
|
+
private var listenerCount = 0
|
|
32
|
+
private var locationReceiver: BroadcastReceiver? = null
|
|
33
|
+
|
|
34
|
+
init {
|
|
35
|
+
isReactContextActive = true
|
|
36
|
+
reactContext.addLifecycleEventListener(object : LifecycleEventListener {
|
|
37
|
+
override fun onHostResume() {
|
|
38
|
+
isReactContextActive = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override fun onHostPause() {
|
|
42
|
+
// Still alive; HeadlessJS not needed yet
|
|
43
|
+
isReactContextActive = true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override fun onHostDestroy() {
|
|
47
|
+
isReactContextActive = false
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override fun getName() = MODULE_NAME
|
|
53
|
+
|
|
54
|
+
// --------------------------------------------------------------------------------------------
|
|
55
|
+
// Required for NativeEventEmitter
|
|
56
|
+
// --------------------------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@ReactMethod
|
|
59
|
+
fun addListener(eventName: String) {
|
|
60
|
+
listenerCount++
|
|
61
|
+
if (listenerCount == 1) registerLocationReceiver()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@ReactMethod
|
|
65
|
+
fun removeListeners(count: Int) {
|
|
66
|
+
listenerCount = (listenerCount - count).coerceAtLeast(0)
|
|
67
|
+
if (listenerCount == 0) unregisterLocationReceiver()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --------------------------------------------------------------------------------------------
|
|
71
|
+
// JS-exposed API
|
|
72
|
+
// --------------------------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@ReactMethod
|
|
75
|
+
fun configure(options: ReadableMap, promise: Promise) {
|
|
76
|
+
try {
|
|
77
|
+
config = GeoServiceConfig(
|
|
78
|
+
minDistanceMeters = options.getDoubleOrDefault("minDistanceMeters", 10.0).toFloat(),
|
|
79
|
+
accuracy = options.getStringOrDefault("accuracy", "balanced"),
|
|
80
|
+
stopOnAppClose = options.getBooleanOrDefault("stopOnAppClose", false),
|
|
81
|
+
restartOnBoot = options.getBooleanOrDefault("restartOnBoot", false),
|
|
82
|
+
updateIntervalMs = options.getDoubleOrDefault("updateIntervalMs", 5000.0).toLong(),
|
|
83
|
+
minUpdateIntervalMs = options.getDoubleOrDefault("minUpdateIntervalMs", 2000.0).toLong(),
|
|
84
|
+
serviceTitle = options.getStringOrDefault("serviceTitle", "Location Tracking"),
|
|
85
|
+
serviceBody = options.getStringOrDefault("serviceBody", "Your location is being tracked in the background."),
|
|
86
|
+
backgroundTaskName = options.getStringOrDefault("backgroundTaskName", "GeoServiceHeadlessTask"),
|
|
87
|
+
adaptiveAccuracy = options.getBooleanOrDefault("adaptiveAccuracy", true),
|
|
88
|
+
idleSpeedThreshold = options.getDoubleOrDefault("idleSpeedThreshold", 0.5).toFloat(),
|
|
89
|
+
idleSampleCount = options.getDoubleOrDefault("idleSampleCount", 3.0).toInt(),
|
|
90
|
+
debug = options.getBooleanOrDefault("debug", false)
|
|
91
|
+
)
|
|
92
|
+
persistConfig()
|
|
93
|
+
log("Config updated: $config")
|
|
94
|
+
promise.resolve(null)
|
|
95
|
+
} catch (e: Exception) {
|
|
96
|
+
promise.reject("CONFIGURE_ERROR", e.message, e)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@ReactMethod
|
|
101
|
+
fun start(promise: Promise) {
|
|
102
|
+
try {
|
|
103
|
+
registerLocationReceiver()
|
|
104
|
+
startLocationService()
|
|
105
|
+
saveTrackingState(true)
|
|
106
|
+
log("Tracking started")
|
|
107
|
+
promise.resolve(null)
|
|
108
|
+
} catch (e: Exception) {
|
|
109
|
+
promise.reject("START_ERROR", e.message, e)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@ReactMethod
|
|
114
|
+
fun stop(promise: Promise) {
|
|
115
|
+
try {
|
|
116
|
+
reactContext.stopService(Intent(reactContext, LocationService::class.java))
|
|
117
|
+
saveTrackingState(false)
|
|
118
|
+
log("Tracking stopped")
|
|
119
|
+
promise.resolve(null)
|
|
120
|
+
} catch (e: Exception) {
|
|
121
|
+
promise.reject("STOP_ERROR", e.message, e)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@ReactMethod
|
|
126
|
+
fun isTracking(promise: Promise) {
|
|
127
|
+
promise.resolve(LocationService.isRunning)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@SuppressLint("MissingPermission")
|
|
131
|
+
@ReactMethod
|
|
132
|
+
fun getCurrentLocation(promise: Promise) {
|
|
133
|
+
val client = LocationServices.getFusedLocationProviderClient(reactContext)
|
|
134
|
+
client.lastLocation
|
|
135
|
+
.addOnSuccessListener { location ->
|
|
136
|
+
if (location != null) {
|
|
137
|
+
promise.resolve(locationToWritableMap(location))
|
|
138
|
+
} else {
|
|
139
|
+
promise.reject("NO_LOCATION", "No last known location available. Start tracking first.")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
.addOnFailureListener { e ->
|
|
143
|
+
promise.reject("LOCATION_ERROR", e.message, e)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --------------------------------------------------------------------------------------------
|
|
148
|
+
// Internal helpers
|
|
149
|
+
// --------------------------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
private fun startLocationService() {
|
|
152
|
+
val intent = Intent(reactContext, LocationService::class.java).apply {
|
|
153
|
+
putExtra("config", config.toBundle())
|
|
154
|
+
}
|
|
155
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
156
|
+
reactContext.startForegroundService(intent)
|
|
157
|
+
} else {
|
|
158
|
+
reactContext.startService(intent)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private fun registerLocationReceiver() {
|
|
163
|
+
if (locationReceiver != null) return
|
|
164
|
+
|
|
165
|
+
locationReceiver = object : BroadcastReceiver() {
|
|
166
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
167
|
+
when (intent.action) {
|
|
168
|
+
LocationService.ACTION_LOCATION_UPDATE -> {
|
|
169
|
+
val json = intent.getStringExtra(LocationService.EXTRA_LOCATION) ?: return
|
|
170
|
+
sendEvent("onLocation", json)
|
|
171
|
+
}
|
|
172
|
+
LocationService.ACTION_LOCATION_ERROR -> {
|
|
173
|
+
val json = intent.getStringExtra(LocationService.EXTRA_ERROR) ?: return
|
|
174
|
+
sendEvent("onError", json)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
val filter = IntentFilter().apply {
|
|
181
|
+
addAction(LocationService.ACTION_LOCATION_UPDATE)
|
|
182
|
+
addAction(LocationService.ACTION_LOCATION_ERROR)
|
|
183
|
+
}
|
|
184
|
+
LocalBroadcastManager.getInstance(reactContext)
|
|
185
|
+
.registerReceiver(locationReceiver!!, filter)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private fun unregisterLocationReceiver() {
|
|
189
|
+
locationReceiver?.let {
|
|
190
|
+
LocalBroadcastManager.getInstance(reactContext).unregisterReceiver(it)
|
|
191
|
+
locationReceiver = null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fun sendEvent(eventName: String, jsonString: String) {
|
|
196
|
+
if (!reactContext.hasActiveCatalystInstance()) return
|
|
197
|
+
try {
|
|
198
|
+
val map = Arguments.createMap()
|
|
199
|
+
val json = JSONObject(jsonString)
|
|
200
|
+
for (key in json.keys()) {
|
|
201
|
+
when (val value = json.get(key)) {
|
|
202
|
+
is Double -> map.putDouble(key, value)
|
|
203
|
+
is Int -> map.putInt(key, value)
|
|
204
|
+
is Long -> map.putDouble(key, value.toDouble())
|
|
205
|
+
is Boolean -> map.putBoolean(key, value)
|
|
206
|
+
is String -> map.putString(key, value)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
reactContext
|
|
210
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
211
|
+
.emit(eventName, map)
|
|
212
|
+
} catch (e: Exception) {
|
|
213
|
+
Log.e(TAG, "Error sending event: ${e.message}")
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun locationToWritableMap(location: android.location.Location): WritableMap {
|
|
218
|
+
return Arguments.createMap().apply {
|
|
219
|
+
putDouble("latitude", location.latitude)
|
|
220
|
+
putDouble("longitude", location.longitude)
|
|
221
|
+
putDouble("accuracy", location.accuracy.toDouble())
|
|
222
|
+
putDouble("altitude", location.altitude)
|
|
223
|
+
putDouble("altitudeAccuracy", if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
|
224
|
+
location.verticalAccuracyMeters.toDouble() else -1.0)
|
|
225
|
+
putDouble("speed", if (location.hasSpeed()) location.speed.toDouble() else -1.0)
|
|
226
|
+
putDouble("bearing", if (location.hasBearing()) location.bearing.toDouble() else -1.0)
|
|
227
|
+
putDouble("timestamp", location.time.toDouble())
|
|
228
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
229
|
+
putBoolean("isFromMockProvider", location.isMock)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun persistConfig() {
|
|
235
|
+
val prefs = reactContext.getSharedPreferences(
|
|
236
|
+
BootReceiver.PREFS_NAME, Context.MODE_PRIVATE
|
|
237
|
+
)
|
|
238
|
+
val json = JSONObject().apply {
|
|
239
|
+
put("minDistanceMeters", config.minDistanceMeters.toDouble())
|
|
240
|
+
put("accuracy", config.accuracy)
|
|
241
|
+
put("stopOnAppClose", config.stopOnAppClose)
|
|
242
|
+
put("restartOnBoot", config.restartOnBoot)
|
|
243
|
+
put("updateIntervalMs", config.updateIntervalMs)
|
|
244
|
+
put("minUpdateIntervalMs", config.minUpdateIntervalMs)
|
|
245
|
+
put("serviceTitle", config.serviceTitle)
|
|
246
|
+
put("serviceBody", config.serviceBody)
|
|
247
|
+
put("backgroundTaskName", config.backgroundTaskName)
|
|
248
|
+
put("adaptiveAccuracy", config.adaptiveAccuracy)
|
|
249
|
+
put("idleSpeedThreshold", config.idleSpeedThreshold.toDouble())
|
|
250
|
+
put("idleSampleCount", config.idleSampleCount)
|
|
251
|
+
put("debug", config.debug)
|
|
252
|
+
}
|
|
253
|
+
prefs.edit()
|
|
254
|
+
.putString("configBundle", json.toString())
|
|
255
|
+
.putBoolean(BootReceiver.KEY_RESTART_ON_BOOT, config.restartOnBoot)
|
|
256
|
+
.apply()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private fun saveTrackingState(isTracking: Boolean) {
|
|
260
|
+
reactContext.getSharedPreferences(BootReceiver.PREFS_NAME, Context.MODE_PRIVATE)
|
|
261
|
+
.edit()
|
|
262
|
+
.putBoolean(BootReceiver.KEY_IS_TRACKING, isTracking)
|
|
263
|
+
.apply()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun log(msg: String) {
|
|
267
|
+
if (config.debug) Log.d(TAG, msg)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
override fun invalidate() {
|
|
271
|
+
super.invalidate()
|
|
272
|
+
isReactContextActive = false
|
|
273
|
+
unregisterLocationReceiver()
|
|
274
|
+
if (config.stopOnAppClose) {
|
|
275
|
+
reactContext.stopService(Intent(reactContext, LocationService::class.java))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --------------------------------------------------------------------------------------------
|
|
281
|
+
// Extension helpers for ReadableMap
|
|
282
|
+
// --------------------------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
private fun ReadableMap.getDoubleOrDefault(key: String, default: Double): Double =
|
|
285
|
+
if (hasKey(key) && !isNull(key)) getDouble(key) else default
|
|
286
|
+
|
|
287
|
+
private fun ReadableMap.getStringOrDefault(key: String, default: String): String =
|
|
288
|
+
if (hasKey(key) && !isNull(key)) getString(key) ?: default else default
|
|
289
|
+
|
|
290
|
+
private fun ReadableMap.getBooleanOrDefault(key: String, default: Boolean): Boolean =
|
|
291
|
+
if (hasKey(key) && !isNull(key)) getBoolean(key) else default
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package com.geoservice
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class GeoServicePackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(GeoServiceModule(reactContext))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
13
|
+
emptyList()
|
|
14
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package com.geoservice
|
|
2
|
+
|
|
3
|
+
import android.content.Intent
|
|
4
|
+
import android.os.Bundle
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.facebook.react.HeadlessJsTaskService
|
|
7
|
+
import com.facebook.react.bridge.Arguments
|
|
8
|
+
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* HeadlessJS task service invoked by LocationService when no React context is active.
|
|
13
|
+
*
|
|
14
|
+
* This allows JavaScript code to run in the background (e.g. to save the location to
|
|
15
|
+
* a server or local storage) even when the app UI is not visible.
|
|
16
|
+
*
|
|
17
|
+
* The user must register a matching headless task in their app's index.js:
|
|
18
|
+
*
|
|
19
|
+
* AppRegistry.registerHeadlessTask('GeoServiceHeadlessTask', () => async (location) => {
|
|
20
|
+
* // handle background location update
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
class HeadlessLocationTask : HeadlessJsTaskService() {
|
|
24
|
+
|
|
25
|
+
companion object {
|
|
26
|
+
const val TAG = "GeoService:HeadlessTask"
|
|
27
|
+
// Max time (ms) the headless task is allowed to run before the OS kills it.
|
|
28
|
+
const val TIMEOUT_MS = 30_000L
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
|
32
|
+
val taskName = intent?.getStringExtra("taskName") ?: run {
|
|
33
|
+
Log.w(TAG, "No taskName provided to HeadlessLocationTask")
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
val locationJson = intent.getStringExtra("location") ?: run {
|
|
37
|
+
Log.w(TAG, "No location data provided to HeadlessLocationTask")
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
val extras = buildTaskExtras(locationJson) ?: run {
|
|
42
|
+
Log.e(TAG, "Failed to parse location JSON")
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Log.d(TAG, "Starting headless task: $taskName")
|
|
47
|
+
return HeadlessJsTaskConfig(
|
|
48
|
+
taskName,
|
|
49
|
+
extras,
|
|
50
|
+
TIMEOUT_MS,
|
|
51
|
+
true // allow task to run in foreground (when app is visible too)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun buildTaskExtras(locationJson: String): com.facebook.react.bridge.WritableMap? {
|
|
56
|
+
return try {
|
|
57
|
+
val json = JSONObject(locationJson)
|
|
58
|
+
val map = Arguments.createMap()
|
|
59
|
+
map.putDouble("latitude", json.getDouble("latitude"))
|
|
60
|
+
map.putDouble("longitude", json.getDouble("longitude"))
|
|
61
|
+
map.putDouble("accuracy", json.getDouble("accuracy"))
|
|
62
|
+
map.putDouble("altitude", json.getDouble("altitude"))
|
|
63
|
+
map.putDouble("altitudeAccuracy", json.getDouble("altitudeAccuracy"))
|
|
64
|
+
map.putDouble("speed", json.getDouble("speed"))
|
|
65
|
+
map.putDouble("bearing", json.getDouble("bearing"))
|
|
66
|
+
map.putDouble("timestamp", json.getDouble("timestamp"))
|
|
67
|
+
if (json.has("isFromMockProvider")) {
|
|
68
|
+
map.putBoolean("isFromMockProvider", json.getBoolean("isFromMockProvider"))
|
|
69
|
+
}
|
|
70
|
+
map
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
Log.e(TAG, "Error building task extras: ${e.message}")
|
|
73
|
+
null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
package com.geoservice
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.app.*
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.IBinder
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
|
11
|
+
import com.google.android.gms.location.*
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
|
|
14
|
+
class LocationService : Service() {
|
|
15
|
+
|
|
16
|
+
companion object {
|
|
17
|
+
const val TAG = "GeoService:LocationService"
|
|
18
|
+
const val ACTION_LOCATION_UPDATE = "com.geoservice.LOCATION_UPDATE"
|
|
19
|
+
const val ACTION_LOCATION_ERROR = "com.geoservice.LOCATION_ERROR"
|
|
20
|
+
const val EXTRA_LOCATION = "location"
|
|
21
|
+
const val EXTRA_ERROR = "error"
|
|
22
|
+
const val NOTIFICATION_CHANNEL_ID = "geo_service_channel"
|
|
23
|
+
const val NOTIFICATION_ID = 9731
|
|
24
|
+
|
|
25
|
+
var isRunning = false
|
|
26
|
+
private set
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
|
30
|
+
private lateinit var locationCallback: LocationCallback
|
|
31
|
+
private var config: GeoServiceConfig = GeoServiceConfig()
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Adaptive accuracy state
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
private var slowReadingCount = 0
|
|
37
|
+
private var isIdle = false
|
|
38
|
+
|
|
39
|
+
override fun onCreate() {
|
|
40
|
+
super.onCreate()
|
|
41
|
+
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
|
42
|
+
createNotificationChannel()
|
|
43
|
+
log("Service created")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
47
|
+
val bundle = intent?.getBundleExtra("config")
|
|
48
|
+
config = GeoServiceConfig.fromBundle(bundle)
|
|
49
|
+
|
|
50
|
+
slowReadingCount = 0
|
|
51
|
+
isIdle = false
|
|
52
|
+
|
|
53
|
+
log("Starting — adaptiveAccuracy=${config.adaptiveAccuracy}, accuracy=${config.accuracy}")
|
|
54
|
+
startForeground(NOTIFICATION_ID, buildNotification())
|
|
55
|
+
startLocationUpdates(idleOverride = false)
|
|
56
|
+
isRunning = true
|
|
57
|
+
|
|
58
|
+
return START_STICKY
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Location updates
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@SuppressLint("MissingPermission")
|
|
66
|
+
private fun startLocationUpdates(idleOverride: Boolean) {
|
|
67
|
+
if (::locationCallback.isInitialized) {
|
|
68
|
+
fusedLocationClient.removeLocationUpdates(locationCallback)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
val (priority, interval, distanceMeters) = if (idleOverride) {
|
|
72
|
+
// Device is idle: cell-tower only, long interval, large gate.
|
|
73
|
+
// GPS chip goes completely idle.
|
|
74
|
+
Triple(Priority.PRIORITY_LOW_POWER, 30_000L, 50f)
|
|
75
|
+
} else {
|
|
76
|
+
Triple(activePriority(), config.updateIntervalMs, config.minDistanceMeters)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
val locationRequest = LocationRequest.Builder(priority, interval)
|
|
80
|
+
.setMinUpdateIntervalMillis(if (idleOverride) 30_000L else config.minUpdateIntervalMs)
|
|
81
|
+
.setMinUpdateDistanceMeters(distanceMeters)
|
|
82
|
+
// Batching: OS can hold fixes and deliver them together → fewer CPU wake-ups.
|
|
83
|
+
.setMaxUpdateDelayMillis(interval * 2)
|
|
84
|
+
.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
|
|
85
|
+
.build()
|
|
86
|
+
|
|
87
|
+
locationCallback = object : LocationCallback() {
|
|
88
|
+
override fun onLocationResult(result: LocationResult) {
|
|
89
|
+
result.lastLocation?.let { handleLocation(it) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override fun onLocationAvailability(availability: LocationAvailability) {
|
|
93
|
+
if (!availability.isLocationAvailable) {
|
|
94
|
+
broadcastError(1, "Location is not available")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, mainLooper)
|
|
101
|
+
log("Updates registered — idle=$idleOverride, priority=$priority, interval=${interval}ms, dist=${distanceMeters}m")
|
|
102
|
+
} catch (e: SecurityException) {
|
|
103
|
+
broadcastError(2, "Location permission denied: ${e.message}")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Adaptive accuracy — speed-based GPS on/off switching
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
private fun handleLocation(location: android.location.Location) {
|
|
112
|
+
if (config.adaptiveAccuracy) {
|
|
113
|
+
evaluateMotionState(location)
|
|
114
|
+
}
|
|
115
|
+
broadcastLocation(location)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private fun evaluateMotionState(location: android.location.Location) {
|
|
119
|
+
val speed = if (location.hasSpeed()) location.speed else 0f
|
|
120
|
+
|
|
121
|
+
if (speed < config.idleSpeedThreshold) {
|
|
122
|
+
slowReadingCount++
|
|
123
|
+
if (!isIdle && slowReadingCount >= config.idleSampleCount) {
|
|
124
|
+
isIdle = true
|
|
125
|
+
slowReadingCount = 0
|
|
126
|
+
log("Device idle — switching to LOW_POWER (GPS off)")
|
|
127
|
+
startLocationUpdates(idleOverride = true)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
if (isIdle) {
|
|
131
|
+
isIdle = false
|
|
132
|
+
slowReadingCount = 0
|
|
133
|
+
log("Movement detected — restoring ${config.accuracy} accuracy")
|
|
134
|
+
startLocationUpdates(idleOverride = false)
|
|
135
|
+
} else {
|
|
136
|
+
slowReadingCount = 0
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private fun activePriority(): Int = when (config.accuracy) {
|
|
142
|
+
"navigation", "high" -> Priority.PRIORITY_HIGH_ACCURACY
|
|
143
|
+
"low" -> Priority.PRIORITY_LOW_POWER
|
|
144
|
+
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Broadcast helpers
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
private fun broadcastLocation(location: android.location.Location) {
|
|
152
|
+
val locationJson = JSONObject().apply {
|
|
153
|
+
put("latitude", location.latitude)
|
|
154
|
+
put("longitude", location.longitude)
|
|
155
|
+
put("accuracy", location.accuracy.toDouble())
|
|
156
|
+
put("altitude", location.altitude)
|
|
157
|
+
put("altitudeAccuracy", if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
|
158
|
+
location.verticalAccuracyMeters.toDouble() else -1.0)
|
|
159
|
+
put("speed", if (location.hasSpeed()) location.speed.toDouble() else -1.0)
|
|
160
|
+
put("bearing", if (location.hasBearing()) location.bearing.toDouble() else -1.0)
|
|
161
|
+
put("timestamp", location.time)
|
|
162
|
+
put("isStationary", isIdle)
|
|
163
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
164
|
+
put("isFromMockProvider", location.isMock)
|
|
165
|
+
}
|
|
166
|
+
}.toString()
|
|
167
|
+
|
|
168
|
+
log("Location: lat=${location.latitude}, lng=${location.longitude}, speed=${location.speed} m/s, idle=$isIdle")
|
|
169
|
+
|
|
170
|
+
val localIntent = Intent(ACTION_LOCATION_UPDATE).apply {
|
|
171
|
+
putExtra(EXTRA_LOCATION, locationJson)
|
|
172
|
+
}
|
|
173
|
+
LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent)
|
|
174
|
+
|
|
175
|
+
if (!GeoServiceModule.isReactContextActive && config.backgroundTaskName.isNotEmpty()) {
|
|
176
|
+
startHeadlessTask(locationJson)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun broadcastError(code: Int, message: String) {
|
|
181
|
+
Log.e(TAG, "Location error [$code]: $message")
|
|
182
|
+
val errorJson = JSONObject().apply {
|
|
183
|
+
put("code", code)
|
|
184
|
+
put("message", message)
|
|
185
|
+
}.toString()
|
|
186
|
+
val intent = Intent(ACTION_LOCATION_ERROR).apply {
|
|
187
|
+
putExtra(EXTRA_ERROR, errorJson)
|
|
188
|
+
}
|
|
189
|
+
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private fun startHeadlessTask(locationJson: String) {
|
|
193
|
+
log("Starting background task: ${config.backgroundTaskName}")
|
|
194
|
+
val intent = Intent(this, HeadlessLocationTask::class.java).apply {
|
|
195
|
+
putExtra("location", locationJson)
|
|
196
|
+
putExtra("taskName", config.backgroundTaskName)
|
|
197
|
+
}
|
|
198
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
199
|
+
startForegroundService(intent)
|
|
200
|
+
} else {
|
|
201
|
+
startService(intent)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Notification
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
private fun createNotificationChannel() {
|
|
210
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
211
|
+
val channel = NotificationChannel(
|
|
212
|
+
NOTIFICATION_CHANNEL_ID,
|
|
213
|
+
"Location Tracking",
|
|
214
|
+
NotificationManager.IMPORTANCE_LOW
|
|
215
|
+
).apply {
|
|
216
|
+
description = "Background location tracking"
|
|
217
|
+
setShowBadge(false)
|
|
218
|
+
enableVibration(false)
|
|
219
|
+
setSound(null, null)
|
|
220
|
+
}
|
|
221
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
222
|
+
manager.createNotificationChannel(channel)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private fun buildNotification(): Notification {
|
|
227
|
+
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
228
|
+
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
229
|
+
} else {
|
|
230
|
+
@Suppress("DEPRECATION")
|
|
231
|
+
Notification.Builder(this)
|
|
232
|
+
}
|
|
233
|
+
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
|
234
|
+
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
|
235
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
236
|
+
else
|
|
237
|
+
PendingIntent.FLAG_UPDATE_CURRENT
|
|
238
|
+
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
|
|
239
|
+
|
|
240
|
+
return builder
|
|
241
|
+
.setContentTitle(config.serviceTitle)
|
|
242
|
+
.setContentText(config.serviceBody)
|
|
243
|
+
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
|
|
244
|
+
.setOngoing(true)
|
|
245
|
+
.setContentIntent(pendingIntent)
|
|
246
|
+
.build()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
override fun onDestroy() {
|
|
252
|
+
super.onDestroy()
|
|
253
|
+
if (::locationCallback.isInitialized) {
|
|
254
|
+
fusedLocationClient.removeLocationUpdates(locationCallback)
|
|
255
|
+
}
|
|
256
|
+
isRunning = false
|
|
257
|
+
log("Service destroyed")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
261
|
+
|
|
262
|
+
private fun log(msg: String) {
|
|
263
|
+
if (config.debug) Log.d(TAG, msg)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTEventEmitter.h>
|
|
3
|
+
#import <CoreLocation/CoreLocation.h>
|
|
4
|
+
|
|
5
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RNGeoService — Battery-efficient background geolocation for React Native (iOS)
|
|
9
|
+
*
|
|
10
|
+
* Supports:
|
|
11
|
+
* - Standard location updates (fine-grained, respects minDistanceMeters)
|
|
12
|
+
* - Significant location changes (coarse, very battery-efficient, wakes app when terminated)
|
|
13
|
+
* - Background location via UIBackgroundModes: location
|
|
14
|
+
*/
|
|
15
|
+
@interface RNGeoService : RCTEventEmitter <RCTBridgeModule, CLLocationManagerDelegate>
|
|
16
|
+
|
|
17
|
+
@end
|
|
18
|
+
|
|
19
|
+
NS_ASSUME_NONNULL_END
|