@atomiqlab/react-native-mapbox-navigation 1.1.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,421 @@
1
+ package expo.modules.mapboxnavigation
2
+
3
+ import android.Manifest
4
+ import android.content.Intent
5
+ import android.content.pm.PackageManager
6
+ import android.os.Bundle
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.util.Log
10
+ import android.view.Gravity
11
+ import android.widget.FrameLayout
12
+ import android.widget.TextView
13
+ import androidx.appcompat.app.AppCompatDelegate
14
+ import androidx.appcompat.app.AppCompatActivity
15
+ import androidx.activity.result.contract.ActivityResultContracts
16
+ import androidx.core.content.ContextCompat
17
+ import com.mapbox.api.directions.v5.models.BannerInstructions
18
+ import com.mapbox.api.directions.v5.models.RouteOptions
19
+ import com.mapbox.geojson.Point
20
+ import com.mapbox.navigation.base.trip.model.RouteProgress
21
+ import com.mapbox.navigation.core.MapboxNavigation
22
+ import com.mapbox.navigation.core.MapboxNavigationProvider
23
+ import com.mapbox.navigation.core.trip.session.BannerInstructionsObserver
24
+ import com.mapbox.navigation.core.trip.session.LocationMatcherResult
25
+ import com.mapbox.navigation.core.trip.session.LocationObserver
26
+ import com.mapbox.navigation.core.trip.session.RouteProgressObserver
27
+ import com.mapbox.navigation.dropin.NavigationView
28
+ import com.mapbox.navigation.dropin.RouteOptionsInterceptor
29
+ import com.mapbox.navigation.dropin.navigationview.NavigationViewListener
30
+ import com.mapbox.navigation.base.route.NavigationRoute
31
+ import com.mapbox.navigation.base.route.RouterFailure
32
+ import com.mapbox.navigation.base.route.RouterOrigin
33
+
34
+ class MapboxNavigationActivity : AppCompatActivity() {
35
+ companion object {
36
+ private const val TAG = "MapboxNavigationActivity"
37
+ }
38
+
39
+ private var navigationView: NavigationView? = null
40
+ private lateinit var accessToken: String
41
+ private var startPoint: Point? = null
42
+ private var destinationPoint: Point? = null
43
+ private var waypointPoints: List<Point> = emptyList()
44
+ private var shouldSimulateRoute: Boolean = false
45
+ private var routeAlternatives: Boolean = false
46
+ private var showsSpeedLimits: Boolean = true
47
+ private var showsWayNameLabel: Boolean = true
48
+ private var showsTripProgress: Boolean = true
49
+ private var showsManeuverView: Boolean = true
50
+ private var showsActionButtons: Boolean = true
51
+ private var mapStyleUriDay: String? = null
52
+ private var mapStyleUriNight: String? = null
53
+ private var uiTheme: String = "system"
54
+ private var hasStartedGuidance: Boolean = false
55
+ private var mapboxNavigation: MapboxNavigation? = null
56
+ private val mainHandler = Handler(Looper.getMainLooper())
57
+ private val locationObserver = object : LocationObserver {
58
+ override fun onNewRawLocation(rawLocation: android.location.Location) = Unit
59
+
60
+ override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
61
+ val location = locationMatcherResult.enhancedLocation
62
+ MapboxNavigationEventBridge.emit(
63
+ "onLocationChange",
64
+ mapOf(
65
+ "latitude" to location.latitude,
66
+ "longitude" to location.longitude,
67
+ "bearing" to location.bearing.toDouble(),
68
+ "speed" to location.speed.toDouble(),
69
+ "altitude" to location.altitude,
70
+ "accuracy" to location.accuracy.toDouble()
71
+ )
72
+ )
73
+ }
74
+ }
75
+ private val routeProgressObserver = RouteProgressObserver { routeProgress: RouteProgress ->
76
+ MapboxNavigationEventBridge.emit(
77
+ "onRouteProgressChange",
78
+ mapOf(
79
+ "distanceTraveled" to routeProgress.distanceTraveled.toDouble(),
80
+ "distanceRemaining" to routeProgress.distanceRemaining.toDouble(),
81
+ "durationRemaining" to routeProgress.durationRemaining,
82
+ "fractionTraveled" to routeProgress.fractionTraveled.toDouble()
83
+ )
84
+ )
85
+
86
+ emitBannerInstruction(routeProgress.bannerInstructions)
87
+ }
88
+ private val bannerInstructionsObserver = BannerInstructionsObserver { bannerInstructions ->
89
+ emitBannerInstruction(bannerInstructions)
90
+ }
91
+ private val locationPermissionLauncher = registerForActivityResult(
92
+ ActivityResultContracts.RequestMultiplePermissions()
93
+ ) { result ->
94
+ val granted = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
95
+ result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
96
+
97
+ if (!granted) {
98
+ showErrorAndStay("Location permission is required to start navigation.", null)
99
+ return@registerForActivityResult
100
+ }
101
+
102
+ createNavigationViewIfNeeded()
103
+ }
104
+ private val navigationViewListener = object : NavigationViewListener() {
105
+ override fun onDestinationChanged(destination: Point?) {
106
+ Log.d(TAG, "Destination changed: $destination")
107
+ }
108
+
109
+ override fun onDestinationPreview() {
110
+ Log.d(TAG, "NavigationView entered destination preview")
111
+ }
112
+
113
+ override fun onRouteFetchFailed(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
114
+ Log.e(TAG, "Route fetch failed. reasons=${reasons.size}, options=$routeOptions")
115
+ }
116
+
117
+ override fun onRouteFetchSuccessful(routes: List<NavigationRoute>) {
118
+ Log.d(TAG, "Route fetch succeeded. routeCount=${routes.size}")
119
+
120
+ if (!shouldSimulateRoute || routes.isEmpty() || hasStartedGuidance) {
121
+ return
122
+ }
123
+
124
+ hasStartedGuidance = true
125
+ navigationView?.api?.startActiveGuidance(routes)
126
+ }
127
+
128
+ override fun onRouteFetchCanceled(routeOptions: RouteOptions, routerOrigin: RouterOrigin) {
129
+ Log.w(TAG, "Route fetch canceled. origin=$routerOrigin, options=$routeOptions")
130
+ }
131
+ }
132
+
133
+ override fun onCreate(savedInstanceState: Bundle?) {
134
+ super.onCreate(savedInstanceState)
135
+
136
+ try {
137
+ accessToken = resolveAccessToken()
138
+ val originLat = intent.getDoubleExtraOrNull("originLat")
139
+ val originLng = intent.getDoubleExtraOrNull("originLng")
140
+ val destinationLat = intent.getDoubleExtraOrNull("destLat")
141
+ val destinationLng = intent.getDoubleExtraOrNull("destLng")
142
+ shouldSimulateRoute = intent.getBooleanExtra("shouldSimulate", false)
143
+ routeAlternatives = intent.getBooleanExtra("routeAlternatives", false)
144
+ showsSpeedLimits = intent.getBooleanExtra("showsSpeedLimits", true)
145
+ showsWayNameLabel = intent.getBooleanExtra("showsWayNameLabel", true)
146
+ showsTripProgress = intent.getBooleanExtra("showsTripProgress", true)
147
+ showsManeuverView = intent.getBooleanExtra("showsManeuverView", true)
148
+ showsActionButtons = intent.getBooleanExtra("showsActionButtons", true)
149
+ mapStyleUriDay = intent.getStringExtra("mapStyleUriDay")?.trim()?.takeIf { it.isNotEmpty() }
150
+ mapStyleUriNight = intent.getStringExtra("mapStyleUriNight")?.trim()?.takeIf { it.isNotEmpty() }
151
+ uiTheme = intent.getStringExtra("uiTheme")?.trim()?.lowercase() ?: "system"
152
+
153
+ val waypointLats = intent.getDoubleArrayExtra("waypointLats")
154
+ val waypointLngs = intent.getDoubleArrayExtra("waypointLngs")
155
+ waypointPoints = parseWaypoints(waypointLats, waypointLngs)
156
+
157
+ if (originLat != null && originLng != null) {
158
+ validateLatLng(originLat, originLng, label = "origin")
159
+ startPoint = Point.fromLngLat(originLng, originLat)
160
+ }
161
+
162
+ if (destinationLat != null && destinationLng != null) {
163
+ validateLatLng(destinationLat, destinationLng, label = "destination")
164
+ destinationPoint = Point.fromLngLat(destinationLng, destinationLat)
165
+ } else {
166
+ Log.w(TAG, "No valid destination extras provided, starting without preview")
167
+ }
168
+ } catch (throwable: Throwable) {
169
+ showErrorAndStay("Navigation init failed: ${throwable.message}", throwable)
170
+ return
171
+ }
172
+
173
+ if (hasLocationPermission()) {
174
+ createNavigationViewIfNeeded()
175
+ } else {
176
+ locationPermissionLauncher.launch(
177
+ arrayOf(
178
+ Manifest.permission.ACCESS_FINE_LOCATION,
179
+ Manifest.permission.ACCESS_COARSE_LOCATION
180
+ )
181
+ )
182
+ }
183
+ }
184
+
185
+ private fun getMapboxAccessToken(): String {
186
+ val resourceId = resources.getIdentifier(
187
+ "mapbox_access_token",
188
+ "string",
189
+ packageName
190
+ )
191
+
192
+ if (resourceId == 0) {
193
+ throw IllegalStateException("Missing string resource: mapbox_access_token")
194
+ }
195
+
196
+ val token = getString(resourceId).trim()
197
+ if (token.isEmpty()) {
198
+ throw IllegalStateException("mapbox_access_token is empty")
199
+ }
200
+
201
+ return token
202
+ }
203
+
204
+ private fun createNavigationViewIfNeeded() {
205
+ if (navigationView != null) {
206
+ return
207
+ }
208
+
209
+ try {
210
+ delegate.localNightMode = when (uiTheme) {
211
+ "light", "day" -> AppCompatDelegate.MODE_NIGHT_NO
212
+ "dark", "night" -> AppCompatDelegate.MODE_NIGHT_YES
213
+ else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
214
+ }
215
+
216
+ navigationView = NavigationView(this, null, accessToken).also { view ->
217
+ view.customizeViewOptions {
218
+ resolveDayStyleUri()?.let { mapStyleUriDay = it }
219
+ resolveNightStyleUri()?.let { mapStyleUriNight = it }
220
+ showSpeedLimit = showsSpeedLimits
221
+ showRoadName = showsWayNameLabel
222
+ showTripProgress = showsTripProgress
223
+ showManeuver = showsManeuverView
224
+ showActionButtons = showsActionButtons
225
+ }
226
+
227
+ if (startPoint != null && destinationPoint != null) {
228
+ view.setRouteOptionsInterceptor(
229
+ RouteOptionsInterceptor { builder ->
230
+ val coordinates = mutableListOf<Point>()
231
+ coordinates.add(startPoint!!)
232
+ coordinates.addAll(waypointPoints)
233
+ coordinates.add(destinationPoint!!)
234
+ builder.coordinatesList(coordinates)
235
+ builder.alternatives(routeAlternatives)
236
+ }
237
+ )
238
+ }
239
+ view.addListener(navigationViewListener)
240
+ }
241
+ setContentView(navigationView ?: FrameLayout(this))
242
+ attachNavigationObserversWithRetry()
243
+
244
+ navigationView?.api?.routeReplayEnabled(shouldSimulateRoute)
245
+
246
+ // Start destination flow only after the window is attached and active.
247
+ destinationPoint?.let { point ->
248
+ mainHandler.postDelayed({
249
+ if (isFinishing || isDestroyed) return@postDelayed
250
+ if (!hasWindowFocus()) {
251
+ Log.w(TAG, "Skipping destination preview because activity has no window focus")
252
+ return@postDelayed
253
+ }
254
+ navigationView?.api?.startDestinationPreview(point)
255
+ }, 350L)
256
+ }
257
+
258
+ } catch (throwable: Throwable) {
259
+ showErrorAndStay("Failed to create NavigationView: ${throwable.message}", throwable)
260
+ }
261
+ }
262
+
263
+ override fun onDestroy() {
264
+ mainHandler.removeCallbacksAndMessages(null)
265
+ navigationView?.removeListener(navigationViewListener)
266
+ detachNavigationObservers()
267
+ super.onDestroy()
268
+ navigationView = null
269
+ }
270
+
271
+ private fun resolveAccessToken(): String {
272
+ val fromIntent = intent.getStringExtra("accessToken")?.trim().orEmpty()
273
+ if (fromIntent.startsWith("pk.") && fromIntent.length > 20) {
274
+ return fromIntent
275
+ }
276
+
277
+ val fromResources = getMapboxAccessToken()
278
+ if (!fromResources.startsWith("pk.") || fromResources.length <= 20) {
279
+ throw IllegalStateException("Invalid Mapbox public token in mapbox_access_token")
280
+ }
281
+ return fromResources
282
+ }
283
+
284
+ private fun Intent.getDoubleExtraOrNull(key: String): Double? {
285
+ if (!hasExtra(key)) {
286
+ return null
287
+ }
288
+ val value = getDoubleExtra(key, Double.NaN)
289
+ if (!value.isFinite()) {
290
+ return null
291
+ }
292
+ return value
293
+ }
294
+
295
+ private fun validateLatLng(latitude: Double, longitude: Double, label: String) {
296
+ if (latitude < -90.0 || latitude > 90.0) {
297
+ throw IllegalStateException("Invalid $label latitude: $latitude")
298
+ }
299
+ if (longitude < -180.0 || longitude > 180.0) {
300
+ throw IllegalStateException("Invalid $label longitude: $longitude")
301
+ }
302
+ }
303
+
304
+ private fun hasLocationPermission(): Boolean {
305
+ val fineGranted = ContextCompat.checkSelfPermission(
306
+ this,
307
+ Manifest.permission.ACCESS_FINE_LOCATION
308
+ ) == PackageManager.PERMISSION_GRANTED
309
+
310
+ val coarseGranted = ContextCompat.checkSelfPermission(
311
+ this,
312
+ Manifest.permission.ACCESS_COARSE_LOCATION
313
+ ) == PackageManager.PERMISSION_GRANTED
314
+
315
+ return fineGranted || coarseGranted
316
+ }
317
+
318
+ private fun showErrorAndStay(message: String, throwable: Throwable?) {
319
+ if (throwable != null) {
320
+ Log.e(TAG, message, throwable)
321
+ } else {
322
+ Log.e(TAG, message)
323
+ }
324
+
325
+ val errorView = TextView(this).apply {
326
+ text = message
327
+ gravity = Gravity.CENTER
328
+ textSize = 16f
329
+ setPadding(32, 32, 32, 32)
330
+ }
331
+ setContentView(
332
+ FrameLayout(this).apply {
333
+ addView(
334
+ errorView,
335
+ FrameLayout.LayoutParams(
336
+ FrameLayout.LayoutParams.MATCH_PARENT,
337
+ FrameLayout.LayoutParams.MATCH_PARENT
338
+ )
339
+ )
340
+ }
341
+ )
342
+ }
343
+
344
+ private fun attachNavigationObserversWithRetry(attempt: Int = 0) {
345
+ if (mapboxNavigation != null) {
346
+ return
347
+ }
348
+ if (!MapboxNavigationProvider.isCreated()) {
349
+ if (attempt < 10) {
350
+ mainHandler.postDelayed({ attachNavigationObserversWithRetry(attempt + 1) }, 150L)
351
+ }
352
+ return
353
+ }
354
+
355
+ val navigation = runCatching { MapboxNavigationProvider.retrieve() }
356
+ .getOrElse { throwable ->
357
+ Log.e(TAG, "Unable to retrieve MapboxNavigation", throwable)
358
+ return
359
+ }
360
+
361
+ mapboxNavigation = navigation
362
+ navigation.registerLocationObserver(locationObserver)
363
+ navigation.registerRouteProgressObserver(routeProgressObserver)
364
+ navigation.registerBannerInstructionsObserver(bannerInstructionsObserver)
365
+ }
366
+
367
+ private fun detachNavigationObservers() {
368
+ mapboxNavigation?.let { navigation ->
369
+ runCatching { navigation.unregisterLocationObserver(locationObserver) }
370
+ runCatching { navigation.unregisterRouteProgressObserver(routeProgressObserver) }
371
+ runCatching { navigation.unregisterBannerInstructionsObserver(bannerInstructionsObserver) }
372
+ }
373
+ mapboxNavigation = null
374
+ }
375
+
376
+ private fun emitBannerInstruction(instruction: BannerInstructions?) {
377
+ val primary = instruction?.primary()?.text()?.trim().orEmpty()
378
+ if (primary.isEmpty()) {
379
+ return
380
+ }
381
+
382
+ val payload = mutableMapOf<String, Any?>("primaryText" to primary)
383
+ val secondary = instruction?.secondary()?.text()?.trim().orEmpty()
384
+ if (secondary.isNotEmpty()) {
385
+ payload["secondaryText"] = secondary
386
+ }
387
+ payload["stepDistanceRemaining"] = instruction?.distanceAlongGeometry() ?: 0.0
388
+
389
+ MapboxNavigationEventBridge.emit("onBannerInstruction", payload)
390
+ }
391
+
392
+ private fun parseWaypoints(
393
+ waypointLats: DoubleArray?,
394
+ waypointLngs: DoubleArray?
395
+ ): List<Point> {
396
+ if (waypointLats == null || waypointLngs == null || waypointLats.size != waypointLngs.size) {
397
+ return emptyList()
398
+ }
399
+
400
+ return waypointLats.indices.mapNotNull { index ->
401
+ val latitude = waypointLats[index]
402
+ val longitude = waypointLngs[index]
403
+ if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
404
+ return@mapNotNull null
405
+ }
406
+ Point.fromLngLat(longitude, latitude)
407
+ }
408
+ }
409
+
410
+ private fun resolveDayStyleUri(): String? {
411
+ mapStyleUriDay?.let { return it }
412
+ val legacy = intent.getStringExtra("mapStyleUri")?.trim().orEmpty()
413
+ return legacy.ifEmpty { null }
414
+ }
415
+
416
+ private fun resolveNightStyleUri(): String? {
417
+ mapStyleUriNight?.let { return it }
418
+ val dayFallback = resolveDayStyleUri()
419
+ return dayFallback
420
+ }
421
+ }
@@ -0,0 +1,18 @@
1
+ package expo.modules.mapboxnavigation
2
+
3
+ object MapboxNavigationEventBridge {
4
+ @Volatile
5
+ private var emitter: ((String, Map<String, Any?>) -> Unit)? = null
6
+
7
+ fun setEmitter(nextEmitter: (String, Map<String, Any?>) -> Unit) {
8
+ emitter = nextEmitter
9
+ }
10
+
11
+ fun clearEmitter() {
12
+ emitter = null
13
+ }
14
+
15
+ fun emit(eventName: String, payload: Map<String, Any?> = emptyMap()) {
16
+ emitter?.invoke(eventName, payload)
17
+ }
18
+ }