@atomiqlab/react-native-mapbox-navigation 1.1.1 → 1.1.3

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
@@ -11,6 +11,17 @@ Native Mapbox turn-by-turn navigation for Expo apps on iOS and Android.
11
11
  - Navigation customization: camera mode/pitch/zoom, theme, map style, and UI visibility toggles.
12
12
  - Expo config plugin that applies required Android and iOS native setup.
13
13
 
14
+ ## Screenshots
15
+
16
+ ![Android](docs/screenshots/android.png)
17
+ ![iOS](docs/screenshots/ios.png)
18
+
19
+ ## Support Us
20
+
21
+ [![Support on Ko-fi](docs/imgs/support_me_on_kofi_badge_red.png)](https://ko-fi.com/atomiqlab)
22
+
23
+ If this package helps your work, support development on Ko-fi.
24
+
14
25
  ## Requirements
15
26
 
16
27
  - Expo SDK `>=50`
@@ -50,6 +61,16 @@ Regenerate native projects:
50
61
  npx expo prebuild --clean
51
62
  ```
52
63
 
64
+ ### Token validation behavior
65
+
66
+ The config plugin now fails fast during prebuild/build when tokens are missing or malformed:
67
+
68
+ - Missing/invalid public token (`pk...`)
69
+ - Missing/invalid downloads token (`sk...`)
70
+
71
+ This prevents silent runtime failures and surfaces setup issues early.
72
+
73
+
53
74
  ## Quick Start
54
75
 
55
76
  ```ts
@@ -107,6 +128,353 @@ import { MapboxNavigationView } from "@atomiqlab/react-native-mapbox-navigation"
107
128
  />;
108
129
  ```
109
130
 
131
+ ```tsx
132
+ import React, { useEffect, useMemo, useState } from "react";
133
+ import {
134
+ addArriveListener,
135
+ addBannerInstructionListener,
136
+ addCancelNavigationListener,
137
+ addErrorListener,
138
+ addLocationChangeListener,
139
+ addRouteProgressChangeListener,
140
+ getNavigationSettings,
141
+ isNavigating,
142
+ MapboxNavigationView,
143
+ setDistanceUnit,
144
+ setLanguage,
145
+ setMuted,
146
+ setVoiceVolume,
147
+ startNavigation,
148
+ stopNavigation,
149
+ type Waypoint,
150
+ } from "@atomiqlab/react-native-mapbox-navigation";
151
+ import {
152
+ Platform,
153
+ Pressable,
154
+ SafeAreaView,
155
+ ScrollView,
156
+ StyleSheet,
157
+ Text,
158
+ TextInput,
159
+ View,
160
+ } from "react-native";
161
+
162
+ const START: Waypoint = {
163
+ latitude: 37.7749,
164
+ longitude: -122.4194,
165
+ name: "San Francisco",
166
+ };
167
+ const DEST: Waypoint = {
168
+ latitude: 37.7847,
169
+ longitude: -122.4073,
170
+ name: "Downtown",
171
+ };
172
+
173
+ export default function Index() {
174
+ const [logs, setLogs] = useState<string[]>([]);
175
+ const [navigating, setNavigating] = useState(false);
176
+ const [showEmbedded, setShowEmbedded] = useState(false);
177
+
178
+ const [mute, setMuteState] = useState(false);
179
+ const [unit, setUnit] = useState<"metric" | "imperial">("metric");
180
+ const [language, setLanguageState] = useState("en");
181
+ const [volumeInput, setVolumeInput] = useState("1");
182
+
183
+ const pushLog = (line: string) =>
184
+ setLogs((prev) =>
185
+ [`${new Date().toLocaleTimeString()} ${line}`, ...prev].slice(0, 40),
186
+ );
187
+
188
+ useEffect(() => {
189
+ const s1 = addLocationChangeListener((e) =>
190
+ pushLog(`location: ${e.latitude.toFixed(5)}, ${e.longitude.toFixed(5)}`),
191
+ );
192
+ const s2 = addRouteProgressChangeListener((e) =>
193
+ pushLog(`progress: ${(e.fractionTraveled * 100).toFixed(1)}%`),
194
+ );
195
+ const s3 = addBannerInstructionListener((e) =>
196
+ pushLog(`banner: ${e.primaryText}`),
197
+ );
198
+ const s4 = addArriveListener((e) => {
199
+ pushLog(`arrive: ${e.name ?? "destination"}`);
200
+ setNavigating(false);
201
+ });
202
+ const s5 = addCancelNavigationListener(() => {
203
+ pushLog("cancelled");
204
+ setNavigating(false);
205
+ });
206
+ const s6 = addErrorListener((e) =>
207
+ pushLog(`error: ${e.message ?? e.code}`),
208
+ );
209
+
210
+ (async () => {
211
+ try {
212
+ setNavigating(await isNavigating());
213
+ pushLog(`isNavigating() loaded`);
214
+ } catch (e: any) {
215
+ pushLog(`isNavigating failed: ${e?.message ?? "unknown"}`);
216
+ }
217
+ })();
218
+
219
+ return () => {
220
+ s1.remove();
221
+ s2.remove();
222
+ s3.remove();
223
+ s4.remove();
224
+ s5.remove();
225
+ s6.remove();
226
+ };
227
+ }, []);
228
+
229
+ const canRun = useMemo(
230
+ () => Platform.OS === "ios" || Platform.OS === "android",
231
+ [],
232
+ );
233
+
234
+ if (!canRun) {
235
+ return (
236
+ <SafeAreaView style={styles.center}>
237
+ <Text style={styles.title}>Run on iOS/Android only</Text>
238
+ </SafeAreaView>
239
+ );
240
+ }
241
+
242
+ return (
243
+ <SafeAreaView style={styles.container}>
244
+ <ScrollView contentContainerStyle={styles.content}>
245
+ <Text style={styles.title}>Mapbox Navigation Full API Test</Text>
246
+ <Text style={styles.status}>
247
+ Status: {navigating ? "Navigating" : "Idle"}
248
+ </Text>
249
+
250
+ <View style={styles.row}>
251
+ <Pressable
252
+ style={styles.btn}
253
+ onPress={async () => {
254
+ try {
255
+ await startNavigation({
256
+ startOrigin: START,
257
+ destination: DEST,
258
+ shouldSimulateRoute: true,
259
+ routeAlternatives: true,
260
+ cameraMode: "following",
261
+ uiTheme: "system",
262
+ mute,
263
+ voiceVolume: Math.max(
264
+ 0,
265
+ Math.min(Number(volumeInput) || 1, 1),
266
+ ),
267
+ distanceUnit: unit,
268
+ language,
269
+ });
270
+ setNavigating(true);
271
+ pushLog("startNavigation() ok");
272
+ } catch (e: any) {
273
+ pushLog(`startNavigation failed: ${e?.message ?? "unknown"}`);
274
+ }
275
+ }}
276
+ >
277
+ <Text style={styles.btnText}>startNavigation</Text>
278
+ </Pressable>
279
+
280
+ <Pressable
281
+ style={styles.btn}
282
+ onPress={async () => {
283
+ try {
284
+ await stopNavigation();
285
+ setNavigating(false);
286
+ pushLog("stopNavigation() ok");
287
+ } catch (e: any) {
288
+ pushLog(`stopNavigation failed: ${e?.message ?? "unknown"}`);
289
+ }
290
+ }}
291
+ >
292
+ <Text style={styles.btnText}>stopNavigation</Text>
293
+ </Pressable>
294
+ </View>
295
+
296
+ <View style={styles.row}>
297
+ <Pressable
298
+ style={styles.btn}
299
+ onPress={async () =>
300
+ pushLog(`isNavigating(): ${await isNavigating()}`)
301
+ }
302
+ >
303
+ <Text style={styles.btnText}>isNavigating</Text>
304
+ </Pressable>
305
+
306
+ <Pressable
307
+ style={styles.btn}
308
+ onPress={async () =>
309
+ pushLog(
310
+ `getNavigationSettings(): ${JSON.stringify(await getNavigationSettings())}`,
311
+ )
312
+ }
313
+ >
314
+ <Text style={styles.btnText}>getNavigationSettings</Text>
315
+ </Pressable>
316
+ </View>
317
+
318
+ <View style={styles.card}>
319
+ <Text style={styles.label}>mute / setMuted</Text>
320
+ <View style={styles.row}>
321
+ <Pressable
322
+ style={styles.btn}
323
+ onPress={async () => {
324
+ const next = !mute;
325
+ setMuteState(next);
326
+ await setMuted(next);
327
+ pushLog(`setMuted(${next})`);
328
+ }}
329
+ >
330
+ <Text style={styles.btnText}>{mute ? "Unmute" : "Mute"}</Text>
331
+ </Pressable>
332
+ </View>
333
+
334
+ <Text style={styles.label}>setVoiceVolume (0..1)</Text>
335
+ <View style={styles.row}>
336
+ <TextInput
337
+ style={styles.input}
338
+ value={volumeInput}
339
+ onChangeText={setVolumeInput}
340
+ keyboardType="decimal-pad"
341
+ />
342
+ <Pressable
343
+ style={styles.btn}
344
+ onPress={async () => {
345
+ const vol = Math.max(0, Math.min(Number(volumeInput) || 1, 1));
346
+ await setVoiceVolume(vol);
347
+ pushLog(`setVoiceVolume(${vol})`);
348
+ }}
349
+ >
350
+ <Text style={styles.btnText}>Apply</Text>
351
+ </Pressable>
352
+ </View>
353
+
354
+ <Text style={styles.label}>setDistanceUnit</Text>
355
+ <View style={styles.row}>
356
+ <Pressable
357
+ style={styles.btn}
358
+ onPress={async () => {
359
+ setUnit("metric");
360
+ await setDistanceUnit("metric");
361
+ pushLog("setDistanceUnit(metric)");
362
+ }}
363
+ >
364
+ <Text style={styles.btnText}>Metric</Text>
365
+ </Pressable>
366
+ <Pressable
367
+ style={styles.btn}
368
+ onPress={async () => {
369
+ setUnit("imperial");
370
+ await setDistanceUnit("imperial");
371
+ pushLog("setDistanceUnit(imperial)");
372
+ }}
373
+ >
374
+ <Text style={styles.btnText}>Imperial</Text>
375
+ </Pressable>
376
+ </View>
377
+
378
+ <Text style={styles.label}>setLanguage</Text>
379
+ <View style={styles.row}>
380
+ <TextInput
381
+ style={styles.input}
382
+ value={language}
383
+ onChangeText={setLanguageState}
384
+ />
385
+ <Pressable
386
+ style={styles.btn}
387
+ onPress={async () => {
388
+ await setLanguage(language.trim() || "en");
389
+ pushLog(`setLanguage(${language.trim() || "en"})`);
390
+ }}
391
+ >
392
+ <Text style={styles.btnText}>Apply</Text>
393
+ </Pressable>
394
+ </View>
395
+ </View>
396
+
397
+ <Pressable
398
+ style={styles.btn}
399
+ onPress={() => setShowEmbedded((v) => !v)}
400
+ >
401
+ <Text style={styles.btnText}>
402
+ {showEmbedded ? "Hide" : "Show"} Embedded MapboxNavigationView
403
+ </Text>
404
+ </Pressable>
405
+
406
+ {showEmbedded ? (
407
+ <View style={styles.embeddedWrap}>
408
+ <MapboxNavigationView
409
+ style={{ flex: 1 }}
410
+ destination={DEST}
411
+ startOrigin={START}
412
+ shouldSimulateRoute
413
+ cameraMode="overview"
414
+ uiTheme="system"
415
+ onError={(e) => pushLog(`embedded error: ${e.message ?? e.code}`)}
416
+ onBannerInstruction={(e) =>
417
+ pushLog(`embedded banner: ${e.primaryText}`)
418
+ }
419
+ onArrive={(e) =>
420
+ pushLog(`embedded arrive: ${e.name ?? "destination"}`)
421
+ }
422
+ onCancelNavigation={() => pushLog("embedded cancelled")}
423
+ />
424
+ </View>
425
+ ) : null}
426
+
427
+ <View style={styles.card}>
428
+ <Text style={styles.label}>Event Logs</Text>
429
+ {logs.map((l, i) => (
430
+ <Text key={`${l}-${i}`} style={styles.log}>
431
+ {l}
432
+ </Text>
433
+ ))}
434
+ </View>
435
+ </ScrollView>
436
+ </SafeAreaView>
437
+ );
438
+ }
439
+
440
+ const styles = StyleSheet.create({
441
+ container: { flex: 1, backgroundColor: "#0b1020" },
442
+ center: {
443
+ flex: 1,
444
+ justifyContent: "center",
445
+ alignItems: "center",
446
+ backgroundColor: "#0b1020",
447
+ },
448
+ content: { padding: 14, gap: 10, paddingBottom: 28 },
449
+ title: { color: "#fff", fontSize: 22, fontWeight: "800" },
450
+ status: { color: "#8ec5ff", fontSize: 13 },
451
+ row: { flexDirection: "row", gap: 8, alignItems: "center" },
452
+ card: { backgroundColor: "#131a2d", borderRadius: 12, padding: 10, gap: 8 },
453
+ label: { color: "#cde4ff", fontSize: 12, fontWeight: "700" },
454
+ input: {
455
+ flex: 1,
456
+ backgroundColor: "#0e1426",
457
+ borderColor: "#2b3a5d",
458
+ borderWidth: 1,
459
+ borderRadius: 8,
460
+ color: "#fff",
461
+ paddingHorizontal: 10,
462
+ paddingVertical: 8,
463
+ },
464
+ btn: {
465
+ flex: 1,
466
+ backgroundColor: "#2563eb",
467
+ borderRadius: 10,
468
+ paddingVertical: 10,
469
+ paddingHorizontal: 10,
470
+ alignItems: "center",
471
+ },
472
+ btnText: { color: "#fff", fontWeight: "700", fontSize: 12 },
473
+ embeddedWrap: { height: 320, borderRadius: 12, overflow: "hidden" },
474
+ log: { color: "#dbeafe", fontSize: 11, marginBottom: 2 },
475
+ });
476
+ ```
477
+
110
478
  ## API Overview
111
479
 
112
480
  Core functions:
@@ -139,6 +507,17 @@ Main `NavigationOptions` fields:
139
507
 
140
508
  Full types: `src/MapboxNavigation.types.ts`
141
509
 
510
+ ## Common Error Codes
511
+
512
+ - `MAPBOX_TOKEN_INVALID`: invalid/expired token or unauthorized access
513
+ - `MAPBOX_TOKEN_FORBIDDEN`: token lacks required scopes/permissions
514
+ - `MAPBOX_RATE_LIMITED`: Mapbox rate limit reached
515
+ - `ROUTE_FETCH_FAILED`: route request failed with native details
516
+ - `CURRENT_LOCATION_UNAVAILABLE`: unable to resolve device location
517
+ - `INVALID_COORDINATES`: invalid origin/destination coordinates
518
+
519
+ Subscribe via `addErrorListener` or `onError` to surface these to developers during testing and production diagnostics.
520
+
142
521
  ## Platform Notes
143
522
 
144
523
  - Android: `startOrigin` is optional (current location is supported).
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`)
@@ -8,7 +8,10 @@ import android.os.Handler
8
8
  import android.os.Looper
9
9
  import android.util.Log
10
10
  import android.view.Gravity
11
+ import android.view.ViewGroup
12
+ import android.widget.Button
11
13
  import android.widget.FrameLayout
14
+ import android.widget.LinearLayout
12
15
  import android.widget.TextView
13
16
  import androidx.appcompat.app.AppCompatDelegate
14
17
  import androidx.appcompat.app.AppCompatActivity
@@ -95,7 +98,7 @@ class MapboxNavigationActivity : AppCompatActivity() {
95
98
  result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
96
99
 
97
100
  if (!granted) {
98
- showErrorAndStay("Location permission is required to start navigation.", null)
101
+ showErrorScreen("Location permission is required to start navigation.", null)
99
102
  return@registerForActivityResult
100
103
  }
101
104
 
@@ -112,6 +115,15 @@ class MapboxNavigationActivity : AppCompatActivity() {
112
115
 
113
116
  override fun onRouteFetchFailed(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
114
117
  Log.e(TAG, "Route fetch failed. reasons=${reasons.size}, options=$routeOptions")
118
+ val (code, message) = mapRouteFetchFailure(reasons)
119
+ MapboxNavigationEventBridge.emit(
120
+ "onError",
121
+ mapOf(
122
+ "code" to code,
123
+ "message" to message
124
+ )
125
+ )
126
+ showErrorScreen(message, null)
115
127
  }
116
128
 
117
129
  override fun onRouteFetchSuccessful(routes: List<NavigationRoute>) {
@@ -127,6 +139,13 @@ class MapboxNavigationActivity : AppCompatActivity() {
127
139
 
128
140
  override fun onRouteFetchCanceled(routeOptions: RouteOptions, routerOrigin: RouterOrigin) {
129
141
  Log.w(TAG, "Route fetch canceled. origin=$routerOrigin, options=$routeOptions")
142
+ MapboxNavigationEventBridge.emit(
143
+ "onError",
144
+ mapOf(
145
+ "code" to "ROUTE_FETCH_CANCELED",
146
+ "message" to "Route fetch canceled (origin: $routerOrigin)."
147
+ )
148
+ )
130
149
  }
131
150
  }
132
151
 
@@ -166,7 +185,7 @@ class MapboxNavigationActivity : AppCompatActivity() {
166
185
  Log.w(TAG, "No valid destination extras provided, starting without preview")
167
186
  }
168
187
  } catch (throwable: Throwable) {
169
- showErrorAndStay("Navigation init failed: ${throwable.message}", throwable)
188
+ showErrorScreen("Navigation init failed: ${throwable.message}", throwable)
170
189
  return
171
190
  }
172
191
 
@@ -256,7 +275,7 @@ class MapboxNavigationActivity : AppCompatActivity() {
256
275
  }
257
276
 
258
277
  } catch (throwable: Throwable) {
259
- showErrorAndStay("Failed to create NavigationView: ${throwable.message}", throwable)
278
+ showErrorScreen("Failed to create NavigationView: ${throwable.message}", throwable)
260
279
  }
261
280
  }
262
281
 
@@ -301,6 +320,25 @@ class MapboxNavigationActivity : AppCompatActivity() {
301
320
  }
302
321
  }
303
322
 
323
+ private fun mapRouteFetchFailure(reasons: List<RouterFailure>): Pair<String, String> {
324
+ val details = reasons.joinToString(" | ") { it.message.orEmpty() }.trim()
325
+ if (details.contains("401") || details.contains("unauthorized", ignoreCase = true)) {
326
+ return "MAPBOX_TOKEN_INVALID" to
327
+ "Route fetch failed: unauthorized. Check EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN and token scopes."
328
+ }
329
+ if (details.contains("403") || details.contains("forbidden", ignoreCase = true)) {
330
+ return "MAPBOX_TOKEN_FORBIDDEN" to
331
+ "Route fetch failed: access forbidden. Verify token scopes and account permissions."
332
+ }
333
+ if (details.contains("429") || details.contains("rate", ignoreCase = true)) {
334
+ return "MAPBOX_RATE_LIMITED" to
335
+ "Route fetch failed: rate limited by Mapbox."
336
+ }
337
+
338
+ return "ROUTE_FETCH_FAILED" to
339
+ (if (details.isNotEmpty()) "Route fetch failed: $details" else "Route fetch failed for unknown reason.")
340
+ }
341
+
304
342
  private fun hasLocationPermission(): Boolean {
305
343
  val fineGranted = ContextCompat.checkSelfPermission(
306
344
  this,
@@ -315,23 +353,73 @@ class MapboxNavigationActivity : AppCompatActivity() {
315
353
  return fineGranted || coarseGranted
316
354
  }
317
355
 
318
- private fun showErrorAndStay(message: String, throwable: Throwable?) {
356
+ private fun showErrorScreen(message: String, throwable: Throwable?) {
319
357
  if (throwable != null) {
320
358
  Log.e(TAG, message, throwable)
321
359
  } else {
322
360
  Log.e(TAG, message)
323
361
  }
324
362
 
325
- val errorView = TextView(this).apply {
363
+ MapboxNavigationEventBridge.emit(
364
+ "onError",
365
+ mapOf(
366
+ "code" to "NATIVE_ERROR",
367
+ "message" to message
368
+ )
369
+ )
370
+
371
+ val titleView = TextView(this).apply {
372
+ text = "Navigation Error"
373
+ textSize = 22f
374
+ setTextColor(0xFFFFFFFF.toInt())
375
+ gravity = Gravity.CENTER
376
+ }
377
+
378
+ val messageView = TextView(this).apply {
326
379
  text = message
380
+ textSize = 15f
381
+ setTextColor(0xFFD6E4FF.toInt())
327
382
  gravity = Gravity.CENTER
328
- textSize = 16f
329
- setPadding(32, 32, 32, 32)
383
+ setPadding(0, 24, 0, 32)
330
384
  }
385
+
386
+ val closeButton = Button(this).apply {
387
+ text = "Back"
388
+ setOnClickListener { finish() }
389
+ }
390
+
391
+ val content = LinearLayout(this).apply {
392
+ orientation = LinearLayout.VERTICAL
393
+ gravity = Gravity.CENTER
394
+ setBackgroundColor(0xFF0B1020.toInt())
395
+ setPadding(48, 48, 48, 48)
396
+ addView(
397
+ titleView,
398
+ LinearLayout.LayoutParams(
399
+ ViewGroup.LayoutParams.MATCH_PARENT,
400
+ ViewGroup.LayoutParams.WRAP_CONTENT
401
+ )
402
+ )
403
+ addView(
404
+ messageView,
405
+ LinearLayout.LayoutParams(
406
+ ViewGroup.LayoutParams.MATCH_PARENT,
407
+ ViewGroup.LayoutParams.WRAP_CONTENT
408
+ )
409
+ )
410
+ addView(
411
+ closeButton,
412
+ LinearLayout.LayoutParams(
413
+ ViewGroup.LayoutParams.MATCH_PARENT,
414
+ ViewGroup.LayoutParams.WRAP_CONTENT
415
+ )
416
+ )
417
+ }
418
+
331
419
  setContentView(
332
420
  FrameLayout(this).apply {
333
421
  addView(
334
- errorView,
422
+ content,
335
423
  FrameLayout.LayoutParams(
336
424
  FrameLayout.LayoutParams.MATCH_PARENT,
337
425
  FrameLayout.LayoutParams.MATCH_PARENT
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;