react-native-background-live-tracking 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 ADDED
@@ -0,0 +1,153 @@
1
+ # react-native-background-live-tracking
2
+
3
+ Android-only React Native library for **continuous driver GPS tracking** in the foreground, background, and after the user swipes the app away. Locations are sent to your backend over **REST** and optionally **WebSocket** or **Socket.IO**, with an **offline queue**, **retries**, and a **foreground service** with a persistent notification.
4
+
5
+ iOS autolinking is disabled; the JavaScript API no-ops on iOS so your monorepo can still import the module safely.
6
+
7
+ ## Features
8
+
9
+ - Fused location updates with configurable interval (milliseconds)
10
+ - `START_STICKY` foreground service; optional restart after **boot** if tracking was active
11
+ - REST `POST` (JSON body) on every fix, plus optional real-time channel
12
+ - Offline **JSONL queue** on disk and periodic flush + per-request retries
13
+ - Permission checks with clear promise rejections
14
+ - Optional `openBatterySettings()` to request **ignore battery optimizations**
15
+ - JS events: `Tracking.addLocationListener`, `Tracking.addStatusListener`, `Tracking.isActive()`
16
+ - Optional **notification map preview**: static map **image** via Google Static Maps (not a live map)
17
+
18
+ ## Why not a live map like iOS / Uber on the lock screen?
19
+
20
+ - **iOS** “Live Activities” use **ActivityKit** with SwiftUI and **MapKit**. That is a separate system UI; it is not how Android works.
21
+ - **Android** foreground-service notifications are built from **RemoteViews**: mostly `TextView` / `ImageView`. You **cannot** put a real, continuously updating **Google Map (MapView)** inside the notification shade the same way.
22
+ - Ride apps on Android usually combine a **text notification** plus **in-app** or **full-screen** UI. A **similar visual** in the tray is normally a **bitmap**: either a **Static Maps** image (what we support optionally) or your own server-rendered snapshot.
23
+
24
+ ### Optional: map snapshot in the expanded notification
25
+
26
+ Enable the [Google Static Maps API](https://developers.google.com/maps/documentation/maps-static/overview) on a Cloud project (billing required). Then:
27
+
28
+ ```ts
29
+ await Tracking.start({
30
+ driverId: 'driver-123',
31
+ interval: 5000,
32
+ serverUrl: 'https://api.example.com/v1/drivers/location',
33
+ notificationTitle: 'Driver En Route',
34
+ notificationBody: 'Live location is active',
35
+ notificationMapPreview: true,
36
+ googleStaticMapsApiKey: 'YOUR_KEY',
37
+ pickup: { latitude: 12.97, longitude: 77.59 }, // optional second marker
38
+ });
39
+ ```
40
+
41
+ The image appears in the **expanded** notification (pull down / long press depending on OEM). Updates are **throttled** (about every 45s) to limit API usage.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ npm install react-native-background-live-tracking
47
+ # or
48
+ yarn add react-native-background-live-tracking
49
+ ```
50
+
51
+ ### Metro (monorepo / `file:` dependency)
52
+
53
+ If the package lives outside your app root, add its folder to `watchFolders` in `metro.config.js` (see this repo’s root config).
54
+
55
+ ### Autolinking
56
+
57
+ Android is configured via `react-native.config.js` (`TrackingPackage`). Rebuild the native app after install.
58
+
59
+ ## Permissions
60
+
61
+ The library declares the required permissions. Your app must **request** at runtime (in order on Android 10+):
62
+
63
+ 1. `ACCESS_FINE_LOCATION`
64
+ 2. `ACCESS_BACKGROUND_LOCATION` (needed when the app is killed)
65
+ 3. `POST_NOTIFICATIONS` (Android 13+)
66
+
67
+ Use `PermissionsAndroid` or your preferred permission helper **before** calling `Tracking.start`.
68
+
69
+ ## JavaScript API
70
+
71
+ ```ts
72
+ import { Tracking } from 'react-native-background-live-tracking';
73
+
74
+ await Tracking.start({
75
+ driverId: 'driver-123',
76
+ interval: 5000, // milliseconds (e.g. 5 seconds)
77
+ serverUrl: 'https://api.example.com/v1/drivers/location',
78
+ socketUrl: 'wss://realtime.example.com/driver', // optional
79
+ socketTransport: 'websocket', // optional: 'websocket' | 'socket.io' | omit to auto-detect
80
+ notificationTitle: 'Driver En Route',
81
+ notificationBody: 'Sharing live location',
82
+ autoStartOnBoot: true,
83
+ restHeaders: { Authorization: 'Bearer …' },
84
+ notificationMapPreview: false,
85
+ googleStaticMapsApiKey: '',
86
+ });
87
+
88
+ await Tracking.stop();
89
+
90
+ const active = await Tracking.isActive();
91
+ ```
92
+
93
+ ### Events
94
+
95
+ ```ts
96
+ const sub = Tracking.addLocationListener((loc) => {
97
+ // { latitude, longitude, timestamp, driverId, accuracy, speed, bearing }
98
+ });
99
+ sub.remove();
100
+
101
+ Tracking.addStatusListener(({ active }) => { ... });
102
+ ```
103
+
104
+ ### Battery optimization
105
+
106
+ ```ts
107
+ await Tracking.openBatterySettings();
108
+ ```
109
+
110
+ OEMs may still restrict background work; combining a foreground service + user opt-out of optimization is the practical maximum on stock Android.
111
+
112
+ ## Backend contract
113
+
114
+ ### REST
115
+
116
+ `POST` to `serverUrl` with `Content-Type: application/json` and body:
117
+
118
+ ```json
119
+ {
120
+ "latitude": 19.076,
121
+ "longitude": 72.8777,
122
+ "timestamp": 1711728000123,
123
+ "driverId": "driver-123",
124
+ "accuracy": 12.5,
125
+ "speed": 8.2,
126
+ "bearing": 90.0
127
+ }
128
+ ```
129
+
130
+ Optional headers come from `restHeaders`.
131
+
132
+ ### WebSocket
133
+
134
+ When `socketTransport` is `websocket` (or inferred), each fix is sent as a **text frame** containing the same JSON string. Use `wss://` or `ws://` URLs (or `https://` / `http://`, which are normalized to WebSocket schemes).
135
+
136
+ ### Socket.IO
137
+
138
+ When `socketTransport` is `socket.io` (or the URL suggests it), the client connects with the **socket.io-java-client** and emits event name **`location`** with a JSON object payload (same fields as above). Point `socketUrl` at your server origin, e.g. `https://api.example.com` (path `/socket.io` is default).
139
+
140
+ ## Native implementation notes
141
+
142
+ - **Service:** `LocationForegroundService`, `foregroundServiceType="location"`
143
+ - **Location:** Google Play services `FusedLocationProviderClient`, balanced power by default (tunable in native code if you fork)
144
+ - **Boot:** `BootCompletedReceiver` restarts the service when `autoStartOnBoot` is true and tracking was active
145
+ - **Queue:** `filesDir/rn_blt_location_queue.jsonl`, capped length 2000
146
+
147
+ ## Example app
148
+
149
+ This repository’s root React Native app depends on the package via `file:./packages/react-native-background-live-tracking` and includes a minimal driver-style UI in `App.tsx`.
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,36 @@
1
+ apply plugin: "com.android.library"
2
+ apply plugin: "org.jetbrains.kotlin.android"
3
+
4
+ def safeExtGet = { String prop, def fallback ->
5
+ return rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
6
+ }
7
+
8
+ android {
9
+ namespace "com.reactnativebackgroundlivetracking"
10
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
11
+ defaultConfig {
12
+ minSdkVersion safeExtGet("minSdkVersion", 24)
13
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
14
+ consumerProguardFiles "consumer-rules.pro"
15
+ }
16
+ compileOptions {
17
+ sourceCompatibility JavaVersion.VERSION_17
18
+ targetCompatibility JavaVersion.VERSION_17
19
+ }
20
+ kotlinOptions {
21
+ jvmTarget = "17"
22
+ }
23
+ }
24
+
25
+ repositories {
26
+ google()
27
+ mavenCentral()
28
+ }
29
+
30
+ dependencies {
31
+ implementation "com.facebook.react:react-android"
32
+ implementation "org.jetbrains.kotlin:kotlin-stdlib"
33
+ implementation "com.google.android.gms:play-services-location:21.3.0"
34
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
35
+ implementation "io.socket:socket.io-client:2.1.1"
36
+ }
@@ -0,0 +1,3 @@
1
+ -keep class okhttp3.** { *; }
2
+ -dontwarn okhttp3.**
3
+ -dontwarn okio.**
@@ -0,0 +1,30 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
6
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
7
+ <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
8
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
10
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
11
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
12
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
13
+ <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
14
+
15
+ <application>
16
+ <service
17
+ android:name=".LocationForegroundService"
18
+ android:exported="false"
19
+ android:foregroundServiceType="location" />
20
+
21
+ <receiver
22
+ android:name=".BootCompletedReceiver"
23
+ android:exported="true"
24
+ android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
25
+ <intent-filter>
26
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
27
+ </intent-filter>
28
+ </receiver>
29
+ </application>
30
+ </manifest>
@@ -0,0 +1,21 @@
1
+ package com.reactnativebackgroundlivetracking
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.os.Build
7
+
8
+ class BootCompletedReceiver : BroadcastReceiver() {
9
+ override fun onReceive(context: Context, intent: Intent?) {
10
+ if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
11
+ val prefs = TrackingPreferences(context.applicationContext)
12
+ if (!prefs.isTrackingActive || !prefs.autoStartOnBoot) return
13
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
14
+ // Foreground service from boot: ensure manifest permission is present
15
+ }
16
+ try {
17
+ LocationForegroundService.start(context.applicationContext)
18
+ } catch (_: Exception) {
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,287 @@
1
+ package com.reactnativebackgroundlivetracking
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.graphics.Bitmap
7
+ import android.app.PendingIntent
8
+ import android.app.Service
9
+ import android.content.Context
10
+ import android.content.Intent
11
+ import android.content.pm.ServiceInfo
12
+ import android.location.Location
13
+ import android.os.Build
14
+ import android.os.Handler
15
+ import android.os.IBinder
16
+ import android.os.Looper
17
+ import androidx.core.app.NotificationCompat
18
+ import androidx.core.content.ContextCompat
19
+ import com.google.android.gms.location.LocationCallback
20
+ import com.google.android.gms.location.LocationRequest
21
+ import com.google.android.gms.location.LocationResult
22
+ import com.google.android.gms.location.LocationServices
23
+ import com.google.android.gms.location.Priority
24
+ import java.util.concurrent.Executors
25
+ import org.json.JSONObject
26
+
27
+ class LocationForegroundService : Service() {
28
+ private val executor = Executors.newSingleThreadExecutor()
29
+ private val staticMapExecutor = Executors.newSingleThreadExecutor()
30
+ private val mainHandler = Handler(Looper.getMainLooper())
31
+ private var locationCallback: LocationCallback? = null
32
+ private var networkClient: LocationNetworkClient? = null
33
+ private var queueStore: LocationQueueStore? = null
34
+
35
+ private var lastStaticMapFetchMs = 0L
36
+ private val flushRunnable =
37
+ object : Runnable {
38
+ override fun run() {
39
+ executor.execute {
40
+ try {
41
+ networkClient?.flushQueue()
42
+ } catch (_: Exception) {
43
+ }
44
+ }
45
+ mainHandler.postDelayed(this, QUEUE_FLUSH_INTERVAL_MS)
46
+ }
47
+ }
48
+
49
+ override fun onBind(intent: Intent?): IBinder? = null
50
+
51
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
52
+ if (intent?.action == ACTION_STOP) {
53
+ shutdown()
54
+ return START_NOT_STICKY
55
+ }
56
+
57
+ val prefs = TrackingPreferences(this)
58
+ if (!prefs.isTrackingActive) {
59
+ stopSelf()
60
+ return START_NOT_STICKY
61
+ }
62
+
63
+ queueStore = LocationQueueStore(this)
64
+ networkClient = LocationNetworkClient(this, prefs, queueStore!!)
65
+ networkClient?.connectSocketsIfNeeded()
66
+
67
+ ensureNotificationChannel(prefs)
68
+ val notification = buildNotification(prefs, mapBitmap = null)
69
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
70
+ startForeground(
71
+ NOTIFICATION_ID,
72
+ notification,
73
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
74
+ )
75
+ } else {
76
+ startForeground(NOTIFICATION_ID, notification)
77
+ }
78
+
79
+ requestLocationUpdates(prefs)
80
+ mainHandler.removeCallbacks(flushRunnable)
81
+ mainHandler.post(flushRunnable)
82
+
83
+ return START_STICKY
84
+ }
85
+
86
+ override fun onDestroy() {
87
+ mainHandler.removeCallbacks(flushRunnable)
88
+ shutdownLocationUpdates()
89
+ networkClient?.disconnectSockets()
90
+ super.onDestroy()
91
+ }
92
+
93
+ private fun shutdown() {
94
+ mainHandler.removeCallbacks(flushRunnable)
95
+ shutdownLocationUpdates()
96
+ networkClient?.disconnectSockets()
97
+ staticMapExecutor.shutdownNow()
98
+ stopForeground(STOP_FOREGROUND_REMOVE)
99
+ stopSelf()
100
+ }
101
+
102
+ private fun useMapStyleChannel(prefs: TrackingPreferences): Boolean {
103
+ return prefs.notificationMapPreview && prefs.staticMapsApiKey.isNotBlank()
104
+ }
105
+
106
+ private fun ensureNotificationChannel(prefs: TrackingPreferences) {
107
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
108
+ val nm = getSystemService(NotificationManager::class.java)
109
+ val low =
110
+ NotificationChannel(
111
+ getString(R.string.rn_blt_channel_id),
112
+ getString(R.string.rn_blt_channel_name),
113
+ NotificationManager.IMPORTANCE_LOW,
114
+ )
115
+ nm.createNotificationChannel(low)
116
+ val map =
117
+ NotificationChannel(
118
+ getString(R.string.rn_blt_channel_id_map),
119
+ getString(R.string.rn_blt_channel_name_map),
120
+ NotificationManager.IMPORTANCE_DEFAULT,
121
+ )
122
+ nm.createNotificationChannel(map)
123
+ }
124
+
125
+ private fun notificationChannelId(prefs: TrackingPreferences): String {
126
+ return if (useMapStyleChannel(prefs)) {
127
+ getString(R.string.rn_blt_channel_id_map)
128
+ } else {
129
+ getString(R.string.rn_blt_channel_id)
130
+ }
131
+ }
132
+
133
+ private fun buildNotification(
134
+ prefs: TrackingPreferences,
135
+ mapBitmap: Bitmap?,
136
+ ): Notification {
137
+ val channelId = notificationChannelId(prefs)
138
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
139
+ val pendingIntent =
140
+ PendingIntent.getActivity(
141
+ this,
142
+ 0,
143
+ launchIntent,
144
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
145
+ )
146
+ val priority =
147
+ if (useMapStyleChannel(prefs)) {
148
+ NotificationCompat.PRIORITY_DEFAULT
149
+ } else {
150
+ NotificationCompat.PRIORITY_LOW
151
+ }
152
+ val builder =
153
+ NotificationCompat.Builder(this, channelId)
154
+ .setContentTitle(prefs.notificationTitle)
155
+ .setContentText(prefs.notificationBody)
156
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
157
+ .setOngoing(true)
158
+ .setContentIntent(pendingIntent)
159
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
160
+ .setPriority(priority)
161
+
162
+ if (mapBitmap != null) {
163
+ builder.setLargeIcon(mapBitmap)
164
+ builder.setStyle(
165
+ NotificationCompat.BigPictureStyle()
166
+ .bigPicture(mapBitmap)
167
+ .setSummaryText(prefs.notificationBody),
168
+ )
169
+ }
170
+ return builder.build()
171
+ }
172
+
173
+ private fun requestLocationUpdates(prefs: TrackingPreferences) {
174
+ shutdownLocationUpdates()
175
+ val interval = prefs.intervalMs.coerceIn(1000L, 60_000L)
176
+ val request =
177
+ LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, interval)
178
+ .setMinUpdateIntervalMillis((interval / 2).coerceAtLeast(1000L))
179
+ .setMaxUpdateDelayMillis(interval * 2)
180
+ .setWaitForAccurateLocation(false)
181
+ .build()
182
+
183
+ val fused = LocationServices.getFusedLocationProviderClient(this)
184
+ locationCallback =
185
+ object : LocationCallback() {
186
+ override fun onLocationResult(result: LocationResult) {
187
+ val loc = result.lastLocation ?: return
188
+ executor.execute { handleLocation(loc, prefs) }
189
+ }
190
+ }
191
+ try {
192
+ fused.requestLocationUpdates(
193
+ request,
194
+ locationCallback!!,
195
+ Looper.getMainLooper(),
196
+ )
197
+ } catch (_: SecurityException) {
198
+ stopSelf()
199
+ }
200
+ }
201
+
202
+ private fun handleLocation(location: Location, prefs: TrackingPreferences) {
203
+ val ts = System.currentTimeMillis()
204
+ val json =
205
+ JSONObject()
206
+ .put("latitude", location.latitude)
207
+ .put("longitude", location.longitude)
208
+ .put("timestamp", ts)
209
+ .put("driverId", prefs.driverId)
210
+ .put("accuracy", location.accuracy.toDouble())
211
+ .put("speed", location.speed.toDouble())
212
+ .put("bearing", location.bearing.toDouble())
213
+ .toString()
214
+
215
+ TrackingEventBridge.emitLocation(
216
+ location.latitude,
217
+ location.longitude,
218
+ ts,
219
+ prefs.driverId,
220
+ location.accuracy,
221
+ location.speed,
222
+ location.bearing,
223
+ )
224
+
225
+ networkClient?.sendLocationJson(json)
226
+ maybeRefreshMapNotification(location, prefs)
227
+ }
228
+
229
+ /**
230
+ * Android cannot embed a live MapView in a notification (unlike iOS Live Activities). This uses
231
+ * Google Static Maps to show a periodically refreshed image in the expanded notification.
232
+ */
233
+ private fun maybeRefreshMapNotification(location: Location, prefs: TrackingPreferences) {
234
+ if (!prefs.notificationMapPreview || prefs.staticMapsApiKey.isBlank()) return
235
+ staticMapExecutor.execute {
236
+ val now = System.currentTimeMillis()
237
+ if (now - lastStaticMapFetchMs < STATIC_MAP_MIN_INTERVAL_MS) return@execute
238
+ val plat = if (prefs.hasPickup) prefs.pickupLatitude else null
239
+ val plng = if (prefs.hasPickup) prefs.pickupLongitude else null
240
+ val bmp =
241
+ StaticMapFetcher.fetch(
242
+ location.latitude,
243
+ location.longitude,
244
+ plat,
245
+ plng,
246
+ prefs.staticMapsApiKey,
247
+ )
248
+ ?: return@execute
249
+ lastStaticMapFetchMs = System.currentTimeMillis()
250
+ mainHandler.post {
251
+ val nm = getSystemService(NotificationManager::class.java)
252
+ nm.notify(NOTIFICATION_ID, buildNotification(prefs, bmp))
253
+ }
254
+ }
255
+ }
256
+
257
+ private fun shutdownLocationUpdates() {
258
+ locationCallback?.let { cb ->
259
+ try {
260
+ LocationServices.getFusedLocationProviderClient(this).removeLocationUpdates(cb)
261
+ } catch (_: Exception) {
262
+ }
263
+ }
264
+ locationCallback = null
265
+ }
266
+
267
+ companion object {
268
+ private const val NOTIFICATION_ID = 90421
269
+ private const val QUEUE_FLUSH_INTERVAL_MS = 30_000L
270
+ /** Limits Static Maps quota / network; expanded notification still updates on this cadence. */
271
+ private const val STATIC_MAP_MIN_INTERVAL_MS = 45_000L
272
+ const val ACTION_STOP = "com.reactnativebackgroundlivetracking.action.STOP"
273
+
274
+ fun start(context: Context) {
275
+ val i = Intent(context, LocationForegroundService::class.java)
276
+ ContextCompat.startForegroundService(context, i)
277
+ }
278
+
279
+ fun stop(context: Context) {
280
+ val i =
281
+ Intent(context, LocationForegroundService::class.java).apply {
282
+ action = ACTION_STOP
283
+ }
284
+ context.startService(i)
285
+ }
286
+ }
287
+ }