@jacques_gordon/expo-mapbox-navigation 2.0.7 → 2.0.9

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.
@@ -1,51 +1,25 @@
1
1
  apply plugin: 'com.android.library'
2
2
  apply plugin: 'kotlin-android'
3
- apply plugin: 'maven-publish'
4
3
 
5
- // ─────────────────────────────────────────────────────────────────────────────
6
- // Mapbox Navigation SDK version used by this module.
7
- // Must be aligned with the Mapbox Maps SDK version used in @rnmapbox/maps.
8
- // Minimum required: 3.x compatible with mapbox-maps-android 11.11.0
9
- // ─────────────────────────────────────────────────────────────────────────────
10
- // Navigation SDK v3 uses com.mapbox.navigationcore (NOT com.mapbox.navigation)
11
- // v3.8.1 is the stable release that ships with Maps SDK 11.11.0
12
- def MAPBOX_NAVIGATION_VERSION = "3.8.1"
13
-
14
- // Forces minimum Mapbox Maps Android version to 11.11.0 to ensure
15
- // CameraAnimationsUtils.calculateCameraAnimationHint(...) is available (fixes #43)
4
+ def MAPBOX_NAV_VERSION = "3.8.1"
16
5
  def MAPBOX_MAPS_MIN_VERSION = "11.11.0"
17
-
18
- // ─────────────────────────────────────────────────────────────────────────────
19
- // NDK 27 — required for 16 KB page size compatibility (Android 15+)
20
- // See: https://developer.android.com/guide/practices/page-sizes
21
- // NDK 27 added full support for 16 KB page alignment via -DALIGN_16KB flag
22
- // ─────────────────────────────────────────────────────────────────────────────
23
6
  def REQUIRED_NDK_VERSION = "27.0.12077973"
24
7
 
25
8
  android {
26
9
  compileSdkVersion safeExtGet("compileSdkVersion", 35)
27
10
  namespace "expo.modules.mapboxnavigation"
28
-
29
- // Force NDK 27 for 16 KB page size compatibility
30
11
  ndkVersion REQUIRED_NDK_VERSION
31
12
 
32
13
  defaultConfig {
33
14
  minSdkVersion safeExtGet("minSdkVersion", 24)
34
15
  targetSdkVersion safeExtGet("targetSdkVersion", 35)
35
16
 
36
- // ─────────────────────────────────────────────────────────────────
37
- // 16 KB PAGE SIZE SUPPORT (Android 15 / API 35+)
38
- // Required for Google Play compliance starting Feb 2025 (64-bit apps)
39
- // This ensures native .so libraries are aligned to 16 KB boundaries.
40
- // ─────────────────────────────────────────────────────────────────
41
17
  externalNativeBuild {
42
18
  cmake {
43
19
  arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
44
20
  }
45
21
  }
46
-
47
22
  ndk {
48
- // Only build for 64-bit ABIs — 16 KB page size requirement is 64-bit only
49
23
  abiFilters "arm64-v8a", "x86_64"
50
24
  }
51
25
  }
@@ -60,8 +34,6 @@ android {
60
34
  }
61
35
 
62
36
  packagingOptions {
63
- // Ensure .so files are NOT compressed — required for 16 KB page size support
64
- // Compressed .so files cannot be aligned in the APK
65
37
  jniLibs {
66
38
  useLegacyPackaging = false
67
39
  }
@@ -75,13 +47,9 @@ android {
75
47
  repositories {
76
48
  mavenCentral()
77
49
  google()
78
-
79
- // Mapbox Maven repository — requires a secret token (sk.*)
80
50
  maven {
81
51
  url 'https://api.mapbox.com/downloads/v2/releases/maven'
82
- authentication {
83
- basic(BasicAuthentication)
84
- }
52
+ authentication { basic(BasicAuthentication) }
85
53
  credentials {
86
54
  username = 'mapbox'
87
55
  password = project.hasProperty('MAPBOX_DOWNLOADS_TOKEN')
@@ -95,22 +63,26 @@ dependencies {
95
63
  implementation project(':expo-modules-core')
96
64
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${safeExtGet('kotlinVersion', '1.9.0')}"
97
65
 
98
- // ─────────────────────────────────────────────────────────────────────────
99
- // Mapbox Navigation SDK v3 for Android
100
- // Group changed from com.mapbox.navigation to com.mapbox.navigationcore in v3
101
- // v3.8.1 ships with mapbox-maps-android 11.11.0 (fixes issue #43)
102
- // ─────────────────────────────────────────────────────────────────────────
103
- implementation "com.mapbox.navigationcore:android:${MAPBOX_NAVIGATION_VERSION}"
66
+ // Mapbox Navigation SDK v3 core
67
+ implementation "com.mapbox.navigationcore:android:${MAPBOX_NAV_VERSION}"
104
68
 
105
- // Explicitly pin Mapbox Maps SDK to ≥ 11.11.0 to guarantee method compatibility
106
- // Prevents gradle from resolving an older transitive version (issue #43 root cause)
69
+ // Mapbox Maps SDK (pinned to minimum version)
107
70
  implementation("com.mapbox.maps:android:${MAPBOX_MAPS_MIN_VERSION}") {
108
71
  transitive = true
109
72
  }
110
73
 
111
- // Lifecycle support
74
+ // ── Navigation UI Components (Waze-style UI) ─────────────────────────────
75
+ // MapboxManeuverView — turn-by-turn banner
76
+ implementation "com.mapbox.navigationcore:ui-components:${MAPBOX_NAV_VERSION}"
77
+ // MapboxSpeedInfoView — speed limit + current speed
78
+ implementation "com.mapbox.navigationcore:tripdata:${MAPBOX_NAV_VERSION}"
79
+ // Trip progress API — ETA, distance, duration
80
+ implementation "com.mapbox.navigationcore:ui-maps:${MAPBOX_NAV_VERSION}"
81
+
82
+ // AndroidX
112
83
  implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
113
84
  implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
85
+ implementation 'androidx.core:core-ktx:1.13.1'
114
86
  }
115
87
 
116
88
  def safeExtGet(prop, fallback) {
@@ -8,6 +8,7 @@ class ExpoMapboxNavigationModule : Module() {
8
8
  Name("ExpoMapboxNavigation")
9
9
 
10
10
  View(ExpoMapboxNavigationView::class) {
11
+ // Events must be declared here — they correspond to EventDispatcher fields in the View
11
12
  Events(
12
13
  "onRouteProgressChanged",
13
14
  "onRoutesReady",
@@ -17,70 +18,42 @@ class ExpoMapboxNavigationModule : Module() {
17
18
  "onArrival"
18
19
  )
19
20
 
20
- // Coordinates: Array of {latitude, longitude} objects
21
21
  Prop("coordinates") { view: ExpoMapboxNavigationView, coordinates: List<Map<String, Double>> ->
22
22
  view.setCoordinates(coordinates)
23
23
  }
24
-
25
- // Waypoint indices
26
24
  Prop("waypointIndices") { view: ExpoMapboxNavigationView, indices: List<Int>? ->
27
25
  view.setWaypointIndices(indices)
28
26
  }
29
-
30
- // Language/locale
31
27
  Prop("language") { view: ExpoMapboxNavigationView, language: String? ->
32
28
  view.setLanguage(language)
33
29
  }
34
-
35
- // ─────────────────────────────────────────────────────────────
36
- // voiceUnits prop — fixes Issue #31 (metric/imperial selection)
37
- // Accepts: "metric" | "imperial" | null (auto from locale)
38
- // ─────────────────────────────────────────────────────────────
39
30
  Prop("voiceUnits") { view: ExpoMapboxNavigationView, units: String? ->
40
31
  view.setVoiceUnits(units)
41
32
  }
42
-
43
- // Navigation profile
44
33
  Prop("navigationProfile") { view: ExpoMapboxNavigationView, profile: String? ->
45
34
  view.setNavigationProfile(profile)
46
35
  }
47
-
48
- // Excluded road types
49
36
  Prop("excludeTypes") { view: ExpoMapboxNavigationView, types: List<String>? ->
50
37
  view.setExcludeTypes(types)
51
38
  }
52
-
53
- // Map style URL
54
39
  Prop("mapStyle") { view: ExpoMapboxNavigationView, style: String? ->
55
40
  view.setMapStyle(style)
56
41
  }
57
-
58
- // Mute navigation audio
59
42
  Prop("mute") { view: ExpoMapboxNavigationView, mute: Boolean ->
60
43
  view.setMute(mute)
61
44
  }
62
-
63
- // Vehicle height restriction (meters)
64
45
  Prop("maxHeight") { view: ExpoMapboxNavigationView, height: Double? ->
65
46
  view.setMaxHeight(height)
66
47
  }
67
-
68
- // Vehicle width restriction (meters)
69
48
  Prop("maxWidth") { view: ExpoMapboxNavigationView, width: Double? ->
70
49
  view.setMaxWidth(width)
71
50
  }
72
-
73
- // Map matching mode
74
51
  Prop("useMapMatching") { view: ExpoMapboxNavigationView, use: Boolean ->
75
52
  view.setUseMapMatching(use)
76
53
  }
77
-
78
- // Custom raster tile URL
79
54
  Prop("customRasterTileUrl") { view: ExpoMapboxNavigationView, url: String? ->
80
55
  view.setCustomRasterTileUrl(url)
81
56
  }
82
-
83
- // Layer ID above which custom raster goes
84
57
  Prop("customRasterAboveLayerId") { view: ExpoMapboxNavigationView, layerId: String? ->
85
58
  view.setCustomRasterAboveLayerId(layerId)
86
59
  }
@@ -2,14 +2,23 @@ package expo.modules.mapboxnavigation
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
+ import android.graphics.Color
6
+ import android.os.Build
5
7
  import android.util.Log
8
+ import android.view.Gravity
9
+ import android.view.View
10
+ import android.widget.FrameLayout
11
+ import android.widget.LinearLayout
12
+ import android.widget.TextView
13
+ import androidx.core.content.ContextCompat
6
14
  import com.mapbox.api.directions.v5.models.RouteOptions
7
15
  import com.mapbox.geojson.Point
8
16
  import com.mapbox.maps.MapView
9
17
  import com.mapbox.maps.Style
10
- import com.mapbox.maps.plugin.animation.camera // ← correct import for .camera extension
18
+ import com.mapbox.maps.plugin.animation.camera
11
19
  import com.mapbox.maps.plugin.locationcomponent.location
12
20
  import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
21
+ import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
13
22
  import com.mapbox.navigation.base.options.NavigationOptions
14
23
  import com.mapbox.navigation.base.route.NavigationRoute
15
24
  import com.mapbox.navigation.base.route.NavigationRouterCallback
@@ -19,16 +28,33 @@ import com.mapbox.navigation.core.MapboxNavigation
19
28
  import com.mapbox.navigation.core.MapboxNavigationProvider
20
29
  import com.mapbox.navigation.core.directions.session.RoutesObserver
21
30
  import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult
31
+ import com.mapbox.navigation.core.trip.session.LocationMatcherResult
32
+ import com.mapbox.navigation.core.trip.session.LocationObserver
22
33
  import com.mapbox.navigation.core.trip.session.RouteProgressObserver
34
+ import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
35
+ import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
36
+ import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
37
+ import com.mapbox.navigation.tripdata.progress.model.EstimatedTimeToArrivalFormatter
38
+ import com.mapbox.navigation.tripdata.progress.model.PercentDistanceTraveledFormatter
39
+ import com.mapbox.navigation.tripdata.progress.model.TimeRemainingFormatter
40
+ import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateFormatter
41
+ import com.mapbox.navigation.tripdata.speedlimit.api.MapboxSpeedInfoApi
42
+ import com.mapbox.navigation.ui.components.maneuver.view.MapboxManeuverView
43
+ import com.mapbox.navigation.ui.components.speedlimit.view.MapboxSpeedInfoView
44
+ import com.mapbox.navigation.ui.components.tripprogress.view.MapboxTripProgressView
45
+ import com.mapbox.navigation.ui.maps.NavigationStyles
23
46
  import com.mapbox.navigation.ui.maps.camera.NavigationCamera
24
47
  import com.mapbox.navigation.ui.maps.camera.data.MapboxNavigationViewportDataSource
25
48
  import com.mapbox.navigation.ui.maps.camera.transition.NavigationCameraTransitionOptions
49
+ import com.mapbox.navigation.ui.maps.location.NavigationLocationProvider
26
50
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
27
51
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
28
52
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
29
53
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
30
54
  import expo.modules.kotlin.AppContext
55
+ import expo.modules.kotlin.viewevent.EventDispatcher
31
56
  import expo.modules.kotlin.views.ExpoView
57
+ import java.util.Calendar
32
58
  import java.util.Locale
33
59
 
34
60
  private const val TAG = "ExpoMapboxNavigation"
@@ -36,14 +62,42 @@ private const val TAG = "ExpoMapboxNavigation"
36
62
  class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
37
63
  ExpoView(context, appContext) {
38
64
 
65
+ // ── EventDispatchers ──────────────────────────────────────────────────────
66
+ private val onRouteProgressChanged by EventDispatcher()
67
+ private val onRoutesReady by EventDispatcher()
68
+ private val onNavigationFinished by EventDispatcher()
69
+ private val onNavigationCancelled by EventDispatcher()
70
+ private val onRoutesFailed by EventDispatcher()
71
+ private val onArrival by EventDispatcher()
72
+
73
+ // ── Core views ────────────────────────────────────────────────────────────
39
74
  private val mapView: MapView = MapView(context)
75
+
76
+ // ── Overlay UI views (Waze-style) ─────────────────────────────────────────
77
+ private lateinit var maneuverView: MapboxManeuverView
78
+ private lateinit var speedInfoView: MapboxSpeedInfoView
79
+ private lateinit var tripProgressView: MapboxTripProgressView
80
+ private lateinit var etaBottomBar: LinearLayout
81
+ private lateinit var tvEtaTime: TextView
82
+ private lateinit var tvDuration: TextView
83
+ private lateinit var tvDistance: TextView
84
+ private lateinit var btnCancel: TextView
85
+
86
+ // ── Navigation APIs ───────────────────────────────────────────────────────
40
87
  private var navigationCamera: NavigationCamera? = null
41
88
  private var viewportDataSource: MapboxNavigationViewportDataSource? = null
42
89
  private var routeLineApi: MapboxRouteLineApi? = null
43
90
  private var routeLineView: MapboxRouteLineView? = null
44
91
  private var mapboxNavigation: MapboxNavigation? = null
92
+ private var maneuverApi: MapboxManeuverApi? = null
93
+ private var speedInfoApi: MapboxSpeedInfoApi? = null
94
+ private var tripProgressApi: MapboxTripProgressApi? = null
95
+ private val navigationLocationProvider = NavigationLocationProvider()
45
96
 
46
- // Props
97
+ // ── Night mode state ──────────────────────────────────────────────────────
98
+ private var isNightMode = false
99
+
100
+ // ── Props ─────────────────────────────────────────────────────────────────
47
101
  private var coordinates: List<Map<String, Double>> = emptyList()
48
102
  private var waypointIndices: List<Int>? = null
49
103
  private var language: String? = null
@@ -59,35 +113,177 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
59
113
  private var customRasterAboveLayerId: String? = null
60
114
 
61
115
  init {
62
- mapView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
63
- addView(mapView)
116
+ buildOverlayUI()
64
117
  setupNavigation()
65
118
  }
66
119
 
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+ // Build the Waze-style overlay UI programmatically
122
+ // ─────────────────────────────────────────────────────────────────────────
123
+ private fun buildOverlayUI() {
124
+ val root = FrameLayout(context).apply {
125
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
126
+ }
127
+
128
+ // MapView full screen
129
+ mapView.layoutParams = FrameLayout.LayoutParams(
130
+ FrameLayout.LayoutParams.MATCH_PARENT,
131
+ FrameLayout.LayoutParams.MATCH_PARENT
132
+ )
133
+ root.addView(mapView)
134
+
135
+ val dp = context.resources.displayMetrics.density
136
+
137
+ // ── Maneuver banner (top) ─────────────────────────────────────────────
138
+ maneuverView = MapboxManeuverView(context).apply {
139
+ layoutParams = FrameLayout.LayoutParams(
140
+ FrameLayout.LayoutParams.MATCH_PARENT,
141
+ FrameLayout.LayoutParams.WRAP_CONTENT
142
+ ).also { it.gravity = Gravity.TOP }
143
+ visibility = View.GONE
144
+ }
145
+ root.addView(maneuverView)
146
+
147
+ // ── Speed info (bottom-left, Waze style) ──────────────────────────────
148
+ speedInfoView = MapboxSpeedInfoView(context).apply {
149
+ layoutParams = FrameLayout.LayoutParams(
150
+ FrameLayout.LayoutParams.WRAP_CONTENT,
151
+ FrameLayout.LayoutParams.WRAP_CONTENT
152
+ ).also {
153
+ it.gravity = Gravity.BOTTOM or Gravity.START
154
+ it.setMargins(
155
+ (16 * dp).toInt(), 0,
156
+ 0, (120 * dp).toInt()
157
+ )
158
+ }
159
+ visibility = View.GONE
160
+ }
161
+ root.addView(speedInfoView)
162
+
163
+ // ── ETA bottom bar ────────────────────────────────────────────────────
164
+ etaBottomBar = LinearLayout(context).apply {
165
+ orientation = LinearLayout.HORIZONTAL
166
+ setBackgroundColor(Color.WHITE)
167
+ elevation = 8 * dp
168
+ layoutParams = FrameLayout.LayoutParams(
169
+ FrameLayout.LayoutParams.MATCH_PARENT,
170
+ (80 * dp).toInt()
171
+ ).also { it.gravity = Gravity.BOTTOM }
172
+ gravity = Gravity.CENTER_VERTICAL
173
+ setPadding(
174
+ (16 * dp).toInt(), (8 * dp).toInt(),
175
+ (16 * dp).toInt(), (8 * dp).toInt()
176
+ )
177
+ visibility = View.GONE
178
+ }
179
+
180
+ // ETA time
181
+ tvEtaTime = TextView(context).apply {
182
+ textSize = 22f
183
+ setTextColor(Color.BLACK)
184
+ typeface = android.graphics.Typeface.DEFAULT_BOLD
185
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
186
+ }
187
+ etaBottomBar.addView(tvEtaTime)
188
+
189
+ // Duration + Distance (center)
190
+ val centerLayout = LinearLayout(context).apply {
191
+ orientation = LinearLayout.VERTICAL
192
+ gravity = Gravity.CENTER
193
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
194
+ }
195
+ tvDuration = TextView(context).apply {
196
+ textSize = 16f
197
+ setTextColor(Color.DKGRAY)
198
+ gravity = Gravity.CENTER
199
+ }
200
+ tvDistance = TextView(context).apply {
201
+ textSize = 13f
202
+ setTextColor(Color.GRAY)
203
+ gravity = Gravity.CENTER
204
+ }
205
+ centerLayout.addView(tvDuration)
206
+ centerLayout.addView(tvDistance)
207
+ etaBottomBar.addView(centerLayout)
208
+
209
+ // Cancel button
210
+ btnCancel = TextView(context).apply {
211
+ text = "✕"
212
+ textSize = 22f
213
+ setTextColor(Color.DKGRAY)
214
+ gravity = Gravity.END or Gravity.CENTER_VERTICAL
215
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
216
+ setOnClickListener { cancelNavigation() }
217
+ }
218
+ etaBottomBar.addView(btnCancel)
219
+ root.addView(etaBottomBar)
220
+
221
+ addView(root)
222
+ }
223
+
224
+ // ─────────────────────────────────────────────────────────────────────────
225
+ // Setup Navigation
226
+ // ─────────────────────────────────────────────────────────────────────────
67
227
  private fun setupNavigation() {
68
228
  val navigationOptions = NavigationOptions.Builder(context).build()
69
229
  mapboxNavigation = MapboxNavigationProvider.create(navigationOptions)
70
230
 
71
- mapView.mapboxMap.loadStyle(mapStyle ?: Style.MAPBOX_STREETS) { style ->
72
- // Route line components
73
- routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
74
- routeLineView = MapboxRouteLineView(
75
- MapboxRouteLineViewOptions.Builder(context).build()
76
- )
77
-
78
- // NavigationCamera — exactly as in official Mapbox example:
79
- // NavigationCamera(mapboxMap, mapView.camera, viewportDataSource)
80
- viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
81
- navigationCamera = NavigationCamera(
82
- mapView.mapboxMap,
83
- mapView.camera, // ← mapView.camera (Maps plugin extension)
84
- viewportDataSource!!
85
- )
231
+ // Auto day/night style
232
+ val style = mapStyle ?: getAutoStyle()
86
233
 
234
+ mapView.mapboxMap.loadStyle(style) { _ ->
235
+ setupComponents()
87
236
  registerObservers()
88
237
  }
89
238
  }
90
239
 
240
+ private fun setupComponents() {
241
+ val mapboxMap = mapView.mapboxMap
242
+
243
+ // Route line
244
+ routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
245
+ routeLineView = MapboxRouteLineView(
246
+ MapboxRouteLineViewOptions.Builder(context)
247
+ .routeLineBelowLayerId("road-label-navigation")
248
+ .build()
249
+ )
250
+
251
+ // Camera
252
+ viewportDataSource = MapboxNavigationViewportDataSource(mapboxMap)
253
+ navigationCamera = NavigationCamera(
254
+ mapboxMap,
255
+ mapView.camera,
256
+ viewportDataSource!!
257
+ )
258
+
259
+ // Speed info API
260
+ speedInfoApi = MapboxSpeedInfoApi()
261
+
262
+ // Maneuver API
263
+ val formatter = DistanceFormatterOptions.Builder(context).build()
264
+ maneuverApi = MapboxManeuverApi(
265
+ com.mapbox.navigation.base.formatter.MapboxDistanceFormatter(formatter)
266
+ )
267
+
268
+ // Trip progress API
269
+ val progressFormatter = TripProgressUpdateFormatter.Builder(context)
270
+ .distanceRemainingFormatter(DistanceRemainingFormatter(formatter))
271
+ .timeRemainingFormatter(TimeRemainingFormatter(context))
272
+ .percentRouteTraveledFormatter(PercentDistanceTraveledFormatter())
273
+ .estimatedTimeToArrivalFormatter(EstimatedTimeToArrivalFormatter(context))
274
+ .build()
275
+ tripProgressApi = MapboxTripProgressApi(progressFormatter)
276
+
277
+ // Location puck
278
+ mapView.location.apply {
279
+ setLocationProvider(navigationLocationProvider)
280
+ enabled = true
281
+ }
282
+ }
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+ // Register all observers
286
+ // ─────────────────────────────────────────────────────────────────────────
91
287
  private fun registerObservers() {
92
288
  val nav = mapboxNavigation ?: return
93
289
 
@@ -101,14 +297,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
101
297
  routeLineView?.renderRouteDrawData(style, value)
102
298
  }
103
299
  }
104
- // Update viewport data source with the route
105
300
  viewportDataSource?.onRouteChanged(routes.first())
106
301
  viewportDataSource?.evaluate()
107
-
108
- // Issue #43 fix: safe camera following transition
109
302
  safeCameraFollowing()
110
303
 
111
- emitEvent("onRoutesReady", mapOf(
304
+ // Show UI overlays
305
+ maneuverView.visibility = View.VISIBLE
306
+ etaBottomBar.visibility = View.VISIBLE
307
+
308
+ onRoutesReady(mapOf(
112
309
  "routeCount" to routes.size,
113
310
  "distanceMeters" to (routes.first().directionsRoute.distance() ?: 0.0),
114
311
  "durationSeconds" to (routes.first().directionsRoute.duration() ?: 0.0)
@@ -121,17 +318,46 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
121
318
  }
122
319
  viewportDataSource?.clearRouteData()
123
320
  viewportDataSource?.evaluate()
321
+ hideNavigationUI()
124
322
  }
125
323
  }
126
324
  })
127
325
 
128
- // Route progress observer
326
+ // Route progress observer — maneuvers + ETA + speed limit
129
327
  nav.registerRouteProgressObserver(RouteProgressObserver { routeProgress ->
130
- // Update viewport data source with progress
131
328
  viewportDataSource?.onRouteProgressChanged(routeProgress)
132
329
  viewportDataSource?.evaluate()
133
330
 
134
- emitEvent("onRouteProgressChanged", mapOf(
331
+ // ── Maneuver instructions ──────────────────────────────────────
332
+ maneuverApi?.getManeuvers(routeProgress) { maneuverState ->
333
+ maneuverView.renderManeuvers(maneuverState)
334
+ }
335
+
336
+ // ── Trip progress (ETA, duration, distance) ────────────────────
337
+ tripProgressApi?.getTripProgress(routeProgress)?.let { tripProgress ->
338
+ val eta = tripProgress.currentLegTimeRemaining
339
+ val cal = Calendar.getInstance().apply {
340
+ add(Calendar.SECOND, eta.toInt())
341
+ }
342
+ val h = cal.get(Calendar.HOUR_OF_DAY)
343
+ val m = cal.get(Calendar.MINUTE)
344
+ tvEtaTime.text = String.format("%02d:%02d", h, m)
345
+
346
+ val totalMin = (tripProgress.totalTimeRemaining / 60).toInt()
347
+ tvDuration.text = if (totalMin >= 60)
348
+ "${totalMin / 60}h ${totalMin % 60}min"
349
+ else
350
+ "${totalMin} min"
351
+
352
+ val distKm = tripProgress.distanceRemaining / 1000.0
353
+ tvDistance.text = if (distKm >= 1.0)
354
+ String.format("%.1f km", distKm)
355
+ else
356
+ "${tripProgress.distanceRemaining.toInt()} m"
357
+ }
358
+
359
+ // ── Emit progress event ───────────────────────────────────────
360
+ onRouteProgressChanged(mapOf(
135
361
  "distanceRemaining" to routeProgress.distanceRemaining,
136
362
  "durationRemaining" to routeProgress.durationRemaining,
137
363
  "distanceTraveled" to routeProgress.distanceTraveled,
@@ -141,9 +367,110 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
141
367
  ?.currentStepProgress?.distanceRemaining ?: 0f)
142
368
  ))
143
369
  })
370
+
371
+ // Location observer — speed limit + location puck
372
+ nav.registerLocationObserver(object : LocationObserver {
373
+ override fun onNewRawLocation(rawLocation: com.mapbox.common.location.Location) {}
374
+
375
+ override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
376
+ val enhancedLocation = locationMatcherResult.enhancedLocation
377
+
378
+ // Update location puck
379
+ navigationLocationProvider.changePosition(
380
+ location = enhancedLocation,
381
+ keyPoints = locationMatcherResult.keyPoints,
382
+ )
383
+
384
+ // Update camera
385
+ viewportDataSource?.onLocationChanged(enhancedLocation)
386
+ viewportDataSource?.evaluate()
387
+
388
+ // ── Speed limit (issue — show posted + current speed) ──────
389
+ val formatter = DistanceFormatterOptions.Builder(context).build()
390
+ val info = speedInfoApi?.updatePostedAndCurrentSpeed(
391
+ locationMatcherResult,
392
+ formatter
393
+ )
394
+ if (info != null) {
395
+ speedInfoView.visibility = View.VISIBLE
396
+ speedInfoView.render(info)
397
+ } else {
398
+ speedInfoView.visibility = View.GONE
399
+ }
400
+
401
+ // ── Auto day/night switch ──────────────────────────────────
402
+ checkAndSwitchDayNight()
403
+ }
404
+ })
405
+ }
406
+
407
+ // ─────────────────────────────────────────────────────────────────────────
408
+ // Auto day/night mode
409
+ // ─────────────────────────────────────────────────────────────────────────
410
+ private fun getAutoStyle(): String {
411
+ val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
412
+ return if (hour in 6..20)
413
+ NavigationStyles.NAVIGATION_DAY_STYLE
414
+ else
415
+ NavigationStyles.NAVIGATION_NIGHT_STYLE
144
416
  }
145
417
 
146
- // ── Issue #43: safe camera transition ────────────────────────────────────
418
+ private fun checkAndSwitchDayNight() {
419
+ val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
420
+ val shouldBeNight = hour !in 6..20
421
+ if (shouldBeNight != isNightMode) {
422
+ isNightMode = shouldBeNight
423
+ val newStyle = if (shouldBeNight)
424
+ NavigationStyles.NAVIGATION_NIGHT_STYLE
425
+ else
426
+ NavigationStyles.NAVIGATION_DAY_STYLE
427
+
428
+ mapView.mapboxMap.loadStyle(newStyle) { _ ->
429
+ // Re-render route line on new style
430
+ mapView.mapboxMap.style?.let { style ->
431
+ routeLineApi?.let { api ->
432
+ mapboxNavigation?.getNavigationRoutes()?.let { routes ->
433
+ if (routes.isNotEmpty()) {
434
+ api.setNavigationRoutes(routes) { value ->
435
+ routeLineView?.renderRouteDrawData(style, value)
436
+ }
437
+ }
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ // Switch bottom bar color
444
+ etaBottomBar.setBackgroundColor(
445
+ if (shouldBeNight) Color.parseColor("#1E2433") else Color.WHITE
446
+ )
447
+ val textColor = if (shouldBeNight) Color.WHITE else Color.BLACK
448
+ tvEtaTime.setTextColor(textColor)
449
+ tvDuration.setTextColor(if (shouldBeNight) Color.LTGRAY else Color.DKGRAY)
450
+ tvDistance.setTextColor(if (shouldBeNight) Color.GRAY else Color.GRAY)
451
+ btnCancel.setTextColor(if (shouldBeNight) Color.LTGRAY else Color.DKGRAY)
452
+ }
453
+ }
454
+
455
+ // ─────────────────────────────────────────────────────────────────────────
456
+ // Cancel navigation
457
+ // ─────────────────────────────────────────────────────────────────────────
458
+ private fun cancelNavigation() {
459
+ mapboxNavigation?.setNavigationRoutes(emptyList())
460
+ mapboxNavigation?.stopTripSession()
461
+ hideNavigationUI()
462
+ onNavigationCancelled(mapOf<String, Any>())
463
+ }
464
+
465
+ private fun hideNavigationUI() {
466
+ maneuverView.visibility = View.GONE
467
+ speedInfoView.visibility = View.GONE
468
+ etaBottomBar.visibility = View.GONE
469
+ }
470
+
471
+ // ─────────────────────────────────────────────────────────────────────────
472
+ // Issue #43: safe camera
473
+ // ─────────────────────────────────────────────────────────────────────────
147
474
  private fun safeCameraFollowing() {
148
475
  try {
149
476
  navigationCamera?.requestNavigationCameraToFollowing(
@@ -152,24 +479,27 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
152
479
  .build()
153
480
  )
154
481
  } catch (e: Exception) {
155
- Log.e(TAG, "Camera transition error (issue #43): ${e.message}")
482
+ Log.e(TAG, "Camera transition error: ${e.message}")
156
483
  }
157
484
  }
158
485
 
159
- // ── Issue #31: voice units ────────────────────────────────────────────────
486
+ // ─────────────────────────────────────────────────────────────────────────
487
+ // Issue #31: voice units
488
+ // ─────────────────────────────────────────────────────────────────────────
160
489
  private fun resolveVoiceUnits(): String {
161
490
  return when (voiceUnits?.lowercase()) {
162
491
  "metric" -> "metric"
163
492
  "imperial" -> "imperial"
164
493
  else -> {
165
494
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
166
- val imperialCountries = setOf("US", "GB", "LR", "MM")
167
- if (locale.country in imperialCountries) "imperial" else "metric"
495
+ if (locale.country in setOf("US", "GB", "LR", "MM")) "imperial" else "metric"
168
496
  }
169
497
  }
170
498
  }
171
499
 
172
- // ── Route request ─────────────────────────────────────────────────────────
500
+ // ─────────────────────────────────────────────────────────────────────────
501
+ // Route request
502
+ // ─────────────────────────────────────────────────────────────────────────
173
503
  @SuppressLint("MissingPermission")
174
504
  private fun fetchRoutes() {
175
505
  val nav = mapboxNavigation ?: return
@@ -183,8 +513,10 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
183
513
  val builder = RouteOptions.builder()
184
514
  .applyDefaultNavigationOptions()
185
515
  .language(locale.toLanguageTag())
186
- .voiceUnits(resolveVoiceUnits()) // Issue #31 fix
516
+ .voiceUnits(resolveVoiceUnits())
187
517
  .coordinatesList(points)
518
+ // maxspeed annotation required for speed limit display
519
+ .annotations("maxspeed,congestion,duration")
188
520
 
189
521
  waypointIndices?.let { builder.waypointIndicesList(it) }
190
522
  navigationProfile?.let {
@@ -196,7 +528,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
196
528
  maxHeight?.let { builder.maxHeight(it) }
197
529
  maxWidth?.let { builder.maxWidth(it) }
198
530
 
199
- // Exact Nav SDK v3 callback signature from official Mapbox docs
200
531
  nav.requestRoutes(
201
532
  builder.build(),
202
533
  object : NavigationRouterCallback {
@@ -205,7 +536,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
205
536
  @RouterOrigin routerOrigin: String
206
537
  ) {
207
538
  if (routes.isEmpty()) {
208
- emitEvent("onRoutesFailed", mapOf("message" to "No routes returned"))
539
+ onRoutesFailed(mapOf("message" to "No routes returned"))
209
540
  return
210
541
  }
211
542
  nav.setNavigationRoutes(routes)
@@ -218,7 +549,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
218
549
  ) {
219
550
  val msg = reasons.firstOrNull()?.message ?: "Unknown error"
220
551
  Log.e(TAG, "Route request failed: $msg")
221
- emitEvent("onRoutesFailed", mapOf("message" to msg))
552
+ onRoutesFailed(mapOf("message" to msg))
222
553
  }
223
554
 
224
555
  override fun onCanceled(
@@ -231,17 +562,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
231
562
  )
232
563
  }
233
564
 
234
- // ── Expo event emitter (SDK 53 compatible) ────────────────────────────────
235
- private fun emitEvent(name: String, data: Map<String, Any>) {
236
- try {
237
- // Expo modules core SDK 53: use appContext.eventEmitter
238
- val emitter = appContext.eventEmitter
239
- emitter?.emit(name, data)
240
- } catch (e: Exception) {
241
- Log.e(TAG, "Failed to emit event $name: ${e.message}")
242
- }
243
- }
244
-
245
565
  // ── Prop setters ──────────────────────────────────────────────────────────
246
566
  fun setCoordinates(coords: List<Map<String, Double>>) {
247
567
  coordinates = coords
@@ -263,6 +583,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
263
583
  // ── Lifecycle ─────────────────────────────────────────────────────────────
264
584
  override fun onDetachedFromWindow() {
265
585
  super.onDetachedFromWindow()
586
+ maneuverApi?.cancel()
266
587
  routeLineApi?.cancel()
267
588
  routeLineView?.cancel()
268
589
  mapboxNavigation?.stopTripSession()
package/app.plugin.js CHANGED
@@ -4,8 +4,6 @@ const path = require('path');
4
4
 
5
5
  const NDK_VERSION = '27.0.12077973';
6
6
  const MAPBOX_MAPS_MIN_VERSION = '11.11.0';
7
- // Navigation SDK v3 uses com.mapbox.navigationcore group
8
- const MAPBOX_NAV_VERSION = '3.8.1';
9
7
 
10
8
  const withMapboxNavigation = (config, options = {}) => {
11
9
  const {
@@ -22,13 +20,11 @@ const withMapboxNavigation = (config, options = {}) => {
22
20
  );
23
21
  }
24
22
 
25
- // Validate mapboxMapsVersion >= 11.11.0
26
23
  const [major, minor] = mapboxMapsVersion.split('.').map(Number);
27
24
  if (major < 11 || (major === 11 && minor < 11)) {
28
25
  throw new Error(
29
26
  `[@jacques_gordon/expo-mapbox-navigation] mapboxMapsVersion must be >= 11.11.0.\n` +
30
- `Provided: ${mapboxMapsVersion}\n` +
31
- `This minimum version is required to fix the CameraAnimationsUtils crash (issue #43).`
27
+ `Provided: ${mapboxMapsVersion}`
32
28
  );
33
29
  }
34
30
 
@@ -36,7 +32,6 @@ const withMapboxNavigation = (config, options = {}) => {
36
32
  config = withProjectBuildGradle(config, (mod) => {
37
33
  let contents = mod.modResults.contents;
38
34
 
39
- // Add Mapbox Maven repo if not already present
40
35
  if (!contents.includes('api.mapbox.com/downloads/v2/releases/maven')) {
41
36
  const mavenBlock = `
42
37
  maven {
@@ -49,7 +44,6 @@ const withMapboxNavigation = (config, options = {}) => {
49
44
  : System.getenv('MAPBOX_DOWNLOADS_TOKEN') ?: ""
50
45
  }
51
46
  }`;
52
-
53
47
  if (contents.includes('allprojects') && contents.includes('repositories')) {
54
48
  contents = contents.replace(
55
49
  /allprojects\s*\{[\s\S]*?repositories\s*\{/,
@@ -58,7 +52,6 @@ const withMapboxNavigation = (config, options = {}) => {
58
52
  }
59
53
  }
60
54
 
61
-
62
55
  mod.modResults.contents = contents;
63
56
  return mod;
64
57
  });
@@ -67,37 +60,32 @@ const withMapboxNavigation = (config, options = {}) => {
67
60
  config = withAppBuildGradle(config, (mod) => {
68
61
  let contents = mod.modResults.contents;
69
62
 
70
- // Force NDK 27 for 16 KB page size
71
63
  if (!contents.includes('ndkVersion')) {
72
64
  contents = contents.replace(
73
65
  /android\s*\{/,
74
- `android {\n ndkVersion "${NDK_VERSION}" // Required for 16 KB page size (expo-mapbox-navigation)`
66
+ `android {\n ndkVersion "${NDK_VERSION}"`
75
67
  );
76
68
  } else if (!contents.includes(NDK_VERSION)) {
77
69
  contents = contents.replace(
78
70
  /ndkVersion\s+["'][^"']*["']/,
79
- `ndkVersion "${NDK_VERSION}" // Forced by expo-mapbox-navigation for 16KB page size`
71
+ `ndkVersion "${NDK_VERSION}"`
80
72
  );
81
73
  }
82
74
 
83
- // Enable useLegacyPackaging = false for 16 KB page alignment
84
75
  if (!contents.includes('useLegacyPackaging')) {
85
76
  contents = contents.replace(
86
77
  /android\s*\{/,
87
- `android {\n packagingOptions {\n jniLibs {\n useLegacyPackaging = false // 16 KB page size support\n }\n }`
78
+ `android {\n packagingOptions {\n jniLibs {\n useLegacyPackaging = false\n }\n }`
88
79
  );
89
80
  }
90
81
 
91
- // Pin Mapbox Maps + Navigation versions via resolutionStrategy
92
82
  const resolutionBlock = `
93
83
  configurations.all {
94
84
  resolutionStrategy {
95
- // Force Mapbox Maps SDK >= ${mapboxMapsVersion} — required for NavigationCamera compatibility (issue #43)
96
85
  force "com.mapbox.maps:android:${mapboxMapsVersion}"
97
86
  force "com.mapbox.maps:android-ndk27:${mapboxMapsVersion}"
98
87
  }
99
88
  }`;
100
-
101
89
  if (!contents.includes('com.mapbox.maps:android:')) {
102
90
  contents = contents + '\n' + resolutionBlock;
103
91
  }
@@ -106,31 +94,57 @@ configurations.all {
106
94
  return mod;
107
95
  });
108
96
 
109
- // ── Android: AndroidManifest — add access token as meta-data ─────────────
97
+ // ── Android: AndroidManifest ─────────────────────────────────────────────
110
98
  config = withAndroidManifest(config, (mod) => {
111
- const mainApp = mod.modResults.manifest.application?.[0];
112
- if (mainApp) {
113
- if (!mainApp['meta-data']) {
114
- mainApp['meta-data'] = [];
99
+ const manifest = mod.modResults.manifest;
100
+
101
+ // ── Permissions required by Mapbox Navigation SDK (targetSdk 35) ────────
102
+ //
103
+ // SecurityException: Starting FGS with type location requires:
104
+ // - android.permission.FOREGROUND_SERVICE_LOCATION (mandatory API 34+)
105
+ // - android.permission.FOREGROUND_SERVICE (mandatory)
106
+ // - android.permission.ACCESS_FINE_LOCATION (at least one of coarse/fine)
107
+ // - android.permission.ACCESS_COARSE_LOCATION
108
+ //
109
+ const requiredPermissions = [
110
+ 'android.permission.ACCESS_FINE_LOCATION',
111
+ 'android.permission.ACCESS_COARSE_LOCATION',
112
+ 'android.permission.FOREGROUND_SERVICE',
113
+ 'android.permission.FOREGROUND_SERVICE_LOCATION', // ← fixes the crash
114
+ 'android.permission.POST_NOTIFICATIONS', // needed for nav notification (API 33+)
115
+ ];
116
+
117
+ if (!manifest['uses-permission']) {
118
+ manifest['uses-permission'] = [];
119
+ }
120
+
121
+ for (const permission of requiredPermissions) {
122
+ const already = manifest['uses-permission'].some(
123
+ (p) => p.$?.['android:name'] === permission
124
+ );
125
+ if (!already) {
126
+ manifest['uses-permission'].push({ $: { 'android:name': permission } });
115
127
  }
128
+ }
116
129
 
130
+ // ── Mapbox access token meta-data ────────────────────────────────────────
131
+ const mainApp = manifest.application?.[0];
132
+ if (mainApp) {
133
+ if (!mainApp['meta-data']) mainApp['meta-data'] = [];
117
134
  const tokenMeta = mainApp['meta-data'].find(
118
135
  (m) => m.$?.['android:name'] === 'com.mapbox.token'
119
136
  );
120
-
121
137
  if (!tokenMeta) {
122
138
  mainApp['meta-data'].push({
123
- $: {
124
- 'android:name': 'com.mapbox.token',
125
- 'android:value': accessToken,
126
- },
139
+ $: { 'android:name': 'com.mapbox.token', 'android:value': accessToken },
127
140
  });
128
141
  }
129
142
  }
143
+
130
144
  return mod;
131
145
  });
132
146
 
133
- // ── Android: Write color overrides resource file ──────────────────────────
147
+ // ── Android: color overrides ─────────────────────────────────────────────
134
148
  if (Object.keys(androidColorOverrides).length > 0) {
135
149
  config = withDangerousMod(config, [
136
150
  'android',
@@ -139,27 +153,16 @@ configurations.all {
139
153
  mod.modRequest.platformProjectRoot,
140
154
  'app/src/main/res/values'
141
155
  );
142
-
143
- if (!fs.existsSync(colorsDir)) {
144
- fs.mkdirSync(colorsDir, { recursive: true });
145
- }
156
+ if (!fs.existsSync(colorsDir)) fs.mkdirSync(colorsDir, { recursive: true });
146
157
 
147
158
  const colorEntries = Object.entries(androidColorOverrides)
148
159
  .map(([name, value]) => ` <color name="${name}">${value}</color>`)
149
160
  .join('\n');
150
161
 
151
- const colorsXml = `<?xml version="1.0" encoding="utf-8"?>
152
- <!-- Generated by @jacques_gordon/expo-mapbox-navigation config plugin -->
153
- <resources>
154
- ${colorEntries}
155
- </resources>
156
- `;
157
-
158
162
  fs.writeFileSync(
159
163
  path.join(colorsDir, 'mapbox_navigation_colors.xml'),
160
- colorsXml
164
+ `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${colorEntries}\n</resources>\n`
161
165
  );
162
-
163
166
  return mod;
164
167
  },
165
168
  ]);
@@ -177,16 +180,12 @@ ${colorEntries}
177
180
  mod.modResults.NSLocationAlwaysAndWhenInUseUsageDescription =
178
181
  'Your location is used for turn-by-turn navigation even when the app is in the background.';
179
182
  }
180
-
181
- if (!mod.modResults.UIBackgroundModes) {
182
- mod.modResults.UIBackgroundModes = [];
183
- }
183
+ if (!mod.modResults.UIBackgroundModes) mod.modResults.UIBackgroundModes = [];
184
184
  for (const mode of ['audio', 'location']) {
185
185
  if (!mod.modResults.UIBackgroundModes.includes(mode)) {
186
186
  mod.modResults.UIBackgroundModes.push(mode);
187
187
  }
188
188
  }
189
-
190
189
  return mod;
191
190
  });
192
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "Expo module for Mapbox Navigation SDK with 16KB page size support, NDK27, and Mapbox Maps v11.11.0+",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -4,8 +4,6 @@ const path = require('path');
4
4
 
5
5
  const NDK_VERSION = '27.0.12077973';
6
6
  const MAPBOX_MAPS_MIN_VERSION = '11.11.0';
7
- // Navigation SDK v3 uses com.mapbox.navigationcore group
8
- const MAPBOX_NAV_VERSION = '3.8.1';
9
7
 
10
8
  const withMapboxNavigation = (config, options = {}) => {
11
9
  const {
@@ -22,13 +20,11 @@ const withMapboxNavigation = (config, options = {}) => {
22
20
  );
23
21
  }
24
22
 
25
- // Validate mapboxMapsVersion >= 11.11.0
26
23
  const [major, minor] = mapboxMapsVersion.split('.').map(Number);
27
24
  if (major < 11 || (major === 11 && minor < 11)) {
28
25
  throw new Error(
29
26
  `[@jacques_gordon/expo-mapbox-navigation] mapboxMapsVersion must be >= 11.11.0.\n` +
30
- `Provided: ${mapboxMapsVersion}\n` +
31
- `This minimum version is required to fix the CameraAnimationsUtils crash (issue #43).`
27
+ `Provided: ${mapboxMapsVersion}`
32
28
  );
33
29
  }
34
30
 
@@ -36,7 +32,6 @@ const withMapboxNavigation = (config, options = {}) => {
36
32
  config = withProjectBuildGradle(config, (mod) => {
37
33
  let contents = mod.modResults.contents;
38
34
 
39
- // Add Mapbox Maven repo if not already present
40
35
  if (!contents.includes('api.mapbox.com/downloads/v2/releases/maven')) {
41
36
  const mavenBlock = `
42
37
  maven {
@@ -49,7 +44,6 @@ const withMapboxNavigation = (config, options = {}) => {
49
44
  : System.getenv('MAPBOX_DOWNLOADS_TOKEN') ?: ""
50
45
  }
51
46
  }`;
52
-
53
47
  if (contents.includes('allprojects') && contents.includes('repositories')) {
54
48
  contents = contents.replace(
55
49
  /allprojects\s*\{[\s\S]*?repositories\s*\{/,
@@ -58,7 +52,6 @@ const withMapboxNavigation = (config, options = {}) => {
58
52
  }
59
53
  }
60
54
 
61
-
62
55
  mod.modResults.contents = contents;
63
56
  return mod;
64
57
  });
@@ -67,37 +60,32 @@ const withMapboxNavigation = (config, options = {}) => {
67
60
  config = withAppBuildGradle(config, (mod) => {
68
61
  let contents = mod.modResults.contents;
69
62
 
70
- // Force NDK 27 for 16 KB page size
71
63
  if (!contents.includes('ndkVersion')) {
72
64
  contents = contents.replace(
73
65
  /android\s*\{/,
74
- `android {\n ndkVersion "${NDK_VERSION}" // Required for 16 KB page size (expo-mapbox-navigation)`
66
+ `android {\n ndkVersion "${NDK_VERSION}"`
75
67
  );
76
68
  } else if (!contents.includes(NDK_VERSION)) {
77
69
  contents = contents.replace(
78
70
  /ndkVersion\s+["'][^"']*["']/,
79
- `ndkVersion "${NDK_VERSION}" // Forced by expo-mapbox-navigation for 16KB page size`
71
+ `ndkVersion "${NDK_VERSION}"`
80
72
  );
81
73
  }
82
74
 
83
- // Enable useLegacyPackaging = false for 16 KB page alignment
84
75
  if (!contents.includes('useLegacyPackaging')) {
85
76
  contents = contents.replace(
86
77
  /android\s*\{/,
87
- `android {\n packagingOptions {\n jniLibs {\n useLegacyPackaging = false // 16 KB page size support\n }\n }`
78
+ `android {\n packagingOptions {\n jniLibs {\n useLegacyPackaging = false\n }\n }`
88
79
  );
89
80
  }
90
81
 
91
- // Pin Mapbox Maps + Navigation versions via resolutionStrategy
92
82
  const resolutionBlock = `
93
83
  configurations.all {
94
84
  resolutionStrategy {
95
- // Force Mapbox Maps SDK >= ${mapboxMapsVersion} — required for NavigationCamera compatibility (issue #43)
96
85
  force "com.mapbox.maps:android:${mapboxMapsVersion}"
97
86
  force "com.mapbox.maps:android-ndk27:${mapboxMapsVersion}"
98
87
  }
99
88
  }`;
100
-
101
89
  if (!contents.includes('com.mapbox.maps:android:')) {
102
90
  contents = contents + '\n' + resolutionBlock;
103
91
  }
@@ -106,31 +94,57 @@ configurations.all {
106
94
  return mod;
107
95
  });
108
96
 
109
- // ── Android: AndroidManifest — add access token as meta-data ─────────────
97
+ // ── Android: AndroidManifest ─────────────────────────────────────────────
110
98
  config = withAndroidManifest(config, (mod) => {
111
- const mainApp = mod.modResults.manifest.application?.[0];
112
- if (mainApp) {
113
- if (!mainApp['meta-data']) {
114
- mainApp['meta-data'] = [];
99
+ const manifest = mod.modResults.manifest;
100
+
101
+ // ── Permissions required by Mapbox Navigation SDK (targetSdk 35) ────────
102
+ //
103
+ // SecurityException: Starting FGS with type location requires:
104
+ // - android.permission.FOREGROUND_SERVICE_LOCATION (mandatory API 34+)
105
+ // - android.permission.FOREGROUND_SERVICE (mandatory)
106
+ // - android.permission.ACCESS_FINE_LOCATION (at least one of coarse/fine)
107
+ // - android.permission.ACCESS_COARSE_LOCATION
108
+ //
109
+ const requiredPermissions = [
110
+ 'android.permission.ACCESS_FINE_LOCATION',
111
+ 'android.permission.ACCESS_COARSE_LOCATION',
112
+ 'android.permission.FOREGROUND_SERVICE',
113
+ 'android.permission.FOREGROUND_SERVICE_LOCATION', // ← fixes the crash
114
+ 'android.permission.POST_NOTIFICATIONS', // needed for nav notification (API 33+)
115
+ ];
116
+
117
+ if (!manifest['uses-permission']) {
118
+ manifest['uses-permission'] = [];
119
+ }
120
+
121
+ for (const permission of requiredPermissions) {
122
+ const already = manifest['uses-permission'].some(
123
+ (p) => p.$?.['android:name'] === permission
124
+ );
125
+ if (!already) {
126
+ manifest['uses-permission'].push({ $: { 'android:name': permission } });
115
127
  }
128
+ }
116
129
 
130
+ // ── Mapbox access token meta-data ────────────────────────────────────────
131
+ const mainApp = manifest.application?.[0];
132
+ if (mainApp) {
133
+ if (!mainApp['meta-data']) mainApp['meta-data'] = [];
117
134
  const tokenMeta = mainApp['meta-data'].find(
118
135
  (m) => m.$?.['android:name'] === 'com.mapbox.token'
119
136
  );
120
-
121
137
  if (!tokenMeta) {
122
138
  mainApp['meta-data'].push({
123
- $: {
124
- 'android:name': 'com.mapbox.token',
125
- 'android:value': accessToken,
126
- },
139
+ $: { 'android:name': 'com.mapbox.token', 'android:value': accessToken },
127
140
  });
128
141
  }
129
142
  }
143
+
130
144
  return mod;
131
145
  });
132
146
 
133
- // ── Android: Write color overrides resource file ──────────────────────────
147
+ // ── Android: color overrides ─────────────────────────────────────────────
134
148
  if (Object.keys(androidColorOverrides).length > 0) {
135
149
  config = withDangerousMod(config, [
136
150
  'android',
@@ -139,27 +153,16 @@ configurations.all {
139
153
  mod.modRequest.platformProjectRoot,
140
154
  'app/src/main/res/values'
141
155
  );
142
-
143
- if (!fs.existsSync(colorsDir)) {
144
- fs.mkdirSync(colorsDir, { recursive: true });
145
- }
156
+ if (!fs.existsSync(colorsDir)) fs.mkdirSync(colorsDir, { recursive: true });
146
157
 
147
158
  const colorEntries = Object.entries(androidColorOverrides)
148
159
  .map(([name, value]) => ` <color name="${name}">${value}</color>`)
149
160
  .join('\n');
150
161
 
151
- const colorsXml = `<?xml version="1.0" encoding="utf-8"?>
152
- <!-- Generated by @jacques_gordon/expo-mapbox-navigation config plugin -->
153
- <resources>
154
- ${colorEntries}
155
- </resources>
156
- `;
157
-
158
162
  fs.writeFileSync(
159
163
  path.join(colorsDir, 'mapbox_navigation_colors.xml'),
160
- colorsXml
164
+ `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n${colorEntries}\n</resources>\n`
161
165
  );
162
-
163
166
  return mod;
164
167
  },
165
168
  ]);
@@ -177,16 +180,12 @@ ${colorEntries}
177
180
  mod.modResults.NSLocationAlwaysAndWhenInUseUsageDescription =
178
181
  'Your location is used for turn-by-turn navigation even when the app is in the background.';
179
182
  }
180
-
181
- if (!mod.modResults.UIBackgroundModes) {
182
- mod.modResults.UIBackgroundModes = [];
183
- }
183
+ if (!mod.modResults.UIBackgroundModes) mod.modResults.UIBackgroundModes = [];
184
184
  for (const mode of ['audio', 'location']) {
185
185
  if (!mod.modResults.UIBackgroundModes.includes(mode)) {
186
186
  mod.modResults.UIBackgroundModes.push(mode);
187
187
  }
188
188
  }
189
-
190
189
  return mod;
191
190
  });
192
191