@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.
@@ -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