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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.NetworkCapabilities
|
|
6
|
+
import java.net.URI
|
|
7
|
+
import java.util.concurrent.TimeUnit
|
|
8
|
+
import okhttp3.Headers
|
|
9
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
10
|
+
import okhttp3.OkHttpClient
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
13
|
+
import okhttp3.Response
|
|
14
|
+
import okhttp3.WebSocket
|
|
15
|
+
import okhttp3.WebSocketListener
|
|
16
|
+
import org.json.JSONObject
|
|
17
|
+
import io.socket.client.IO
|
|
18
|
+
import io.socket.client.Socket
|
|
19
|
+
|
|
20
|
+
internal class LocationNetworkClient(
|
|
21
|
+
private val appContext: Context,
|
|
22
|
+
private val prefs: TrackingPreferences,
|
|
23
|
+
private val queue: LocationQueueStore,
|
|
24
|
+
) {
|
|
25
|
+
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
|
26
|
+
private val client =
|
|
27
|
+
OkHttpClient.Builder()
|
|
28
|
+
.connectTimeout(25, TimeUnit.SECONDS)
|
|
29
|
+
.readTimeout(25, TimeUnit.SECONDS)
|
|
30
|
+
.writeTimeout(25, TimeUnit.SECONDS)
|
|
31
|
+
.retryOnConnectionFailure(true)
|
|
32
|
+
.build()
|
|
33
|
+
|
|
34
|
+
@Volatile private var webSocket: WebSocket? = null
|
|
35
|
+
@Volatile private var socketIo: Socket? = null
|
|
36
|
+
|
|
37
|
+
fun connectSocketsIfNeeded() {
|
|
38
|
+
val url = prefs.socketUrl ?: return
|
|
39
|
+
when (prefs.socketTransport) {
|
|
40
|
+
"websocket" -> connectWebSocket(url)
|
|
41
|
+
"socket.io" -> connectSocketIo(url)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fun disconnectSockets() {
|
|
46
|
+
try {
|
|
47
|
+
webSocket?.close(1000, "stop")
|
|
48
|
+
} catch (_: Exception) {
|
|
49
|
+
}
|
|
50
|
+
webSocket = null
|
|
51
|
+
try {
|
|
52
|
+
socketIo?.disconnect()
|
|
53
|
+
socketIo?.off()
|
|
54
|
+
} catch (_: Exception) {
|
|
55
|
+
}
|
|
56
|
+
socketIo = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun sendLocationJson(jsonBody: String): Boolean {
|
|
60
|
+
val online = isOnline()
|
|
61
|
+
if (!online) {
|
|
62
|
+
queue.enqueue(jsonBody)
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
connectSocketsIfNeeded()
|
|
66
|
+
val restOk = postRestWithRetry(jsonBody)
|
|
67
|
+
sendOverRealtime(jsonBody)
|
|
68
|
+
if (!restOk) {
|
|
69
|
+
queue.enqueue(jsonBody)
|
|
70
|
+
}
|
|
71
|
+
return restOk
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fun flushQueue(): Int {
|
|
75
|
+
if (!isOnline()) return 0
|
|
76
|
+
connectSocketsIfNeeded()
|
|
77
|
+
return queue.drain { line ->
|
|
78
|
+
val ok = postRestWithRetry(line)
|
|
79
|
+
if (ok) {
|
|
80
|
+
sendOverRealtime(line)
|
|
81
|
+
}
|
|
82
|
+
ok
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun sendOverRealtime(jsonBody: String) {
|
|
87
|
+
try {
|
|
88
|
+
webSocket?.send(jsonBody)
|
|
89
|
+
socketIo?.emit("location", JSONObject(jsonBody))
|
|
90
|
+
} catch (_: Exception) {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private fun postRestWithRetry(body: String): Boolean {
|
|
95
|
+
repeat(3) { attempt ->
|
|
96
|
+
try {
|
|
97
|
+
if (postRestOnce(body)) return true
|
|
98
|
+
} catch (_: Exception) {
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
Thread.sleep((300L * (attempt + 1)).coerceAtMost(2000L))
|
|
102
|
+
} catch (_: InterruptedException) {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun postRestOnce(body: String): Boolean {
|
|
110
|
+
val url = prefs.serverUrl
|
|
111
|
+
if (url.isBlank()) return false
|
|
112
|
+
val headersBuilder = Headers.Builder()
|
|
113
|
+
try {
|
|
114
|
+
val headersJson = JSONObject(prefs.restHeadersJson)
|
|
115
|
+
val keys = headersJson.keys()
|
|
116
|
+
while (keys.hasNext()) {
|
|
117
|
+
val k = keys.next()
|
|
118
|
+
headersBuilder.add(k, headersJson.optString(k))
|
|
119
|
+
}
|
|
120
|
+
} catch (_: Exception) {
|
|
121
|
+
}
|
|
122
|
+
val request =
|
|
123
|
+
Request.Builder()
|
|
124
|
+
.url(url)
|
|
125
|
+
.headers(headersBuilder.build())
|
|
126
|
+
.post(body.toRequestBody(jsonMedia))
|
|
127
|
+
.build()
|
|
128
|
+
client.newCall(request).execute().use { response ->
|
|
129
|
+
return response.isSuccessful
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun connectWebSocket(rawUrl: String) {
|
|
134
|
+
if (webSocket != null) return
|
|
135
|
+
val wsUrl =
|
|
136
|
+
when {
|
|
137
|
+
rawUrl.startsWith("https://") -> "wss://" + rawUrl.removePrefix("https://")
|
|
138
|
+
rawUrl.startsWith("http://") -> "ws://" + rawUrl.removePrefix("http://")
|
|
139
|
+
else -> rawUrl
|
|
140
|
+
}
|
|
141
|
+
val request = Request.Builder().url(wsUrl).build()
|
|
142
|
+
webSocket =
|
|
143
|
+
client.newWebSocket(
|
|
144
|
+
request,
|
|
145
|
+
object : WebSocketListener() {
|
|
146
|
+
override fun onFailure(
|
|
147
|
+
webSocket: WebSocket,
|
|
148
|
+
t: Throwable,
|
|
149
|
+
response: Response?,
|
|
150
|
+
) {
|
|
151
|
+
this@LocationNetworkClient.webSocket = null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override fun onClosed(
|
|
155
|
+
webSocket: WebSocket,
|
|
156
|
+
code: Int,
|
|
157
|
+
reason: String,
|
|
158
|
+
) {
|
|
159
|
+
this@LocationNetworkClient.webSocket = null
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun connectSocketIo(rawUrl: String) {
|
|
166
|
+
if (socketIo?.connected() == true) return
|
|
167
|
+
try {
|
|
168
|
+
socketIo?.disconnect()
|
|
169
|
+
socketIo?.off()
|
|
170
|
+
} catch (_: Exception) {
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
val uri = URI.create(toHttpScheme(rawUrl))
|
|
174
|
+
val opts = IO.Options()
|
|
175
|
+
opts.reconnection = true
|
|
176
|
+
socketIo = IO.socket(uri, opts)
|
|
177
|
+
socketIo?.connect()
|
|
178
|
+
} catch (_: Exception) {
|
|
179
|
+
socketIo = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private fun toHttpScheme(url: String): String {
|
|
184
|
+
return when {
|
|
185
|
+
url.startsWith("wss://") -> "https://" + url.removePrefix("wss://")
|
|
186
|
+
url.startsWith("ws://") -> "http://" + url.removePrefix("ws://")
|
|
187
|
+
else -> url
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun isOnline(): Boolean {
|
|
192
|
+
val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
193
|
+
val network = cm.activeNetwork ?: return false
|
|
194
|
+
val caps = cm.getNetworkCapabilities(network) ?: return false
|
|
195
|
+
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
6
|
+
import kotlin.concurrent.withLock
|
|
7
|
+
|
|
8
|
+
internal class LocationQueueStore(context: Context) {
|
|
9
|
+
private val file = File(context.filesDir, "rn_blt_location_queue.jsonl")
|
|
10
|
+
private val lock = ReentrantLock()
|
|
11
|
+
private val maxLines = 2000
|
|
12
|
+
|
|
13
|
+
fun enqueue(line: String) {
|
|
14
|
+
lock.withLock {
|
|
15
|
+
if (!file.exists()) {
|
|
16
|
+
file.createNewFile()
|
|
17
|
+
}
|
|
18
|
+
val lines = file.readLines().toMutableList()
|
|
19
|
+
lines.add(line)
|
|
20
|
+
while (lines.size > maxLines) {
|
|
21
|
+
lines.removeAt(0)
|
|
22
|
+
}
|
|
23
|
+
file.writeText(lines.joinToString("\n") + if (lines.isNotEmpty()) "\n" else "")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attempts to process queued payloads. [processor] returns true if the entry was delivered.
|
|
29
|
+
*/
|
|
30
|
+
fun drain(processor: (String) -> Boolean): Int {
|
|
31
|
+
lock.withLock {
|
|
32
|
+
if (!file.exists() || file.length() == 0L) return 0
|
|
33
|
+
val lines = file.readLines()
|
|
34
|
+
if (lines.isEmpty()) return 0
|
|
35
|
+
val remaining = mutableListOf<String>()
|
|
36
|
+
var sent = 0
|
|
37
|
+
for (line in lines) {
|
|
38
|
+
if (line.isBlank()) continue
|
|
39
|
+
if (processor(line)) {
|
|
40
|
+
sent++
|
|
41
|
+
} else {
|
|
42
|
+
remaining.add(line)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (remaining.isEmpty()) {
|
|
46
|
+
file.delete()
|
|
47
|
+
} else {
|
|
48
|
+
file.writeText(remaining.joinToString("\n") + "\n")
|
|
49
|
+
}
|
|
50
|
+
return sent
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.BitmapFactory
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
7
|
+
import okhttp3.OkHttpClient
|
|
8
|
+
import okhttp3.Request
|
|
9
|
+
|
|
10
|
+
/** Fetches a map bitmap via Google Static Maps API (not an interactive MapView). */
|
|
11
|
+
internal object StaticMapFetcher {
|
|
12
|
+
private val client =
|
|
13
|
+
OkHttpClient.Builder()
|
|
14
|
+
.connectTimeout(18, TimeUnit.SECONDS)
|
|
15
|
+
.readTimeout(18, TimeUnit.SECONDS)
|
|
16
|
+
.build()
|
|
17
|
+
|
|
18
|
+
fun fetch(
|
|
19
|
+
driverLat: Double,
|
|
20
|
+
driverLng: Double,
|
|
21
|
+
pickupLat: Double?,
|
|
22
|
+
pickupLng: Double?,
|
|
23
|
+
apiKey: String,
|
|
24
|
+
): Bitmap? {
|
|
25
|
+
if (apiKey.isBlank()) return null
|
|
26
|
+
val urlBuilder =
|
|
27
|
+
"https://maps.googleapis.com/maps/api/staticmap".toHttpUrl().newBuilder()
|
|
28
|
+
.addQueryParameter("center", "$driverLat,$driverLng")
|
|
29
|
+
.addQueryParameter("zoom", "15")
|
|
30
|
+
.addQueryParameter("size", "480x240")
|
|
31
|
+
.addQueryParameter("scale", "2")
|
|
32
|
+
.addQueryParameter("maptype", "roadmap")
|
|
33
|
+
.addQueryParameter(
|
|
34
|
+
"markers",
|
|
35
|
+
"color:0x4285F4|size:mid|$driverLat,$driverLng",
|
|
36
|
+
)
|
|
37
|
+
.addQueryParameter("key", apiKey)
|
|
38
|
+
|
|
39
|
+
if (pickupLat != null && pickupLng != null) {
|
|
40
|
+
urlBuilder.addQueryParameter(
|
|
41
|
+
"markers",
|
|
42
|
+
"color:0x0F9D58|label:P|size:mid|$pickupLat,$pickupLng",
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
val request = Request.Builder().url(urlBuilder.build()).get().build()
|
|
47
|
+
return try {
|
|
48
|
+
client.newCall(request).execute().use { response ->
|
|
49
|
+
if (!response.isSuccessful) return null
|
|
50
|
+
response.body?.byteStream()?.use { BitmapFactory.decodeStream(it) }
|
|
51
|
+
}
|
|
52
|
+
} catch (_: Exception) {
|
|
53
|
+
null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
8
|
+
|
|
9
|
+
internal object TrackingEventBridge {
|
|
10
|
+
@Volatile
|
|
11
|
+
var reactContext: ReactApplicationContext? = null
|
|
12
|
+
|
|
13
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
14
|
+
|
|
15
|
+
fun emitLocation(
|
|
16
|
+
latitude: Double,
|
|
17
|
+
longitude: Double,
|
|
18
|
+
timestamp: Long,
|
|
19
|
+
driverId: String,
|
|
20
|
+
accuracy: Float,
|
|
21
|
+
speed: Float,
|
|
22
|
+
bearing: Float,
|
|
23
|
+
) {
|
|
24
|
+
val ctx = reactContext ?: return
|
|
25
|
+
val map =
|
|
26
|
+
Arguments.createMap().apply {
|
|
27
|
+
putDouble("latitude", latitude)
|
|
28
|
+
putDouble("longitude", longitude)
|
|
29
|
+
putDouble("timestamp", timestamp.toDouble())
|
|
30
|
+
putString("driverId", driverId)
|
|
31
|
+
putDouble("accuracy", accuracy.toDouble())
|
|
32
|
+
putDouble("speed", speed.toDouble())
|
|
33
|
+
putDouble("bearing", bearing.toDouble())
|
|
34
|
+
}
|
|
35
|
+
mainHandler.post {
|
|
36
|
+
try {
|
|
37
|
+
ctx
|
|
38
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
39
|
+
.emit("RNBackgroundLiveTracking_location", map)
|
|
40
|
+
} catch (_: Exception) {
|
|
41
|
+
// Bridge may be torn down
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun emitStatus(active: Boolean) {
|
|
47
|
+
val ctx = reactContext ?: return
|
|
48
|
+
val map = Arguments.createMap().apply { putBoolean("active", active) }
|
|
49
|
+
mainHandler.post {
|
|
50
|
+
try {
|
|
51
|
+
ctx
|
|
52
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
53
|
+
.emit("RNBackgroundLiveTracking_status", map)
|
|
54
|
+
} catch (_: Exception) {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.provider.Settings
|
|
9
|
+
import androidx.core.content.ContextCompat
|
|
10
|
+
import com.facebook.react.bridge.Promise
|
|
11
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
12
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
13
|
+
import com.facebook.react.bridge.ReactMethod
|
|
14
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
15
|
+
import org.json.JSONObject
|
|
16
|
+
|
|
17
|
+
@ReactModule(name = RNBackgroundLiveTrackingModule.NAME)
|
|
18
|
+
class RNBackgroundLiveTrackingModule(
|
|
19
|
+
private val reactContext: ReactApplicationContext,
|
|
20
|
+
) : ReactContextBaseJavaModule(reactContext) {
|
|
21
|
+
|
|
22
|
+
override fun getName(): String = NAME
|
|
23
|
+
|
|
24
|
+
override fun initialize() {
|
|
25
|
+
super.initialize()
|
|
26
|
+
TrackingEventBridge.reactContext = reactContext
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override fun invalidate() {
|
|
30
|
+
TrackingEventBridge.reactContext = null
|
|
31
|
+
super.invalidate()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@ReactMethod
|
|
35
|
+
fun startTracking(configJson: String, promise: Promise) {
|
|
36
|
+
try {
|
|
37
|
+
if (!hasFineLocation()) {
|
|
38
|
+
promise.reject(
|
|
39
|
+
"E_PERMISSION",
|
|
40
|
+
"ACCESS_FINE_LOCATION is required. Request it from JavaScript before starting.",
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !hasBackgroundLocation()) {
|
|
45
|
+
promise.reject(
|
|
46
|
+
"E_PERMISSION",
|
|
47
|
+
"ACCESS_BACKGROUND_LOCATION is required for tracking when the app is killed (Android 10+).",
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotifications()) {
|
|
52
|
+
promise.reject(
|
|
53
|
+
"E_PERMISSION",
|
|
54
|
+
"POST_NOTIFICATIONS is required to show the foreground service notification (Android 13+).",
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
val json = JSONObject(configJson)
|
|
60
|
+
val driverId = json.getString("driverId")
|
|
61
|
+
val intervalMs = json.getLong("intervalMs")
|
|
62
|
+
val serverUrl = json.getString("serverUrl")
|
|
63
|
+
val socketUrl =
|
|
64
|
+
when {
|
|
65
|
+
!json.has("socketUrl") || json.isNull("socketUrl") -> null
|
|
66
|
+
else -> json.getString("socketUrl").takeIf { it.isNotBlank() }
|
|
67
|
+
}
|
|
68
|
+
val socketTransportJs =
|
|
69
|
+
when {
|
|
70
|
+
!json.has("socketTransport") || json.isNull("socketTransport") -> null
|
|
71
|
+
else -> json.getString("socketTransport").takeIf { it.isNotBlank() }
|
|
72
|
+
}
|
|
73
|
+
val notificationTitle = json.getString("notificationTitle")
|
|
74
|
+
val notificationBody = json.getString("notificationBody")
|
|
75
|
+
val autoStartOnBoot = json.optBoolean("autoStartOnBoot", true)
|
|
76
|
+
val restHeaders =
|
|
77
|
+
if (json.has("restHeaders")) json.getJSONObject("restHeaders").toString() else "{}"
|
|
78
|
+
|
|
79
|
+
val notificationMapPreview = json.optBoolean("notificationMapPreview", false)
|
|
80
|
+
val staticMapsApiKey =
|
|
81
|
+
if (json.has("googleStaticMapsApiKey")) {
|
|
82
|
+
json.optString("googleStaticMapsApiKey", "")
|
|
83
|
+
} else {
|
|
84
|
+
""
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
val prefs = TrackingPreferences(reactContext)
|
|
88
|
+
prefs.driverId = driverId
|
|
89
|
+
prefs.intervalMs = intervalMs.coerceAtLeast(1000L)
|
|
90
|
+
prefs.serverUrl = serverUrl
|
|
91
|
+
prefs.socketUrl = socketUrl
|
|
92
|
+
prefs.socketTransport = resolveTransport(socketUrl, socketTransportJs)
|
|
93
|
+
prefs.notificationTitle = notificationTitle
|
|
94
|
+
prefs.notificationBody = notificationBody
|
|
95
|
+
prefs.autoStartOnBoot = autoStartOnBoot
|
|
96
|
+
prefs.restHeadersJson = restHeaders
|
|
97
|
+
prefs.notificationMapPreview = notificationMapPreview
|
|
98
|
+
prefs.staticMapsApiKey = staticMapsApiKey
|
|
99
|
+
if (json.has("pickup") && !json.isNull("pickup")) {
|
|
100
|
+
val pu = json.getJSONObject("pickup")
|
|
101
|
+
prefs.pickupLatitude = pu.getDouble("latitude")
|
|
102
|
+
prefs.pickupLongitude = pu.getDouble("longitude")
|
|
103
|
+
prefs.hasPickup = true
|
|
104
|
+
} else {
|
|
105
|
+
prefs.hasPickup = false
|
|
106
|
+
}
|
|
107
|
+
prefs.isTrackingActive = true
|
|
108
|
+
|
|
109
|
+
LocationForegroundService.start(reactContext.applicationContext)
|
|
110
|
+
TrackingEventBridge.emitStatus(true)
|
|
111
|
+
promise.resolve(null)
|
|
112
|
+
} catch (e: Exception) {
|
|
113
|
+
promise.reject("E_START", e.message, e)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@ReactMethod
|
|
118
|
+
fun stopTracking(promise: Promise) {
|
|
119
|
+
try {
|
|
120
|
+
val prefs = TrackingPreferences(reactContext)
|
|
121
|
+
prefs.clearTrackingFlag()
|
|
122
|
+
LocationForegroundService.stop(reactContext.applicationContext)
|
|
123
|
+
TrackingEventBridge.emitStatus(false)
|
|
124
|
+
promise.resolve(null)
|
|
125
|
+
} catch (e: Exception) {
|
|
126
|
+
promise.reject("E_STOP", e.message, e)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@ReactMethod
|
|
131
|
+
fun isTracking(promise: Promise) {
|
|
132
|
+
promise.resolve(TrackingPreferences(reactContext).isTrackingActive)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@ReactMethod
|
|
136
|
+
fun openBatteryOptimizationSettings(promise: Promise) {
|
|
137
|
+
try {
|
|
138
|
+
val intent =
|
|
139
|
+
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
|
140
|
+
data = Uri.parse("package:${reactContext.packageName}")
|
|
141
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
142
|
+
}
|
|
143
|
+
reactContext.startActivity(intent)
|
|
144
|
+
promise.resolve(null)
|
|
145
|
+
} catch (e: Exception) {
|
|
146
|
+
try {
|
|
147
|
+
val fallback =
|
|
148
|
+
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
149
|
+
data = Uri.fromParts("package", reactContext.packageName, null)
|
|
150
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
151
|
+
}
|
|
152
|
+
reactContext.startActivity(fallback)
|
|
153
|
+
promise.resolve(null)
|
|
154
|
+
} catch (e2: Exception) {
|
|
155
|
+
promise.reject("E_BATTERY", e2.message, e2)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun resolveTransport(socketUrl: String?, jsTransport: String?): String {
|
|
161
|
+
if (socketUrl.isNullOrBlank()) return "rest"
|
|
162
|
+
if (!jsTransport.isNullOrBlank()) {
|
|
163
|
+
return when (jsTransport.lowercase()) {
|
|
164
|
+
"socket.io" -> "socket.io"
|
|
165
|
+
"websocket" -> "websocket"
|
|
166
|
+
else -> "websocket"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return if (
|
|
170
|
+
socketUrl.contains("/socket.io", ignoreCase = true) ||
|
|
171
|
+
socketUrl.contains("socket.io", ignoreCase = true)
|
|
172
|
+
) {
|
|
173
|
+
"socket.io"
|
|
174
|
+
} else {
|
|
175
|
+
"websocket"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun hasFineLocation(): Boolean {
|
|
180
|
+
return ContextCompat.checkSelfPermission(
|
|
181
|
+
reactContext,
|
|
182
|
+
Manifest.permission.ACCESS_FINE_LOCATION,
|
|
183
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private fun hasBackgroundLocation(): Boolean {
|
|
187
|
+
return ContextCompat.checkSelfPermission(
|
|
188
|
+
reactContext,
|
|
189
|
+
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
|
|
190
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun hasPostNotifications(): Boolean {
|
|
194
|
+
return ContextCompat.checkSelfPermission(
|
|
195
|
+
reactContext,
|
|
196
|
+
Manifest.permission.POST_NOTIFICATIONS,
|
|
197
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
companion object {
|
|
201
|
+
const val NAME = "RNBackgroundLiveTracking"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
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 TrackingPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
10
|
+
return listOf(RNBackgroundLiveTrackingModule(reactContext))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override fun createViewManagers(
|
|
14
|
+
reactContext: ReactApplicationContext,
|
|
15
|
+
): List<ViewManager<*, *>> = emptyList()
|
|
16
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package com.reactnativebackgroundlivetracking
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
|
|
5
|
+
internal class TrackingPreferences(context: Context) {
|
|
6
|
+
private val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
7
|
+
|
|
8
|
+
var isTrackingActive: Boolean
|
|
9
|
+
get() = p.getBoolean(KEY_ACTIVE, false)
|
|
10
|
+
set(value) = p.edit().putBoolean(KEY_ACTIVE, value).apply()
|
|
11
|
+
|
|
12
|
+
var autoStartOnBoot: Boolean
|
|
13
|
+
get() = p.getBoolean(KEY_AUTO_BOOT, true)
|
|
14
|
+
set(value) = p.edit().putBoolean(KEY_AUTO_BOOT, value).apply()
|
|
15
|
+
|
|
16
|
+
var driverId: String
|
|
17
|
+
get() = p.getString(KEY_DRIVER_ID, "") ?: ""
|
|
18
|
+
set(value) = p.edit().putString(KEY_DRIVER_ID, value).apply()
|
|
19
|
+
|
|
20
|
+
var intervalMs: Long
|
|
21
|
+
get() = p.getLong(KEY_INTERVAL_MS, 5000L)
|
|
22
|
+
set(value) = p.edit().putLong(KEY_INTERVAL_MS, value).apply()
|
|
23
|
+
|
|
24
|
+
var serverUrl: String
|
|
25
|
+
get() = p.getString(KEY_SERVER_URL, "") ?: ""
|
|
26
|
+
set(value) = p.edit().putString(KEY_SERVER_URL, value).apply()
|
|
27
|
+
|
|
28
|
+
var socketUrl: String?
|
|
29
|
+
get() = p.getString(KEY_SOCKET_URL, null)
|
|
30
|
+
set(value) = p.edit().putString(KEY_SOCKET_URL, value).apply()
|
|
31
|
+
|
|
32
|
+
/** rest | websocket | socket.io */
|
|
33
|
+
var socketTransport: String
|
|
34
|
+
get() = p.getString(KEY_SOCKET_TRANSPORT, "rest") ?: "rest"
|
|
35
|
+
set(value) = p.edit().putString(KEY_SOCKET_TRANSPORT, value).apply()
|
|
36
|
+
|
|
37
|
+
var notificationTitle: String
|
|
38
|
+
get() = p.getString(KEY_NOTIF_TITLE, "Driver tracking") ?: "Driver tracking"
|
|
39
|
+
set(value) = p.edit().putString(KEY_NOTIF_TITLE, value).apply()
|
|
40
|
+
|
|
41
|
+
var notificationBody: String
|
|
42
|
+
get() = p.getString(KEY_NOTIF_BODY, "") ?: ""
|
|
43
|
+
set(value) = p.edit().putString(KEY_NOTIF_BODY, value).apply()
|
|
44
|
+
|
|
45
|
+
var restHeadersJson: String
|
|
46
|
+
get() = p.getString(KEY_REST_HEADERS, "{}") ?: "{}"
|
|
47
|
+
set(value) = p.edit().putString(KEY_REST_HEADERS, value).apply()
|
|
48
|
+
|
|
49
|
+
/** When true, notification expanded view shows a Static Maps image (requires API key). */
|
|
50
|
+
var notificationMapPreview: Boolean
|
|
51
|
+
get() = p.getBoolean(KEY_MAP_PREVIEW, false)
|
|
52
|
+
set(value) = p.edit().putBoolean(KEY_MAP_PREVIEW, value).apply()
|
|
53
|
+
|
|
54
|
+
var staticMapsApiKey: String
|
|
55
|
+
get() = p.getString(KEY_STATIC_MAP_KEY, "") ?: ""
|
|
56
|
+
set(value) = p.edit().putString(KEY_STATIC_MAP_KEY, value).apply()
|
|
57
|
+
|
|
58
|
+
var hasPickup: Boolean
|
|
59
|
+
get() = p.getBoolean(KEY_HAS_PICKUP, false)
|
|
60
|
+
set(value) = p.edit().putBoolean(KEY_HAS_PICKUP, value).apply()
|
|
61
|
+
|
|
62
|
+
var pickupLatitude: Double
|
|
63
|
+
get() =
|
|
64
|
+
java.lang.Double.longBitsToDouble(
|
|
65
|
+
p.getLong(KEY_PICKUP_LAT_BITS, 0L),
|
|
66
|
+
)
|
|
67
|
+
set(value) =
|
|
68
|
+
p.edit().putLong(KEY_PICKUP_LAT_BITS, java.lang.Double.doubleToRawLongBits(value)).apply()
|
|
69
|
+
|
|
70
|
+
var pickupLongitude: Double
|
|
71
|
+
get() =
|
|
72
|
+
java.lang.Double.longBitsToDouble(
|
|
73
|
+
p.getLong(KEY_PICKUP_LNG_BITS, 0L),
|
|
74
|
+
)
|
|
75
|
+
set(value) =
|
|
76
|
+
p.edit().putLong(KEY_PICKUP_LNG_BITS, java.lang.Double.doubleToRawLongBits(value)).apply()
|
|
77
|
+
|
|
78
|
+
fun clearTrackingFlag() {
|
|
79
|
+
p.edit().putBoolean(KEY_ACTIVE, false).apply()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
companion object {
|
|
83
|
+
private const val PREFS = "rn_blt_prefs"
|
|
84
|
+
private const val KEY_ACTIVE = "tracking_active"
|
|
85
|
+
private const val KEY_AUTO_BOOT = "auto_boot"
|
|
86
|
+
private const val KEY_DRIVER_ID = "driver_id"
|
|
87
|
+
private const val KEY_INTERVAL_MS = "interval_ms"
|
|
88
|
+
private const val KEY_SERVER_URL = "server_url"
|
|
89
|
+
private const val KEY_SOCKET_URL = "socket_url"
|
|
90
|
+
private const val KEY_SOCKET_TRANSPORT = "socket_transport"
|
|
91
|
+
private const val KEY_NOTIF_TITLE = "notif_title"
|
|
92
|
+
private const val KEY_NOTIF_BODY = "notif_body"
|
|
93
|
+
private const val KEY_REST_HEADERS = "rest_headers_json"
|
|
94
|
+
private const val KEY_MAP_PREVIEW = "map_preview"
|
|
95
|
+
private const val KEY_STATIC_MAP_KEY = "static_maps_api_key"
|
|
96
|
+
private const val KEY_HAS_PICKUP = "has_pickup"
|
|
97
|
+
private const val KEY_PICKUP_LAT_BITS = "pickup_lat_bits"
|
|
98
|
+
private const val KEY_PICKUP_LNG_BITS = "pickup_lng_bits"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<resources>
|
|
3
|
+
<string name="rn_blt_channel_id">rn_background_live_tracking</string>
|
|
4
|
+
<string name="rn_blt_channel_name">Live location tracking</string>
|
|
5
|
+
<string name="rn_blt_channel_id_map">rn_background_live_tracking_map</string>
|
|
6
|
+
<string name="rn_blt_channel_name_map">Live location (map preview)</string>
|
|
7
|
+
</resources>
|