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 +153 -0
- package/android/build.gradle +36 -0
- package/android/consumer-rules.pro +3 -0
- package/android/src/main/AndroidManifest.xml +30 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/BootCompletedReceiver.kt +21 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/LocationForegroundService.kt +287 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/LocationNetworkClient.kt +197 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/LocationQueueStore.kt +53 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/StaticMapFetcher.kt +56 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/TrackingEventBridge.kt +58 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/TrackingModule.kt +203 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/TrackingPackage.kt +16 -0
- package/android/src/main/java/com/reactnativebackgroundlivetracking/TrackingPreferences.kt +100 -0
- package/android/src/main/res/values/strings.xml +7 -0
- package/package.json +30 -0
- package/react-native.config.js +12 -0
- package/src/NativeTracking.ts +26 -0
- package/src/index.ts +103 -0
- package/src/types.ts +41 -0
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,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
|
+
}
|
package/android/src/main/java/com/reactnativebackgroundlivetracking/LocationForegroundService.kt
ADDED
|
@@ -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
|
+
}
|