@atomiqlab/react-native-mapbox-navigation 1.1.0 → 1.1.2

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 CHANGED
@@ -1,25 +1,33 @@
1
1
  # @atomiqlab/react-native-mapbox-navigation
2
2
 
3
- Native Mapbox turn-by-turn navigation bridge for Expo / React Native (iOS + Android).
3
+ Native Mapbox turn-by-turn navigation for Expo apps on iOS and Android.
4
4
 
5
- ## Highlights
5
+ ## Features
6
6
 
7
- - Full-screen native navigation (`startNavigation`).
8
- - Embedded native navigation (`MapboxNavigationView`).
9
- - Event stream: location, route progress, banner instruction, arrival, cancel, error.
10
- - Flexible camera/theme/style controls.
11
- - Expo config plugin for Android + iOS setup defaults.
7
+ - Full-screen native navigation via `startNavigation`.
8
+ - Embedded native navigation UI via `MapboxNavigationView`.
9
+ - Real-time events: location, route progress, banner instruction, arrival, cancel, and error.
10
+ - Runtime controls for mute, voice volume, distance unit, and language.
11
+ - Navigation customization: camera mode/pitch/zoom, theme, map style, and UI visibility toggles.
12
+ - Expo config plugin that applies required Android and iOS native setup.
13
+
14
+ ## Requirements
15
+
16
+ - Expo SDK `>=50`
17
+ - iOS `14+`
18
+ - Mapbox access credentials:
19
+ - Public token (`pk...`)
20
+ - Downloads token (`sk...`) with `DOWNLOADS:READ`
12
21
 
13
22
  ## Installation
14
23
 
15
24
  ```bash
16
25
  npm install @atomiqlab/react-native-mapbox-navigation
17
- npx expo install expo-build-properties
18
26
  ```
19
27
 
20
- ## Expo Config
28
+ ## Expo Setup
21
29
 
22
- Add plugin in app config:
30
+ Add the plugin in your app config (`app.json` or `app.config.js`):
23
31
 
24
32
  ```json
25
33
  {
@@ -31,18 +39,28 @@ Add plugin in app config:
31
39
  }
32
40
  ```
33
41
 
34
- ### Required tokens
42
+ Set these environment variables:
35
43
 
36
- - `EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN`: Mapbox public token (`pk...`)
37
- - `MAPBOX_DOWNLOADS_TOKEN`: Mapbox downloads token (`sk...`) with `DOWNLOADS:READ`
44
+ - `EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN` (Mapbox public token)
45
+ - `MAPBOX_DOWNLOADS_TOKEN` (Mapbox downloads token)
38
46
 
39
- Then regenerate native projects:
47
+ Regenerate native projects:
40
48
 
41
49
  ```bash
42
50
  npx expo prebuild --clean
43
51
  ```
44
52
 
45
- ## Quick Usage
53
+ ### Token validation behavior
54
+
55
+ The config plugin now fails fast during prebuild/build when tokens are missing or malformed:
56
+
57
+ - Missing/invalid public token (`pk...`)
58
+ - Missing/invalid downloads token (`sk...`)
59
+
60
+ This prevents silent runtime failures and surfaces setup issues early.
61
+
62
+
63
+ ## Quick Start
46
64
 
47
65
  ```ts
48
66
  import {
@@ -60,11 +78,14 @@ await startNavigation({
60
78
  destination: { latitude: 37.7847, longitude: -122.4073, name: "Downtown" },
61
79
  startOrigin: { latitude: 37.7749, longitude: -122.4194 },
62
80
  shouldSimulateRoute: true,
81
+ routeAlternatives: true,
63
82
  cameraMode: "following",
64
83
  uiTheme: "system",
84
+ distanceUnit: "metric",
85
+ language: "en",
65
86
  });
66
87
 
67
- const subs = [
88
+ const subscriptions = [
68
89
  addLocationChangeListener((location) => console.log(location)),
69
90
  addRouteProgressChangeListener((progress) => console.log(progress)),
70
91
  addBannerInstructionListener((instruction) => console.log(instruction.primaryText)),
@@ -74,11 +95,11 @@ const subs = [
74
95
  ];
75
96
 
76
97
  // Cleanup
77
- subs.forEach((sub) => sub.remove());
98
+ subscriptions.forEach((sub) => sub.remove());
78
99
  await stopNavigation();
79
100
  ```
80
101
 
81
- ## Embedded View
102
+ ## Embedded Navigation View
82
103
 
83
104
  ```tsx
84
105
  import { MapboxNavigationView } from "@atomiqlab/react-native-mapbox-navigation";
@@ -89,13 +110,363 @@ import { MapboxNavigationView } from "@atomiqlab/react-native-mapbox-navigation"
89
110
  startOrigin={{ latitude: 37.7749, longitude: -122.4194 }}
90
111
  shouldSimulateRoute
91
112
  cameraMode="following"
113
+ showsTripProgress
92
114
  onBannerInstruction={(instruction) => console.log(instruction.primaryText)}
115
+ onRouteProgressChange={(progress) => console.log(progress.fractionTraveled)}
116
+ onError={(error) => console.warn(error.message)}
93
117
  />;
94
118
  ```
95
119
 
96
- ## API Surface
120
+ ```tsx
121
+ import React, { useEffect, useMemo, useState } from "react";
122
+ import {
123
+ addArriveListener,
124
+ addBannerInstructionListener,
125
+ addCancelNavigationListener,
126
+ addErrorListener,
127
+ addLocationChangeListener,
128
+ addRouteProgressChangeListener,
129
+ getNavigationSettings,
130
+ isNavigating,
131
+ MapboxNavigationView,
132
+ setDistanceUnit,
133
+ setLanguage,
134
+ setMuted,
135
+ setVoiceVolume,
136
+ startNavigation,
137
+ stopNavigation,
138
+ type Waypoint,
139
+ } from "@atomiqlab/react-native-mapbox-navigation";
140
+ import {
141
+ Platform,
142
+ Pressable,
143
+ SafeAreaView,
144
+ ScrollView,
145
+ StyleSheet,
146
+ Text,
147
+ TextInput,
148
+ View,
149
+ } from "react-native";
150
+
151
+ const START: Waypoint = {
152
+ latitude: 37.7749,
153
+ longitude: -122.4194,
154
+ name: "San Francisco",
155
+ };
156
+ const DEST: Waypoint = {
157
+ latitude: 37.7847,
158
+ longitude: -122.4073,
159
+ name: "Downtown",
160
+ };
161
+
162
+ export default function Index() {
163
+ const [logs, setLogs] = useState<string[]>([]);
164
+ const [navigating, setNavigating] = useState(false);
165
+ const [showEmbedded, setShowEmbedded] = useState(false);
166
+
167
+ const [mute, setMuteState] = useState(false);
168
+ const [unit, setUnit] = useState<"metric" | "imperial">("metric");
169
+ const [language, setLanguageState] = useState("en");
170
+ const [volumeInput, setVolumeInput] = useState("1");
171
+
172
+ const pushLog = (line: string) =>
173
+ setLogs((prev) =>
174
+ [`${new Date().toLocaleTimeString()} ${line}`, ...prev].slice(0, 40),
175
+ );
176
+
177
+ useEffect(() => {
178
+ const s1 = addLocationChangeListener((e) =>
179
+ pushLog(`location: ${e.latitude.toFixed(5)}, ${e.longitude.toFixed(5)}`),
180
+ );
181
+ const s2 = addRouteProgressChangeListener((e) =>
182
+ pushLog(`progress: ${(e.fractionTraveled * 100).toFixed(1)}%`),
183
+ );
184
+ const s3 = addBannerInstructionListener((e) =>
185
+ pushLog(`banner: ${e.primaryText}`),
186
+ );
187
+ const s4 = addArriveListener((e) => {
188
+ pushLog(`arrive: ${e.name ?? "destination"}`);
189
+ setNavigating(false);
190
+ });
191
+ const s5 = addCancelNavigationListener(() => {
192
+ pushLog("cancelled");
193
+ setNavigating(false);
194
+ });
195
+ const s6 = addErrorListener((e) =>
196
+ pushLog(`error: ${e.message ?? e.code}`),
197
+ );
198
+
199
+ (async () => {
200
+ try {
201
+ setNavigating(await isNavigating());
202
+ pushLog(`isNavigating() loaded`);
203
+ } catch (e: any) {
204
+ pushLog(`isNavigating failed: ${e?.message ?? "unknown"}`);
205
+ }
206
+ })();
97
207
 
98
- ### Core functions
208
+ return () => {
209
+ s1.remove();
210
+ s2.remove();
211
+ s3.remove();
212
+ s4.remove();
213
+ s5.remove();
214
+ s6.remove();
215
+ };
216
+ }, []);
217
+
218
+ const canRun = useMemo(
219
+ () => Platform.OS === "ios" || Platform.OS === "android",
220
+ [],
221
+ );
222
+
223
+ if (!canRun) {
224
+ return (
225
+ <SafeAreaView style={styles.center}>
226
+ <Text style={styles.title}>Run on iOS/Android only</Text>
227
+ </SafeAreaView>
228
+ );
229
+ }
230
+
231
+ return (
232
+ <SafeAreaView style={styles.container}>
233
+ <ScrollView contentContainerStyle={styles.content}>
234
+ <Text style={styles.title}>Mapbox Navigation Full API Test</Text>
235
+ <Text style={styles.status}>
236
+ Status: {navigating ? "Navigating" : "Idle"}
237
+ </Text>
238
+
239
+ <View style={styles.row}>
240
+ <Pressable
241
+ style={styles.btn}
242
+ onPress={async () => {
243
+ try {
244
+ await startNavigation({
245
+ startOrigin: START,
246
+ destination: DEST,
247
+ shouldSimulateRoute: true,
248
+ routeAlternatives: true,
249
+ cameraMode: "following",
250
+ uiTheme: "system",
251
+ mute,
252
+ voiceVolume: Math.max(
253
+ 0,
254
+ Math.min(Number(volumeInput) || 1, 1),
255
+ ),
256
+ distanceUnit: unit,
257
+ language,
258
+ });
259
+ setNavigating(true);
260
+ pushLog("startNavigation() ok");
261
+ } catch (e: any) {
262
+ pushLog(`startNavigation failed: ${e?.message ?? "unknown"}`);
263
+ }
264
+ }}
265
+ >
266
+ <Text style={styles.btnText}>startNavigation</Text>
267
+ </Pressable>
268
+
269
+ <Pressable
270
+ style={styles.btn}
271
+ onPress={async () => {
272
+ try {
273
+ await stopNavigation();
274
+ setNavigating(false);
275
+ pushLog("stopNavigation() ok");
276
+ } catch (e: any) {
277
+ pushLog(`stopNavigation failed: ${e?.message ?? "unknown"}`);
278
+ }
279
+ }}
280
+ >
281
+ <Text style={styles.btnText}>stopNavigation</Text>
282
+ </Pressable>
283
+ </View>
284
+
285
+ <View style={styles.row}>
286
+ <Pressable
287
+ style={styles.btn}
288
+ onPress={async () =>
289
+ pushLog(`isNavigating(): ${await isNavigating()}`)
290
+ }
291
+ >
292
+ <Text style={styles.btnText}>isNavigating</Text>
293
+ </Pressable>
294
+
295
+ <Pressable
296
+ style={styles.btn}
297
+ onPress={async () =>
298
+ pushLog(
299
+ `getNavigationSettings(): ${JSON.stringify(await getNavigationSettings())}`,
300
+ )
301
+ }
302
+ >
303
+ <Text style={styles.btnText}>getNavigationSettings</Text>
304
+ </Pressable>
305
+ </View>
306
+
307
+ <View style={styles.card}>
308
+ <Text style={styles.label}>mute / setMuted</Text>
309
+ <View style={styles.row}>
310
+ <Pressable
311
+ style={styles.btn}
312
+ onPress={async () => {
313
+ const next = !mute;
314
+ setMuteState(next);
315
+ await setMuted(next);
316
+ pushLog(`setMuted(${next})`);
317
+ }}
318
+ >
319
+ <Text style={styles.btnText}>{mute ? "Unmute" : "Mute"}</Text>
320
+ </Pressable>
321
+ </View>
322
+
323
+ <Text style={styles.label}>setVoiceVolume (0..1)</Text>
324
+ <View style={styles.row}>
325
+ <TextInput
326
+ style={styles.input}
327
+ value={volumeInput}
328
+ onChangeText={setVolumeInput}
329
+ keyboardType="decimal-pad"
330
+ />
331
+ <Pressable
332
+ style={styles.btn}
333
+ onPress={async () => {
334
+ const vol = Math.max(0, Math.min(Number(volumeInput) || 1, 1));
335
+ await setVoiceVolume(vol);
336
+ pushLog(`setVoiceVolume(${vol})`);
337
+ }}
338
+ >
339
+ <Text style={styles.btnText}>Apply</Text>
340
+ </Pressable>
341
+ </View>
342
+
343
+ <Text style={styles.label}>setDistanceUnit</Text>
344
+ <View style={styles.row}>
345
+ <Pressable
346
+ style={styles.btn}
347
+ onPress={async () => {
348
+ setUnit("metric");
349
+ await setDistanceUnit("metric");
350
+ pushLog("setDistanceUnit(metric)");
351
+ }}
352
+ >
353
+ <Text style={styles.btnText}>Metric</Text>
354
+ </Pressable>
355
+ <Pressable
356
+ style={styles.btn}
357
+ onPress={async () => {
358
+ setUnit("imperial");
359
+ await setDistanceUnit("imperial");
360
+ pushLog("setDistanceUnit(imperial)");
361
+ }}
362
+ >
363
+ <Text style={styles.btnText}>Imperial</Text>
364
+ </Pressable>
365
+ </View>
366
+
367
+ <Text style={styles.label}>setLanguage</Text>
368
+ <View style={styles.row}>
369
+ <TextInput
370
+ style={styles.input}
371
+ value={language}
372
+ onChangeText={setLanguageState}
373
+ />
374
+ <Pressable
375
+ style={styles.btn}
376
+ onPress={async () => {
377
+ await setLanguage(language.trim() || "en");
378
+ pushLog(`setLanguage(${language.trim() || "en"})`);
379
+ }}
380
+ >
381
+ <Text style={styles.btnText}>Apply</Text>
382
+ </Pressable>
383
+ </View>
384
+ </View>
385
+
386
+ <Pressable
387
+ style={styles.btn}
388
+ onPress={() => setShowEmbedded((v) => !v)}
389
+ >
390
+ <Text style={styles.btnText}>
391
+ {showEmbedded ? "Hide" : "Show"} Embedded MapboxNavigationView
392
+ </Text>
393
+ </Pressable>
394
+
395
+ {showEmbedded ? (
396
+ <View style={styles.embeddedWrap}>
397
+ <MapboxNavigationView
398
+ style={{ flex: 1 }}
399
+ destination={DEST}
400
+ startOrigin={START}
401
+ shouldSimulateRoute
402
+ cameraMode="overview"
403
+ uiTheme="system"
404
+ onError={(e) => pushLog(`embedded error: ${e.message ?? e.code}`)}
405
+ onBannerInstruction={(e) =>
406
+ pushLog(`embedded banner: ${e.primaryText}`)
407
+ }
408
+ onArrive={(e) =>
409
+ pushLog(`embedded arrive: ${e.name ?? "destination"}`)
410
+ }
411
+ onCancelNavigation={() => pushLog("embedded cancelled")}
412
+ />
413
+ </View>
414
+ ) : null}
415
+
416
+ <View style={styles.card}>
417
+ <Text style={styles.label}>Event Logs</Text>
418
+ {logs.map((l, i) => (
419
+ <Text key={`${l}-${i}`} style={styles.log}>
420
+ {l}
421
+ </Text>
422
+ ))}
423
+ </View>
424
+ </ScrollView>
425
+ </SafeAreaView>
426
+ );
427
+ }
428
+
429
+ const styles = StyleSheet.create({
430
+ container: { flex: 1, backgroundColor: "#0b1020" },
431
+ center: {
432
+ flex: 1,
433
+ justifyContent: "center",
434
+ alignItems: "center",
435
+ backgroundColor: "#0b1020",
436
+ },
437
+ content: { padding: 14, gap: 10, paddingBottom: 28 },
438
+ title: { color: "#fff", fontSize: 22, fontWeight: "800" },
439
+ status: { color: "#8ec5ff", fontSize: 13 },
440
+ row: { flexDirection: "row", gap: 8, alignItems: "center" },
441
+ card: { backgroundColor: "#131a2d", borderRadius: 12, padding: 10, gap: 8 },
442
+ label: { color: "#cde4ff", fontSize: 12, fontWeight: "700" },
443
+ input: {
444
+ flex: 1,
445
+ backgroundColor: "#0e1426",
446
+ borderColor: "#2b3a5d",
447
+ borderWidth: 1,
448
+ borderRadius: 8,
449
+ color: "#fff",
450
+ paddingHorizontal: 10,
451
+ paddingVertical: 8,
452
+ },
453
+ btn: {
454
+ flex: 1,
455
+ backgroundColor: "#2563eb",
456
+ borderRadius: 10,
457
+ paddingVertical: 10,
458
+ paddingHorizontal: 10,
459
+ alignItems: "center",
460
+ },
461
+ btnText: { color: "#fff", fontWeight: "700", fontSize: 12 },
462
+ embeddedWrap: { height: 320, borderRadius: 12, overflow: "hidden" },
463
+ log: { color: "#dbeafe", fontSize: 11, marginBottom: 2 },
464
+ });
465
+ ```
466
+
467
+ ## API Overview
468
+
469
+ Core functions:
99
470
 
100
471
  - `startNavigation(options)`
101
472
  - `stopNavigation()`
@@ -106,7 +477,7 @@ import { MapboxNavigationView } from "@atomiqlab/react-native-mapbox-navigation"
106
477
  - `setDistanceUnit(unit)`
107
478
  - `setLanguage(language)`
108
479
 
109
- ### Listener helpers
480
+ Event listeners:
110
481
 
111
482
  - `addLocationChangeListener(listener)`
112
483
  - `addRouteProgressChangeListener(listener)`
@@ -115,17 +486,28 @@ import { MapboxNavigationView } from "@atomiqlab/react-native-mapbox-navigation"
115
486
  - `addCancelNavigationListener(listener)`
116
487
  - `addErrorListener(listener)`
117
488
 
118
- ### Key navigation options
489
+ Main `NavigationOptions` fields:
119
490
 
120
- - Routing: `destination`, `startOrigin`, `waypoints`, `routeAlternatives`, `shouldSimulateRoute`
491
+ - Route: `destination`, `startOrigin`, `waypoints`, `routeAlternatives`, `shouldSimulateRoute`
121
492
  - Camera: `cameraMode`, `cameraPitch`, `cameraZoom`
122
493
  - Theme/style: `uiTheme`, `mapStyleUri`, `mapStyleUriDay`, `mapStyleUriNight`
123
- - Guidance/UI: `distanceUnit`, `language`, `mute`, `voiceVolume`
124
- - Visibility toggles: `showsSpeedLimits`, `showsWayNameLabel`, `showsTripProgress`, `showsManeuverView`, `showsActionButtons`
494
+ - Guidance: `distanceUnit`, `language`, `mute`, `voiceVolume`
495
+ - UI toggles: `showsSpeedLimits`, `showsWayNameLabel`, `showsTripProgress`, `showsManeuverView`, `showsActionButtons`
496
+
497
+ Full types: `src/MapboxNavigation.types.ts`
498
+
499
+ ## Common Error Codes
500
+
501
+ - `MAPBOX_TOKEN_INVALID`: invalid/expired token or unauthorized access
502
+ - `MAPBOX_TOKEN_FORBIDDEN`: token lacks required scopes/permissions
503
+ - `MAPBOX_RATE_LIMITED`: Mapbox rate limit reached
504
+ - `ROUTE_FETCH_FAILED`: route request failed with native details
505
+ - `CURRENT_LOCATION_UNAVAILABLE`: unable to resolve device location
506
+ - `INVALID_COORDINATES`: invalid origin/destination coordinates
125
507
 
126
- Full type reference: `src/MapboxNavigation.types.ts`
508
+ Subscribe via `addErrorListener` or `onError` to surface these to developers during testing and production diagnostics.
127
509
 
128
- ## Platform behavior
510
+ ## Platform Notes
129
511
 
130
- - Android: `startOrigin` optional; current location start is supported.
131
- - iOS: `startOrigin` optional; when omitted, the module resolves current device location (requires location permission).
512
+ - Android: `startOrigin` is optional (current location is supported).
513
+ - iOS: `startOrigin` is optional (current location is resolved at runtime with location permission).
package/SECURITY.md ADDED
@@ -0,0 +1,36 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest published minor version receives security updates.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ - Open a private security advisory on GitHub, or
10
+ - Email the maintainers listed in the repository profile.
11
+
12
+ Include:
13
+ - Package version
14
+ - Platform (iOS/Android)
15
+ - Reproduction steps
16
+ - Impact assessment
17
+
18
+ ## Dependency Audit Policy
19
+
20
+ `npm audit` output in Expo/React Native apps can include transitive advisories from upstream tooling.
21
+
22
+ Project policy:
23
+ 1. Prioritize vulnerabilities reachable in production runtime dependencies.
24
+ 2. Track dev/build-only advisories from Expo/Metro/Jest and resolve when upstream ships patched versions.
25
+ 3. Never run `npm audit fix --force` blindly in release branches.
26
+ 4. Keep Expo SDK and React Native versions updated to receive upstream security fixes.
27
+
28
+ ## Token Security
29
+
30
+ - Never commit Mapbox tokens to git.
31
+ - Use environment variables or CI/CD secrets.
32
+ - Rotate tokens immediately if leaked.
33
+
34
+ Required tokens:
35
+ - `EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN` (`pk...`)
36
+ - `MAPBOX_DOWNLOADS_TOKEN` (`sk...`, `DOWNLOADS:READ`)
@@ -112,6 +112,15 @@ class MapboxNavigationActivity : AppCompatActivity() {
112
112
 
113
113
  override fun onRouteFetchFailed(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
114
114
  Log.e(TAG, "Route fetch failed. reasons=${reasons.size}, options=$routeOptions")
115
+ val (code, message) = mapRouteFetchFailure(reasons)
116
+ MapboxNavigationEventBridge.emit(
117
+ "onError",
118
+ mapOf(
119
+ "code" to code,
120
+ "message" to message
121
+ )
122
+ )
123
+ showErrorAndStay(message, null)
115
124
  }
116
125
 
117
126
  override fun onRouteFetchSuccessful(routes: List<NavigationRoute>) {
@@ -127,6 +136,13 @@ class MapboxNavigationActivity : AppCompatActivity() {
127
136
 
128
137
  override fun onRouteFetchCanceled(routeOptions: RouteOptions, routerOrigin: RouterOrigin) {
129
138
  Log.w(TAG, "Route fetch canceled. origin=$routerOrigin, options=$routeOptions")
139
+ MapboxNavigationEventBridge.emit(
140
+ "onError",
141
+ mapOf(
142
+ "code" to "ROUTE_FETCH_CANCELED",
143
+ "message" to "Route fetch canceled (origin: $routerOrigin)."
144
+ )
145
+ )
130
146
  }
131
147
  }
132
148
 
@@ -301,6 +317,25 @@ class MapboxNavigationActivity : AppCompatActivity() {
301
317
  }
302
318
  }
303
319
 
320
+ private fun mapRouteFetchFailure(reasons: List<RouterFailure>): Pair<String, String> {
321
+ val details = reasons.joinToString(" | ") { it.message.orEmpty() }.trim()
322
+ if (details.contains("401") || details.contains("unauthorized", ignoreCase = true)) {
323
+ return "MAPBOX_TOKEN_INVALID" to
324
+ "Route fetch failed: unauthorized. Check EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN and token scopes."
325
+ }
326
+ if (details.contains("403") || details.contains("forbidden", ignoreCase = true)) {
327
+ return "MAPBOX_TOKEN_FORBIDDEN" to
328
+ "Route fetch failed: access forbidden. Verify token scopes and account permissions."
329
+ }
330
+ if (details.contains("429") || details.contains("rate", ignoreCase = true)) {
331
+ return "MAPBOX_RATE_LIMITED" to
332
+ "Route fetch failed: rate limited by Mapbox."
333
+ }
334
+
335
+ return "ROUTE_FETCH_FAILED" to
336
+ (if (details.isNotEmpty()) "Route fetch failed: $details" else "Route fetch failed for unknown reason.")
337
+ }
338
+
304
339
  private fun hasLocationPermission(): Boolean {
305
340
  val fineGranted = ContextCompat.checkSelfPermission(
306
341
  this,
@@ -322,6 +357,14 @@ class MapboxNavigationActivity : AppCompatActivity() {
322
357
  Log.e(TAG, message)
323
358
  }
324
359
 
360
+ MapboxNavigationEventBridge.emit(
361
+ "onError",
362
+ mapOf(
363
+ "code" to "NATIVE_ERROR",
364
+ "message" to message
365
+ )
366
+ )
367
+
325
368
  val errorView = TextView(this).apply {
326
369
  text = message
327
370
  gravity = Gravity.CENTER
package/app.plugin.js CHANGED
@@ -32,6 +32,64 @@ const REQUIRED_ANDROID_PERMISSIONS = [
32
32
  const DEFAULT_IOS_LOCATION_USAGE =
33
33
  "Allow $(PRODUCT_NAME) to access your location for turn-by-turn navigation.";
34
34
 
35
+ function resolveToken(config, envKeys = [], extraKeys = []) {
36
+ for (const key of envKeys) {
37
+ const value = process.env[key]?.trim();
38
+ if (value) {
39
+ return value;
40
+ }
41
+ }
42
+
43
+ const extra = config?.extra ?? {};
44
+ for (const key of extraKeys) {
45
+ const value = typeof extra[key] === "string" ? extra[key].trim() : "";
46
+ if (value) {
47
+ return value;
48
+ }
49
+ }
50
+
51
+ return "";
52
+ }
53
+
54
+ function validateMapboxTokenShape(token, expectedPrefix) {
55
+ return token.startsWith(expectedPrefix) && token.length > 20;
56
+ }
57
+
58
+ function assertRequiredTokens(config) {
59
+ const publicToken = resolveToken(
60
+ config,
61
+ ["EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN", "MAPBOX_PUBLIC_TOKEN"],
62
+ ["mapboxPublicToken", "expoPublicMapboxAccessToken", "mapboxAccessToken"]
63
+ );
64
+ const downloadsToken = resolveToken(
65
+ config,
66
+ ["MAPBOX_DOWNLOADS_TOKEN"],
67
+ ["mapboxDownloadsToken"]
68
+ );
69
+
70
+ if (!publicToken) {
71
+ throw new Error(
72
+ "[@atomiqlab/react-native-mapbox-navigation] Missing EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN (or MAPBOX_PUBLIC_TOKEN / expo.extra.mapboxPublicToken)."
73
+ );
74
+ }
75
+ if (!validateMapboxTokenShape(publicToken, "pk.")) {
76
+ throw new Error(
77
+ "[@atomiqlab/react-native-mapbox-navigation] Invalid public token format. Expected a Mapbox public token starting with 'pk.'."
78
+ );
79
+ }
80
+
81
+ if (!downloadsToken) {
82
+ throw new Error(
83
+ "[@atomiqlab/react-native-mapbox-navigation] Missing MAPBOX_DOWNLOADS_TOKEN (or expo.extra.mapboxDownloadsToken)."
84
+ );
85
+ }
86
+ if (!validateMapboxTokenShape(downloadsToken, "sk.")) {
87
+ throw new Error(
88
+ "[@atomiqlab/react-native-mapbox-navigation] Invalid downloads token format. Expected a Mapbox secret token starting with 'sk.' and DOWNLOADS:READ scope."
89
+ );
90
+ }
91
+ }
92
+
35
93
  function ensureAndroidPermissions(androidManifest) {
36
94
  const manifest = androidManifest.manifest;
37
95
  if (!manifest["uses-permission"]) {
@@ -114,10 +172,11 @@ function withMapboxNavigationAndroid(config) {
114
172
  function withMapboxNavigationIos(config) {
115
173
  return withInfoPlist(config, (config) => {
116
174
  const infoPlist = config.modResults;
117
- const mapboxPublicToken =
118
- process.env.EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN ||
119
- process.env.MAPBOX_PUBLIC_TOKEN ||
120
- "";
175
+ const mapboxPublicToken = resolveToken(
176
+ config,
177
+ ["EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN", "MAPBOX_PUBLIC_TOKEN"],
178
+ ["mapboxPublicToken", "expoPublicMapboxAccessToken", "mapboxAccessToken"]
179
+ );
121
180
 
122
181
  if (!infoPlist.MBXAccessToken && mapboxPublicToken) {
123
182
  infoPlist.MBXAccessToken = mapboxPublicToken;
@@ -142,6 +201,7 @@ function withMapboxNavigationIos(config) {
142
201
  }
143
202
 
144
203
  const withMapboxNavigation = (config) => {
204
+ assertRequiredTokens(config);
145
205
  config = withMapboxNavigationAndroid(config);
146
206
  config = withMapboxNavigationIos(config);
147
207
  return config;
@@ -150,5 +210,5 @@ const withMapboxNavigation = (config) => {
150
210
  module.exports = createRunOncePlugin(
151
211
  withMapboxNavigation,
152
212
  "react-native-mapbox-navigation-plugin",
153
- "1.1.0"
213
+ "1.1.1"
154
214
  );
@@ -33,3 +33,25 @@ If not, verify you are running the latest package version and rebuilt native cod
33
33
  ```bash
34
34
  npx expo prebuild --clean
35
35
  ```
36
+
37
+ ## Prebuild Fails With Missing Token Error
38
+
39
+ The config plugin now validates tokens early. Ensure one of these provides a public token:
40
+
41
+ - `EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN`
42
+ - `MAPBOX_PUBLIC_TOKEN`
43
+ - `expo.extra.mapboxPublicToken`
44
+
45
+ And provide downloads token:
46
+
47
+ - `MAPBOX_DOWNLOADS_TOKEN` or `expo.extra.mapboxDownloadsToken`
48
+
49
+ ## Route Fetch Errors (401/403/429)
50
+
51
+ Listen to `onError` / `addErrorListener` and check `code`:
52
+
53
+ - `MAPBOX_TOKEN_INVALID` (invalid/expired token)
54
+ - `MAPBOX_TOKEN_FORBIDDEN` (missing scopes)
55
+ - `MAPBOX_RATE_LIMITED` (request throttling)
56
+
57
+ For Android dependency download failures during build, verify `MAPBOX_DOWNLOADS_TOKEN` has `DOWNLOADS:READ` scope.
@@ -159,6 +159,10 @@ public class MapboxNavigationModule: Module {
159
159
 
160
160
  private func startNavigation(options: NavigationStartOptions, promise: Promise) {
161
161
  guard let destination = options.destination.toCLLocationCoordinate2D() else {
162
+ self.sendEvent("onError", [
163
+ "code": "INVALID_COORDINATES",
164
+ "message": "Invalid coordinates provided"
165
+ ])
162
166
  promise.reject("INVALID_COORDINATES", "Invalid coordinates provided")
163
167
  return
164
168
  }
@@ -184,6 +188,10 @@ public class MapboxNavigationModule: Module {
184
188
  promise: promise
185
189
  )
186
190
  case .failure(let error):
191
+ self.sendEvent("onError", [
192
+ "code": "CURRENT_LOCATION_UNAVAILABLE",
193
+ "message": error.localizedDescription
194
+ ])
187
195
  promise.reject("CURRENT_LOCATION_UNAVAILABLE", error.localizedDescription)
188
196
  }
189
197
  }
@@ -225,6 +233,10 @@ public class MapboxNavigationModule: Module {
225
233
  switch result {
226
234
  case .success(let response):
227
235
  guard response.routes?.first != nil else {
236
+ self.sendEvent("onError", [
237
+ "code": "NO_ROUTE",
238
+ "message": "No route found"
239
+ ])
228
240
  promise.reject("NO_ROUTE", "No route found")
229
241
  return
230
242
  }
@@ -276,7 +288,12 @@ public class MapboxNavigationModule: Module {
276
288
  }
277
289
 
278
290
  case .failure(let error):
279
- promise.reject("ROUTE_ERROR", error.localizedDescription)
291
+ let (code, message) = self.mapDirectionsError(error)
292
+ self.sendEvent("onError", [
293
+ "code": code,
294
+ "message": message
295
+ ])
296
+ promise.reject(code, message)
280
297
  }
281
298
  }
282
299
  }
@@ -292,6 +309,23 @@ public class MapboxNavigationModule: Module {
292
309
  }
293
310
  }
294
311
 
312
+ private func mapDirectionsError(_ error: Error) -> (String, String) {
313
+ let message = error.localizedDescription
314
+ let lowered = message.lowercased()
315
+
316
+ if lowered.contains("401") || lowered.contains("unauthorized") {
317
+ return ("MAPBOX_TOKEN_INVALID", "Route fetch failed: unauthorized. Check EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN and token scopes.")
318
+ }
319
+ if lowered.contains("403") || lowered.contains("forbidden") {
320
+ return ("MAPBOX_TOKEN_FORBIDDEN", "Route fetch failed: access forbidden. Verify token scopes and account permissions.")
321
+ }
322
+ if lowered.contains("429") || lowered.contains("rate") {
323
+ return ("MAPBOX_RATE_LIMITED", "Route fetch failed: rate limited by Mapbox.")
324
+ }
325
+
326
+ return ("ROUTE_ERROR", message)
327
+ }
328
+
295
329
  private func stopNavigation(promise: Promise) {
296
330
  guard let navVC = navigationViewController else {
297
331
  promise.resolve(nil)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atomiqlab/react-native-mapbox-navigation",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Native Mapbox turn-by-turn navigation for Expo and React Native (iOS + Android)",
5
5
  "main": "src/index.tsx",
6
6
  "types": "src/index.tsx",
@@ -27,6 +27,7 @@
27
27
  "README.md",
28
28
  "QUICKSTART.md",
29
29
  "CHANGELOG.md",
30
+ "SECURITY.md",
30
31
  "docs",
31
32
  "scripts"
32
33
  ],
@@ -70,6 +71,7 @@
70
71
  "react-native": "*"
71
72
  },
72
73
  "devDependencies": {
73
- "expo-module-scripts": "^3.0.0"
74
+ "expo-module-scripts": "^3.0.0",
75
+ "typescript": "^5.9.3"
74
76
  }
75
77
  }
@@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const moduleDir = path.resolve(path.dirname(__filename), "..");
8
- const repoRoot = path.resolve(moduleDir, "..", "..");
9
- const androidDir = path.join(repoRoot, "android");
10
- const envFilePath = path.join(repoRoot, ".env");
8
+ const repoRoot = moduleDir;
9
+ const androidDir = path.join(moduleDir, "android");
10
+ const envFilePath = path.join(moduleDir, ".env");
11
11
 
12
12
  function loadDotEnv(filePath) {
13
13
  if (!existsSync(filePath)) {
@@ -49,7 +49,8 @@ function run(command, cwd = moduleDir, options = {}) {
49
49
  env: {
50
50
  ...process.env,
51
51
  GRADLE_USER_HOME:
52
- process.env.GRADLE_USER_HOME || "/tmp/gradle-user-home-react-native-mapbox-navigation",
52
+ process.env.GRADLE_USER_HOME ||
53
+ "/tmp/gradle-user-home-react-native-mapbox-navigation",
53
54
  },
54
55
  });
55
56
  if (output?.length) {
@@ -72,7 +73,9 @@ function run(command, cwd = moduleDir, options = {}) {
72
73
  combined.includes("SocketException: Operation not permitted");
73
74
 
74
75
  if (optionalOnOfflineGradle && skippableGradleEnvironmentIssue) {
75
- console.warn(`\nSkipping optional Gradle check due to restricted/offline environment: ${command}`);
76
+ console.warn(
77
+ `\nSkipping optional Gradle check due to restricted/offline environment: ${command}`,
78
+ );
76
79
  return;
77
80
  }
78
81
  throw error;
@@ -95,7 +98,14 @@ function runWithFallback(commands, cwd, options = {}) {
95
98
 
96
99
  loadDotEnv(envFilePath);
97
100
 
98
- run("npx tsc --noEmit", repoRoot);
101
+ const tsconfigPath = path.join(moduleDir, "tsconfig.json");
102
+ if (existsSync(tsconfigPath)) {
103
+ run(`npx --no-install tsc -p ${tsconfigPath} --noEmit`, moduleDir);
104
+ } else {
105
+ console.log(
106
+ "\n> Skipping TypeScript check (tsconfig.json not found in package root).",
107
+ );
108
+ }
99
109
 
100
110
  if (existsSync(path.join(androidDir, "gradlew"))) {
101
111
  runWithFallback(
@@ -104,12 +114,17 @@ if (existsSync(path.join(androidDir, "gradlew"))) {
104
114
  "./gradlew :mapbox-navigation-native:compileDebugKotlin",
105
115
  ],
106
116
  androidDir,
107
- { optionalOnOfflineGradle: true }
117
+ { optionalOnOfflineGradle: true },
108
118
  );
109
119
  } else {
110
- console.log("\n> Skipping Android compile check (android/gradlew not found).");
120
+ console.log(
121
+ "\n> Skipping Android compile check (android/gradlew not found).",
122
+ );
111
123
  }
112
124
 
113
- run("npm pack --dry-run --cache /tmp/npm-cache-react-native-mapbox-navigation", moduleDir);
125
+ run(
126
+ "npm pack --dry-run --cache /tmp/npm-cache-react-native-mapbox-navigation",
127
+ moduleDir,
128
+ );
114
129
 
115
130
  console.log("\nRelease verification completed.");
@@ -1,36 +1,66 @@
1
+ /** Geographic coordinate in WGS84 format. */
1
2
  export type Coordinate = {
3
+ /** Latitude in range -90..90 */
2
4
  latitude: number;
5
+ /** Longitude in range -180..180 */
3
6
  longitude: number;
4
7
  };
5
8
 
9
+ /** A route point that can optionally include a display name. */
6
10
  export type Waypoint = Coordinate & {
11
+ /** Optional label for UI/debug output. */
7
12
  name?: string;
8
13
  };
9
14
 
15
+ /**
16
+ * Options passed to `startNavigation`.
17
+ */
10
18
  export type NavigationOptions = {
19
+ /** Optional route origin. If omitted, native layer may resolve current location. */
11
20
  startOrigin?: Coordinate;
21
+ /** Final destination waypoint (required). */
12
22
  destination: Waypoint;
23
+ /** Optional intermediate waypoints. */
13
24
  waypoints?: Waypoint[];
25
+ /** Enable route simulation for testing without physical movement. */
14
26
  shouldSimulateRoute?: boolean;
27
+ /** UI theme selection for native UI. */
15
28
  uiTheme?: 'system' | 'light' | 'dark' | 'day' | 'night';
29
+ /** Request alternative routes when available. */
16
30
  routeAlternatives?: boolean;
31
+ /** Distance unit for guidance. */
17
32
  distanceUnit?: 'metric' | 'imperial';
33
+ /** Guidance language (for example `en`, `fr`). */
18
34
  language?: string;
35
+ /** Start muted. */
19
36
  mute?: boolean;
37
+ /** Voice volume range `0..1`. */
20
38
  voiceVolume?: number;
39
+ /** Camera pitch range `0..85`. */
21
40
  cameraPitch?: number;
41
+ /** Camera zoom range `1..22`. */
22
42
  cameraZoom?: number;
43
+ /** Camera behavior mode. */
23
44
  cameraMode?: 'following' | 'overview';
45
+ /** One style URI used for both day/night fallback. */
24
46
  mapStyleUri?: string;
47
+ /** Day style URI override. */
25
48
  mapStyleUriDay?: string;
49
+ /** Night style URI override. */
26
50
  mapStyleUriNight?: string;
51
+ /** Show speed limit panel when supported. */
27
52
  showsSpeedLimits?: boolean;
53
+ /** Show current road name label. */
28
54
  showsWayNameLabel?: boolean;
55
+ /** Show trip progress summary. */
29
56
  showsTripProgress?: boolean;
57
+ /** Show maneuver/instruction view. */
30
58
  showsManeuverView?: boolean;
59
+ /** Show default action buttons in native UI. */
31
60
  showsActionButtons?: boolean;
32
61
  };
33
62
 
63
+ /** Runtime settings/state returned by `getNavigationSettings()`. */
34
64
  export type NavigationSettings = {
35
65
  isNavigating: boolean;
36
66
  mute: boolean;
@@ -39,6 +69,7 @@ export type NavigationSettings = {
39
69
  language: string;
40
70
  };
41
71
 
72
+ /** Location update payload emitted by native layer. */
42
73
  export type LocationUpdate = {
43
74
  latitude: number;
44
75
  longitude: number;
@@ -48,6 +79,7 @@ export type LocationUpdate = {
48
79
  accuracy?: number;
49
80
  };
50
81
 
82
+ /** Route progress payload emitted by native layer. */
51
83
  export type RouteProgress = {
52
84
  distanceTraveled: number;
53
85
  distanceRemaining: number;
@@ -55,52 +87,45 @@ export type RouteProgress = {
55
87
  fractionTraveled: number;
56
88
  };
57
89
 
90
+ /** Arrival event payload. */
58
91
  export type ArrivalEvent = {
59
92
  index?: number;
60
93
  name?: string;
61
94
  };
62
95
 
96
+ /** Error payload emitted by native layer. */
63
97
  export type NavigationError = {
98
+ /** Machine-readable error code. */
64
99
  code: string;
100
+ /** Developer-readable error details. */
65
101
  message: string;
66
102
  };
67
103
 
104
+ /** Banner instruction payload emitted during guidance. */
68
105
  export type BannerInstruction = {
69
106
  primaryText: string;
70
107
  secondaryText?: string;
71
108
  stepDistanceRemaining?: number;
72
109
  };
73
110
 
111
+ /** Event subscription handle. */
74
112
  export type Subscription = {
75
113
  remove: () => void;
76
114
  };
77
115
 
116
+ /** Native module interface bridged from iOS/Android. */
78
117
  export interface MapboxNavigationModule {
79
- // Start navigation with options
80
118
  startNavigation(options: NavigationOptions): Promise<void>;
81
-
82
- // Stop/cancel navigation
83
119
  stopNavigation(): Promise<void>;
84
-
85
- // Mute/unmute voice guidance
86
120
  setMuted(muted: boolean): Promise<void>;
87
-
88
- // Set voice volume (0.0 - 1.0)
89
121
  setVoiceVolume(volume: number): Promise<void>;
90
-
91
- // Set distance unit used by spoken and visual instructions
92
122
  setDistanceUnit(unit: 'metric' | 'imperial'): Promise<void>;
93
-
94
- // Set route instruction language (BCP-47, e.g. 'en', 'fr')
95
123
  setLanguage(language: string): Promise<void>;
96
-
97
- // Check if navigation is active
98
124
  isNavigating(): Promise<boolean>;
99
-
100
- // Get current native navigation settings
101
125
  getNavigationSettings(): Promise<NavigationSettings>;
102
126
  }
103
127
 
128
+ /** Props for embedded `MapboxNavigationView`. */
104
129
  export interface MapboxNavigationViewProps {
105
130
  style?: any;
106
131
  startOrigin?: Coordinate;
@@ -125,12 +150,17 @@ export interface MapboxNavigationViewProps {
125
150
  showsTripProgress?: boolean;
126
151
  showsManeuverView?: boolean;
127
152
  showsActionButtons?: boolean;
128
-
129
- // Event callbacks
153
+
154
+ /** Callback for location changes. */
130
155
  onLocationChange?: (location: LocationUpdate) => void;
156
+ /** Callback for route progress changes. */
131
157
  onRouteProgressChange?: (progress: RouteProgress) => void;
158
+ /** Callback when arrival is detected. */
132
159
  onArrive?: (point: ArrivalEvent) => void;
160
+ /** Callback when navigation is canceled by user. */
133
161
  onCancelNavigation?: () => void;
162
+ /** Callback for native errors. */
134
163
  onError?: (error: NavigationError) => void;
164
+ /** Callback for banner instruction updates. */
135
165
  onBannerInstruction?: (instruction: BannerInstruction) => void;
136
166
  }
package/src/index.tsx CHANGED
@@ -2,24 +2,22 @@ import { requireNativeModule, requireNativeViewManager } from "expo-modules-core
2
2
  import { ViewProps } from "react-native";
3
3
 
4
4
  import type {
5
- ArrivalEvent,
6
- BannerInstruction,
7
- LocationUpdate,
8
- MapboxNavigationModule as MapboxNavigationModuleType,
9
- MapboxNavigationViewProps,
10
- NavigationSettings,
11
- NavigationError,
12
- NavigationOptions,
13
- RouteProgress,
14
- Subscription,
5
+ ArrivalEvent,
6
+ BannerInstruction,
7
+ LocationUpdate,
8
+ MapboxNavigationModule as MapboxNavigationModuleType,
9
+ MapboxNavigationViewProps,
10
+ NavigationSettings,
11
+ NavigationError,
12
+ NavigationOptions,
13
+ RouteProgress,
14
+ Subscription,
15
15
  } from "./MapboxNavigation.types";
16
16
 
17
- // Get the native module (use requireNativeModule instead of deprecated NativeModulesProxy)
18
17
  const MapboxNavigationModule = requireNativeModule<MapboxNavigationModuleType>(
19
18
  "MapboxNavigationModule",
20
19
  );
21
20
 
22
- // The native module already acts as an EventEmitter; don't wrap with `new EventEmitter`.
23
21
  const emitter = MapboxNavigationModule as unknown as {
24
22
  addListener: (
25
23
  eventName: string,
@@ -106,74 +104,166 @@ function normalizeNavigationOptions(options: NavigationOptions): NavigationOptio
106
104
  };
107
105
  }
108
106
 
109
- // Module API
107
+ function normalizeNativeError(error: unknown, fallbackCode = "NATIVE_ERROR"): Error {
108
+ if (error instanceof Error) {
109
+ return error;
110
+ }
111
+
112
+ const candidate = error as { code?: string; message?: string } | undefined;
113
+ const code = candidate?.code ?? fallbackCode;
114
+ const message = candidate?.message ?? "Unknown native error";
115
+ return new Error(`[${code}] ${message}`);
116
+ }
117
+
118
+ /**
119
+ * Start full-screen native turn-by-turn navigation.
120
+ *
121
+ * @param options Navigation settings such as destination, camera mode, simulation, and map UI options.
122
+ * @throws Error if options are invalid or native route/token setup fails.
123
+ */
110
124
  export async function startNavigation(
111
125
  options: NavigationOptions,
112
126
  ): Promise<void> {
113
127
  const normalizedOptions = normalizeNavigationOptions(options);
114
- return await MapboxNavigationModule.startNavigation(normalizedOptions);
128
+ try {
129
+ await MapboxNavigationModule.startNavigation(normalizedOptions);
130
+ } catch (error) {
131
+ throw normalizeNativeError(error, "START_NAVIGATION_FAILED");
132
+ }
115
133
  }
116
134
 
135
+ /**
136
+ * Stop/dismiss native navigation if active.
137
+ */
117
138
  export async function stopNavigation(): Promise<void> {
118
- return await MapboxNavigationModule.stopNavigation();
139
+ try {
140
+ await MapboxNavigationModule.stopNavigation();
141
+ } catch (error) {
142
+ throw normalizeNativeError(error, "STOP_NAVIGATION_FAILED");
143
+ }
119
144
  }
120
145
 
146
+ /**
147
+ * Enable or disable voice guidance.
148
+ *
149
+ * @param muted `true` to mute voice instructions.
150
+ */
121
151
  export async function setMuted(muted: boolean): Promise<void> {
122
- return await MapboxNavigationModule.setMuted(muted);
152
+ try {
153
+ await MapboxNavigationModule.setMuted(muted);
154
+ } catch (error) {
155
+ throw normalizeNativeError(error, "SET_MUTED_FAILED");
156
+ }
123
157
  }
124
158
 
159
+ /**
160
+ * Set voice instruction volume in range `0..1`.
161
+ */
125
162
  export async function setVoiceVolume(volume: number): Promise<void> {
126
- return await MapboxNavigationModule.setVoiceVolume(volume);
163
+ try {
164
+ await MapboxNavigationModule.setVoiceVolume(volume);
165
+ } catch (error) {
166
+ throw normalizeNativeError(error, "SET_VOICE_VOLUME_FAILED");
167
+ }
127
168
  }
128
169
 
170
+ /**
171
+ * Set spoken and displayed distance units.
172
+ */
129
173
  export async function setDistanceUnit(unit: "metric" | "imperial"): Promise<void> {
130
- return await MapboxNavigationModule.setDistanceUnit(unit);
174
+ try {
175
+ await MapboxNavigationModule.setDistanceUnit(unit);
176
+ } catch (error) {
177
+ throw normalizeNativeError(error, "SET_DISTANCE_UNIT_FAILED");
178
+ }
131
179
  }
132
180
 
181
+ /**
182
+ * Set instruction language (BCP-47-like code, for example `en`, `fr`).
183
+ */
133
184
  export async function setLanguage(language: string): Promise<void> {
134
- return await MapboxNavigationModule.setLanguage(language);
185
+ try {
186
+ await MapboxNavigationModule.setLanguage(language);
187
+ } catch (error) {
188
+ throw normalizeNativeError(error, "SET_LANGUAGE_FAILED");
189
+ }
135
190
  }
136
191
 
192
+ /**
193
+ * Check whether full-screen native navigation is currently active.
194
+ */
137
195
  export async function isNavigating(): Promise<boolean> {
138
- return await MapboxNavigationModule.isNavigating();
196
+ try {
197
+ return await MapboxNavigationModule.isNavigating();
198
+ } catch (error) {
199
+ throw normalizeNativeError(error, "IS_NAVIGATING_FAILED");
200
+ }
139
201
  }
140
202
 
203
+ /**
204
+ * Read current native navigation runtime settings.
205
+ */
141
206
  export async function getNavigationSettings(): Promise<NavigationSettings> {
142
- return await MapboxNavigationModule.getNavigationSettings();
207
+ try {
208
+ return await MapboxNavigationModule.getNavigationSettings();
209
+ } catch (error) {
210
+ throw normalizeNativeError(error, "GET_NAVIGATION_SETTINGS_FAILED");
211
+ }
143
212
  }
144
213
 
145
- // Event listeners
214
+ /**
215
+ * Subscribe to location updates from native navigation.
216
+ */
146
217
  export function addLocationChangeListener(
147
218
  listener: (location: LocationUpdate) => void,
148
219
  ): Subscription {
149
220
  return emitter.addListener("onLocationChange", listener);
150
221
  }
151
222
 
223
+ /**
224
+ * Subscribe to route progress updates.
225
+ */
152
226
  export function addRouteProgressChangeListener(
153
227
  listener: (progress: RouteProgress) => void,
154
228
  ): Subscription {
155
229
  return emitter.addListener("onRouteProgressChange", listener);
156
230
  }
157
231
 
232
+ /**
233
+ * Subscribe to arrival events.
234
+ */
158
235
  export function addArriveListener(listener: (point: ArrivalEvent) => void): Subscription {
159
236
  return emitter.addListener("onArrive", listener);
160
237
  }
161
238
 
239
+ /**
240
+ * Subscribe to cancellation events.
241
+ */
162
242
  export function addCancelNavigationListener(listener: () => void): Subscription {
163
243
  return emitter.addListener("onCancelNavigation", listener);
164
244
  }
165
245
 
246
+ /**
247
+ * Subscribe to native errors (token issues, route fetch failures, permission failures, etc.).
248
+ */
166
249
  export function addErrorListener(listener: (error: NavigationError) => void): Subscription {
167
250
  return emitter.addListener("onError", listener);
168
251
  }
169
252
 
253
+ /**
254
+ * Subscribe to banner instruction updates.
255
+ */
170
256
  export function addBannerInstructionListener(
171
257
  listener: (instruction: BannerInstruction) => void,
172
258
  ): Subscription {
173
259
  return emitter.addListener("onBannerInstruction", listener);
174
260
  }
175
261
 
176
- // React component wrapper
262
+ /**
263
+ * Embedded native navigation component.
264
+ *
265
+ * Use this when you need navigation inside your own screen layout instead of full-screen modal navigation.
266
+ */
177
267
  export function MapboxNavigationView(
178
268
  props: MapboxNavigationViewProps & ViewProps,
179
269
  ) {
@@ -181,10 +271,8 @@ export function MapboxNavigationView(
181
271
  return <NativeView {...props} />;
182
272
  }
183
273
 
184
- // Export types
185
274
  export * from "./MapboxNavigation.types";
186
275
 
187
- // Default export
188
276
  export default {
189
277
  startNavigation,
190
278
  stopNavigation,