@jacques_gordon/expo-mapbox-navigation 2.0.8 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,51 +1,19 @@
1
1
  apply plugin: 'com.android.library'
2
2
  apply plugin: 'kotlin-android'
3
- apply plugin: 'maven-publish'
4
-
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)
16
- def MAPBOX_MAPS_MIN_VERSION = "11.11.0"
17
3
 
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
- // ─────────────────────────────────────────────────────────────────────────────
4
+ def MAPBOX_NAV_VERSION = "3.8.1"
5
+ def MAPBOX_MAPS_MIN_VERSION = "11.11.0"
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
-
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
- externalNativeBuild {
42
- cmake {
43
- arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
44
- }
45
- }
46
-
47
16
  ndk {
48
- // Only build for 64-bit ABIs — 16 KB page size requirement is 64-bit only
49
17
  abiFilters "arm64-v8a", "x86_64"
50
18
  }
51
19
  }
@@ -60,8 +28,6 @@ android {
60
28
  }
61
29
 
62
30
  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
31
  jniLibs {
66
32
  useLegacyPackaging = false
67
33
  }
@@ -75,13 +41,9 @@ android {
75
41
  repositories {
76
42
  mavenCentral()
77
43
  google()
78
-
79
- // Mapbox Maven repository — requires a secret token (sk.*)
80
44
  maven {
81
45
  url 'https://api.mapbox.com/downloads/v2/releases/maven'
82
- authentication {
83
- basic(BasicAuthentication)
84
- }
46
+ authentication { basic(BasicAuthentication) }
85
47
  credentials {
86
48
  username = 'mapbox'
87
49
  password = project.hasProperty('MAPBOX_DOWNLOADS_TOKEN')
@@ -95,22 +57,30 @@ dependencies {
95
57
  implementation project(':expo-modules-core')
96
58
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${safeExtGet('kotlinVersion', '1.9.0')}"
97
59
 
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}"
60
+ // Mapbox Navigation SDK v3
61
+ implementation "com.mapbox.navigationcore:android:${MAPBOX_NAV_VERSION}"
104
62
 
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)
63
+ // Mapbox Maps SDK
107
64
  implementation("com.mapbox.maps:android:${MAPBOX_MAPS_MIN_VERSION}") {
108
65
  transitive = true
109
66
  }
110
67
 
111
- // Lifecycle support
68
+ // Navigation UI Components (ManeuverView, SpeedInfoView)
69
+ implementation "com.mapbox.navigationcore:ui-components:${MAPBOX_NAV_VERSION}"
70
+
71
+ // TripData (ManeuverApi, SpeedInfoApi, TripProgressApi)
72
+ implementation "com.mapbox.navigationcore:tripdata:${MAPBOX_NAV_VERSION}"
73
+
74
+ // UI Maps (NavigationStyles, NavigationLocationProvider, NavigationCamera)
75
+ implementation "com.mapbox.navigationcore:ui-maps:${MAPBOX_NAV_VERSION}"
76
+
77
+ // ConstraintLayout — required because MapboxManeuverView extends ConstraintLayout
78
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
79
+
80
+ // AndroidX
112
81
  implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
113
82
  implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
83
+ implementation 'androidx.core:core-ktx:1.13.1'
114
84
  }
115
85
 
116
86
  def safeExtGet(prop, fallback) {
@@ -2,13 +2,22 @@ package expo.modules.mapboxnavigation
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
+ import android.graphics.Color
5
6
  import android.util.Log
7
+ import android.view.Gravity
8
+ import android.view.View
9
+ import android.widget.FrameLayout
10
+ import android.widget.LinearLayout
11
+ import android.widget.TextView
6
12
  import com.mapbox.api.directions.v5.models.RouteOptions
13
+ import com.mapbox.common.location.Location
7
14
  import com.mapbox.geojson.Point
8
15
  import com.mapbox.maps.MapView
9
- import com.mapbox.maps.Style
10
16
  import com.mapbox.maps.plugin.animation.camera
17
+ import com.mapbox.maps.plugin.locationcomponent.location
18
+ import com.mapbox.navigation.base.TimeFormat
11
19
  import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
20
+ import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
12
21
  import com.mapbox.navigation.base.options.NavigationOptions
13
22
  import com.mapbox.navigation.base.route.NavigationRoute
14
23
  import com.mapbox.navigation.base.route.NavigationRouterCallback
@@ -18,10 +27,29 @@ import com.mapbox.navigation.core.MapboxNavigation
18
27
  import com.mapbox.navigation.core.MapboxNavigationProvider
19
28
  import com.mapbox.navigation.core.directions.session.RoutesObserver
20
29
  import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult
30
+ import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter
31
+ import com.mapbox.navigation.core.trip.session.LocationMatcherResult
32
+ import com.mapbox.navigation.core.trip.session.LocationObserver
21
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
22
46
  import com.mapbox.navigation.ui.maps.camera.NavigationCamera
23
47
  import com.mapbox.navigation.ui.maps.camera.data.MapboxNavigationViewportDataSource
24
48
  import com.mapbox.navigation.ui.maps.camera.transition.NavigationCameraTransitionOptions
49
+ import com.mapbox.navigation.ui.maps.location.NavigationLocationProvider
50
+ import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowApi
51
+ import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowView
52
+ import com.mapbox.navigation.ui.maps.route.arrow.model.RouteArrowOptions
25
53
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
26
54
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
27
55
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
@@ -29,6 +57,7 @@ import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
29
57
  import expo.modules.kotlin.AppContext
30
58
  import expo.modules.kotlin.viewevent.EventDispatcher
31
59
  import expo.modules.kotlin.views.ExpoView
60
+ import java.util.Calendar
32
61
  import java.util.Locale
33
62
 
34
63
  private const val TAG = "ExpoMapboxNavigation"
@@ -36,8 +65,7 @@ private const val TAG = "ExpoMapboxNavigation"
36
65
  class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
37
66
  ExpoView(context, appContext) {
38
67
 
39
- // ── EventDispatchers — the correct Expo Modules API for View events ───────
40
- // Each event declared here must match the Events(...) list in ExpoMapboxNavigationModule
68
+ // ── EventDispatchers (correct Expo Modules API for View events) ───────────
41
69
  private val onRouteProgressChanged by EventDispatcher()
42
70
  private val onRoutesReady by EventDispatcher()
43
71
  private val onNavigationFinished by EventDispatcher()
@@ -45,14 +73,32 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
45
73
  private val onRoutesFailed by EventDispatcher()
46
74
  private val onArrival by EventDispatcher()
47
75
 
48
- // ── Map & Navigation ──────────────────────────────────────────────────────
76
+ // ── Views ─────────────────────────────────────────────────────────────────
49
77
  private val mapView: MapView = MapView(context)
50
- private var navigationCamera: NavigationCamera? = null
51
- private var viewportDataSource: MapboxNavigationViewportDataSource? = null
52
- private var routeLineApi: MapboxRouteLineApi? = null
53
- private var routeLineView: MapboxRouteLineView? = null
78
+ private var maneuverView: MapboxManeuverView? = null
79
+ private var speedInfoView: MapboxSpeedInfoView? = null
80
+ private var tripProgressView: MapboxTripProgressView? = null
81
+ private var tvEtaTime: TextView? = null
82
+ private var tvDuration: TextView? = null
83
+ private var tvDistance: TextView? = null
84
+ private var etaBar: LinearLayout? = null
85
+
86
+ // ── Navigation APIs (from official TurnByTurnExperienceActivity) ──────────
87
+ private lateinit var navigationCamera: NavigationCamera
88
+ private lateinit var viewportDataSource: MapboxNavigationViewportDataSource
89
+ private lateinit var routeLineApi: MapboxRouteLineApi
90
+ private lateinit var routeLineView: MapboxRouteLineView
91
+ private lateinit var maneuverApi: MapboxManeuverApi
92
+ private lateinit var tripProgressApi: MapboxTripProgressApi
93
+ private lateinit var speedInfoApi: MapboxSpeedInfoApi
94
+ private val routeArrowApi = MapboxRouteArrowApi()
95
+ private lateinit var routeArrowView: MapboxRouteArrowView
96
+ private val navigationLocationProvider = NavigationLocationProvider()
54
97
  private var mapboxNavigation: MapboxNavigation? = null
55
98
 
99
+ // ── Night mode ────────────────────────────────────────────────────────────
100
+ private var isNightMode = false
101
+
56
102
  // ── Props ─────────────────────────────────────────────────────────────────
57
103
  private var coordinates: List<Map<String, Double>> = emptyList()
58
104
  private var waypointIndices: List<Int>? = null
@@ -69,67 +115,250 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
69
115
  private var customRasterAboveLayerId: String? = null
70
116
 
71
117
  init {
72
- mapView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
73
- addView(mapView)
118
+ buildUI()
119
+ initAPIs()
74
120
  setupNavigation()
75
121
  }
76
122
 
77
- private fun setupNavigation() {
78
- val navigationOptions = NavigationOptions.Builder(context).build()
79
- mapboxNavigation = MapboxNavigationProvider.create(navigationOptions)
123
+ // ─────────────────────────────────────────────────────────────────────────
124
+ // 1. Build programmatic UI
125
+ // ─────────────────────────────────────────────────────────────────────────
126
+ private fun buildUI() {
127
+ val dp = context.resources.displayMetrics.density
128
+ val root = FrameLayout(context).apply {
129
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
130
+ }
131
+
132
+ // Full-screen MapView
133
+ root.addView(mapView, FrameLayout.LayoutParams(
134
+ FrameLayout.LayoutParams.MATCH_PARENT,
135
+ FrameLayout.LayoutParams.MATCH_PARENT
136
+ ))
80
137
 
81
- mapView.mapboxMap.loadStyle(mapStyle ?: Style.MAPBOX_STREETS) { _ ->
82
- routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
83
- routeLineView = MapboxRouteLineView(
84
- MapboxRouteLineViewOptions.Builder(context).build()
138
+ // ManeuverView top banner
139
+ // NOTE: MapboxManeuverView extends ConstraintLayout, cast to View for addView()
140
+ val mv = MapboxManeuverView(context)
141
+ val mvParams = FrameLayout.LayoutParams(
142
+ FrameLayout.LayoutParams.MATCH_PARENT,
143
+ FrameLayout.LayoutParams.WRAP_CONTENT
144
+ )
145
+ mvParams.gravity = Gravity.TOP
146
+ mv.visibility = View.INVISIBLE // matches official example (INVISIBLE not GONE)
147
+ root.addView(mv as View, mvParams)
148
+ maneuverView = mv
149
+
150
+ // SpeedInfoView — bottom-left
151
+ val siv = MapboxSpeedInfoView(context)
152
+ val sivParams = FrameLayout.LayoutParams(
153
+ FrameLayout.LayoutParams.WRAP_CONTENT,
154
+ FrameLayout.LayoutParams.WRAP_CONTENT
155
+ )
156
+ sivParams.gravity = Gravity.BOTTOM or Gravity.START
157
+ sivParams.setMargins((16 * dp).toInt(), 0, 0, (100 * dp).toInt())
158
+ siv.visibility = View.GONE
159
+ root.addView(siv as View, sivParams)
160
+ speedInfoView = siv
161
+
162
+ // TripProgressView — embedded in bottom bar
163
+ val tpv = MapboxTripProgressView(context)
164
+ tripProgressView = tpv
165
+
166
+ // ETA bottom bar
167
+ val bar = LinearLayout(context).apply {
168
+ orientation = LinearLayout.HORIZONTAL
169
+ setBackgroundColor(Color.WHITE)
170
+ elevation = 8 * dp
171
+ visibility = View.INVISIBLE
172
+ gravity = Gravity.CENTER_VERTICAL
173
+ setPadding(
174
+ (16 * dp).toInt(), (8 * dp).toInt(),
175
+ (16 * dp).toInt(), (8 * dp).toInt()
85
176
  )
177
+ }
178
+ val barParams = FrameLayout.LayoutParams(
179
+ FrameLayout.LayoutParams.MATCH_PARENT,
180
+ (80 * dp).toInt()
181
+ )
182
+ barParams.gravity = Gravity.BOTTOM
183
+
184
+ val etaTime = TextView(context).apply {
185
+ textSize = 22f
186
+ setTextColor(Color.BLACK)
187
+ typeface = android.graphics.Typeface.DEFAULT_BOLD
188
+ }
189
+ bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
190
+ tvEtaTime = etaTime
191
+
192
+ val center = LinearLayout(context).apply {
193
+ orientation = LinearLayout.VERTICAL
194
+ gravity = Gravity.CENTER
195
+ }
196
+ val dur = TextView(context).apply { textSize = 16f; setTextColor(Color.DKGRAY); gravity = Gravity.CENTER }
197
+ val dist = TextView(context).apply { textSize = 13f; setTextColor(Color.GRAY); gravity = Gravity.CENTER }
198
+ center.addView(dur)
199
+ center.addView(dist)
200
+ bar.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f))
201
+ tvDuration = dur
202
+ tvDistance = dist
203
+
204
+ val cancelBtn = TextView(context).apply {
205
+ text = "✕"
206
+ textSize = 22f
207
+ setTextColor(Color.DKGRAY)
208
+ gravity = Gravity.END or Gravity.CENTER_VERTICAL
209
+ setOnClickListener { cancelNavigation() }
210
+ }
211
+ bar.addView(cancelBtn, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
212
+ etaBar = bar
213
+ root.addView(bar, barParams)
214
+
215
+ addView(root)
216
+ }
217
+
218
+ // ─────────────────────────────────────────────────────────────────────────
219
+ // 2. Init APIs (from official example — exact same pattern)
220
+ // ─────────────────────────────────────────────────────────────────────────
221
+ private fun initAPIs() {
222
+ val distanceFormatterOptions = DistanceFormatterOptions.Builder(context).build()
223
+
224
+ // EXACT same instantiation as TurnByTurnExperienceActivity
225
+ maneuverApi = MapboxManeuverApi(
226
+ MapboxDistanceFormatter(distanceFormatterOptions)
227
+ )
228
+
229
+ tripProgressApi = MapboxTripProgressApi(
230
+ TripProgressUpdateFormatter.Builder(context)
231
+ .distanceRemainingFormatter(DistanceRemainingFormatter(distanceFormatterOptions))
232
+ .timeRemainingFormatter(TimeRemainingFormatter(context))
233
+ .percentRouteTraveledFormatter(PercentDistanceTraveledFormatter())
234
+ .estimatedTimeToArrivalFormatter(
235
+ EstimatedTimeToArrivalFormatter(context, TimeFormat.NONE_SPECIFIED)
236
+ )
237
+ .build()
238
+ )
239
+
240
+ speedInfoApi = MapboxSpeedInfoApi()
241
+
242
+ val routeLineViewOptions = MapboxRouteLineViewOptions.Builder(context)
243
+ .routeLineBelowLayerId("road-label-navigation")
244
+ .build()
245
+ routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
246
+ routeLineView = MapboxRouteLineView(routeLineViewOptions)
247
+
248
+ val routeArrowOptions = RouteArrowOptions.Builder(context).build()
249
+ routeArrowView = MapboxRouteArrowView(routeArrowOptions)
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────
253
+ // 3. Setup Navigation
254
+ // ─────────────────────────────────────────────────────────────────────────
255
+ private fun setupNavigation() {
256
+ mapboxNavigation = MapboxNavigationProvider.create(
257
+ NavigationOptions.Builder(context).build()
258
+ )
259
+
260
+ val style = mapStyle ?: getAutoStyle()
261
+ mapView.mapboxMap.loadStyle(style) { mapStyle ->
262
+
263
+ // Route line layers must be initialized first
264
+ routeLineView.initializeLayers(mapStyle)
265
+
266
+ // Camera setup — EXACT same as official example
86
267
  viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
87
268
  navigationCamera = NavigationCamera(
88
269
  mapView.mapboxMap,
89
270
  mapView.camera,
90
- viewportDataSource!!
271
+ viewportDataSource
91
272
  )
273
+
274
+ // Location puck
275
+ mapView.location.apply {
276
+ setLocationProvider(navigationLocationProvider)
277
+ enabled = true
278
+ }
279
+
92
280
  registerObservers()
93
281
  }
94
282
  }
95
283
 
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+ // 4. Observers — based 100% on official TurnByTurnExperienceActivity
286
+ // ─────────────────────────────────────────────────────────────────────────
96
287
  private fun registerObservers() {
97
288
  val nav = mapboxNavigation ?: return
98
289
 
290
+ // ── Routes observer ───────────────────────────────────────────────────
99
291
  nav.registerRoutesObserver(object : RoutesObserver {
100
292
  override fun onRoutesChanged(result: RoutesUpdatedResult) {
101
- val routes = result.navigationRoutes
102
- if (routes.isNotEmpty()) {
103
- routeLineApi?.setNavigationRoutes(routes) { value ->
104
- mapView.mapboxMap.style?.let { style ->
105
- routeLineView?.renderRouteDrawData(style, value)
293
+ if (result.navigationRoutes.isNotEmpty()) {
294
+ // Draw route line
295
+ routeLineApi.setNavigationRoutes(result.navigationRoutes) { value ->
296
+ mapView.mapboxMap.style?.apply {
297
+ routeLineView.renderRouteDrawData(this, value)
106
298
  }
107
299
  }
108
- viewportDataSource?.onRouteChanged(routes.first())
109
- viewportDataSource?.evaluate()
110
- safeCameraFollowing()
300
+ // Update camera viewport
301
+ viewportDataSource.onRouteChanged(result.navigationRoutes.first())
302
+ viewportDataSource.evaluate()
303
+
304
+ // Show navigation UI
305
+ maneuverView?.visibility = View.VISIBLE
306
+ etaBar?.visibility = View.VISIBLE
111
307
 
112
308
  onRoutesReady(mapOf(
113
- "routeCount" to routes.size,
114
- "distanceMeters" to (routes.first().directionsRoute.distance() ?: 0.0),
115
- "durationSeconds" to (routes.first().directionsRoute.duration() ?: 0.0)
309
+ "routeCount" to result.navigationRoutes.size,
310
+ "distanceMeters" to (result.navigationRoutes.first().directionsRoute.distance() ?: 0.0),
311
+ "durationSeconds" to (result.navigationRoutes.first().directionsRoute.duration() ?: 0.0)
116
312
  ))
117
313
  } else {
118
- routeLineApi?.clearRouteLine { value ->
119
- mapView.mapboxMap.style?.let { style ->
120
- routeLineView?.renderClearRouteLineValue(style, value)
314
+ // Clear route line
315
+ val style = mapView.mapboxMap.style
316
+ if (style != null) {
317
+ routeLineApi.clearRouteLine { value ->
318
+ routeLineView.renderClearRouteLineValue(style, value)
121
319
  }
320
+ routeArrowView.render(style, routeArrowApi.clearArrows())
122
321
  }
123
- viewportDataSource?.clearRouteData()
124
- viewportDataSource?.evaluate()
322
+ viewportDataSource.clearRouteData()
323
+ viewportDataSource.evaluate()
324
+ hideUI()
125
325
  }
126
326
  }
127
327
  })
128
328
 
329
+ // ── Route progress observer ───────────────────────────────────────────
330
+ // EXACT same pattern as TurnByTurnExperienceActivity.routeProgressObserver
129
331
  nav.registerRouteProgressObserver(RouteProgressObserver { routeProgress ->
130
- viewportDataSource?.onRouteProgressChanged(routeProgress)
131
- viewportDataSource?.evaluate()
332
+ // Update camera
333
+ viewportDataSource.onRouteProgressChanged(routeProgress)
334
+ viewportDataSource.evaluate()
132
335
 
336
+ // Draw maneuver arrow
337
+ val style = mapView.mapboxMap.style
338
+ if (style != null) {
339
+ val maneuverArrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
340
+ routeArrowView.renderManeuverUpdate(style, maneuverArrowResult)
341
+ }
342
+
343
+ // Maneuver banner — EXACT same fold() pattern as official example
344
+ val maneuvers = maneuverApi.getManeuvers(routeProgress)
345
+ maneuvers.fold(
346
+ { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}") },
347
+ {
348
+ maneuverView?.visibility = View.VISIBLE
349
+ maneuverView?.renderManeuvers(maneuvers)
350
+ }
351
+ )
352
+
353
+ // Trip progress — EXACT same as official example
354
+ // getTripProgress returns TripProgressUpdateValue directly (not nullable)
355
+ val tripProgress = tripProgressApi.getTripProgress(routeProgress)
356
+ tripProgressView?.render(tripProgress)
357
+
358
+ // Update our custom ETA bar from tripProgress fields
359
+ updateEtaBar(tripProgress)
360
+
361
+ // Emit event
133
362
  onRouteProgressChanged(mapOf(
134
363
  "distanceRemaining" to routeProgress.distanceRemaining,
135
364
  "durationRemaining" to routeProgress.durationRemaining,
@@ -140,22 +369,145 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
140
369
  ?.currentStepProgress?.distanceRemaining ?: 0f)
141
370
  ))
142
371
  })
372
+
373
+ // ── Location observer ─────────────────────────────────────────────────
374
+ // EXACT same as official example
375
+ nav.registerLocationObserver(object : LocationObserver {
376
+ override fun onNewRawLocation(rawLocation: Location) {}
377
+
378
+ override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
379
+ val enhancedLocation = locationMatcherResult.enhancedLocation
380
+
381
+ // Update location puck
382
+ navigationLocationProvider.changePosition(
383
+ location = enhancedLocation,
384
+ keyPoints = locationMatcherResult.keyPoints,
385
+ )
386
+
387
+ // Update camera
388
+ viewportDataSource.onLocationChanged(enhancedLocation)
389
+ viewportDataSource.evaluate()
390
+
391
+ // Speed limit display
392
+ val formatterOptions = DistanceFormatterOptions.Builder(context).build()
393
+ val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(
394
+ locationMatcherResult,
395
+ formatterOptions
396
+ )
397
+ if (speedInfo != null) {
398
+ speedInfoView?.visibility = View.VISIBLE
399
+ speedInfoView?.render(speedInfo)
400
+ } else {
401
+ speedInfoView?.visibility = View.GONE
402
+ }
403
+
404
+ // Auto day/night switch
405
+ checkAndSwitchDayNight()
406
+ }
407
+ })
408
+ }
409
+
410
+ // ─────────────────────────────────────────────────────────────────────────
411
+ // 5. ETA bar update
412
+ // TripProgressUpdateValue fields confirmed from official tripdata API
413
+ // ─────────────────────────────────────────────────────────────────────────
414
+ private fun updateEtaBar(tripProgress: com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateValue) {
415
+ // ETA arrival time
416
+ val etaSec = tripProgress.formatter.timeRemainingFormatter
417
+ .formatTime(tripProgress).toString()
418
+ // Calculate arrival clock time
419
+ val totalSec = tripProgress.totalTimeRemaining.toLong()
420
+ val cal = Calendar.getInstance().apply { add(Calendar.SECOND, totalSec.toInt()) }
421
+ tvEtaTime?.text = String.format("%02d:%02d",
422
+ cal.get(Calendar.HOUR_OF_DAY),
423
+ cal.get(Calendar.MINUTE)
424
+ )
425
+
426
+ // Duration remaining
427
+ val totalMin = (tripProgress.totalTimeRemaining / 60).toInt()
428
+ tvDuration?.text = if (totalMin >= 60)
429
+ "${totalMin / 60}h ${totalMin % 60}min"
430
+ else
431
+ "${totalMin} min"
432
+
433
+ // Distance remaining
434
+ val distM = tripProgress.distanceRemaining
435
+ tvDistance?.text = if (distM >= 1000)
436
+ String.format("%.1f km", distM / 1000.0)
437
+ else
438
+ "${distM.toInt()} m"
439
+ }
440
+
441
+ // ─────────────────────────────────────────────────────────────────────────
442
+ // 6. Day / Night auto switch
443
+ // ─────────────────────────────────────────────────────────────────────────
444
+ private fun getAutoStyle(): String {
445
+ val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
446
+ return if (hour in 6..20) NavigationStyles.NAVIGATION_DAY_STYLE
447
+ else NavigationStyles.NAVIGATION_NIGHT_STYLE
448
+ }
449
+
450
+ private fun checkAndSwitchDayNight() {
451
+ val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
452
+ val shouldBeNight = hour !in 6..20
453
+ if (shouldBeNight == isNightMode) return
454
+ isNightMode = shouldBeNight
455
+
456
+ val newStyle = if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE
457
+ else NavigationStyles.NAVIGATION_DAY_STYLE
458
+
459
+ mapView.mapboxMap.loadStyle(newStyle) { style ->
460
+ routeLineView.initializeLayers(style)
461
+ mapboxNavigation?.getNavigationRoutes()?.takeIf { it.isNotEmpty() }?.let { routes ->
462
+ routeLineApi.setNavigationRoutes(routes) { value ->
463
+ routeLineView.renderRouteDrawData(style, value)
464
+ }
465
+ }
466
+ }
467
+
468
+ etaBar?.setBackgroundColor(
469
+ if (shouldBeNight) Color.parseColor("#1E2433") else Color.WHITE
470
+ )
471
+ val tc = if (shouldBeNight) Color.WHITE else Color.BLACK
472
+ tvEtaTime?.setTextColor(tc)
473
+ tvDuration?.setTextColor(if (shouldBeNight) Color.LTGRAY else Color.DKGRAY)
474
+ tvDistance?.setTextColor(Color.GRAY)
475
+ }
476
+
477
+ // ─────────────────────────────────────────────────────────────────────────
478
+ // 7. Cancel navigation
479
+ // ─────────────────────────────────────────────────────────────────────────
480
+ private fun cancelNavigation() {
481
+ mapboxNavigation?.setNavigationRoutes(listOf())
482
+ mapboxNavigation?.stopTripSession()
483
+ hideUI()
484
+ onNavigationCancelled(mapOf<String, Any>())
143
485
  }
144
486
 
145
- // ── Issue #43: safe camera ────────────────────────────────────────────────
487
+ private fun hideUI() {
488
+ maneuverView?.visibility = View.INVISIBLE
489
+ speedInfoView?.visibility = View.GONE
490
+ etaBar?.visibility = View.INVISIBLE
491
+ }
492
+
493
+ // ─────────────────────────────────────────────────────────────────────────
494
+ // 8. Issue #43 — safe camera following
495
+ // ─────────────────────────────────────────────────────────────────────────
146
496
  private fun safeCameraFollowing() {
147
497
  try {
148
- navigationCamera?.requestNavigationCameraToFollowing(
498
+ navigationCamera.requestNavigationCameraToFollowing(
149
499
  stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
150
500
  .maxDuration(0)
151
501
  .build()
152
502
  )
153
503
  } catch (e: Exception) {
154
- Log.e(TAG, "Camera transition error (issue #43): ${e.message}")
504
+ Log.e(TAG, "Camera transition error: ${e.message}")
155
505
  }
156
506
  }
157
507
 
158
- // ── Issue #31: voice units ────────────────────────────────────────────────
508
+ // ─────────────────────────────────────────────────────────────────────────
509
+ // 9. Issue #31 — Voice units
510
+ // ─────────────────────────────────────────────────────────────────────────
159
511
  private fun resolveVoiceUnits(): String {
160
512
  return when (voiceUnits?.lowercase()) {
161
513
  "metric" -> "metric"
@@ -167,7 +519,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
167
519
  }
168
520
  }
169
521
 
170
- // ── Route request ─────────────────────────────────────────────────────────
522
+ // ─────────────────────────────────────────────────────────────────────────
523
+ // 10. Route request
524
+ // ─────────────────────────────────────────────────────────────────────────
171
525
  @SuppressLint("MissingPermission")
172
526
  private fun fetchRoutes() {
173
527
  val nav = mapboxNavigation ?: return
@@ -183,14 +537,13 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
183
537
  .language(locale.toLanguageTag())
184
538
  .voiceUnits(resolveVoiceUnits())
185
539
  .coordinatesList(points)
540
+ .annotations("maxspeed,congestion,duration")
186
541
 
187
542
  waypointIndices?.let { builder.waypointIndicesList(it) }
188
543
  navigationProfile?.let {
189
544
  builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it")
190
545
  }
191
- excludeTypes?.takeIf { it.isNotEmpty() }?.let {
192
- builder.exclude(it.joinToString(","))
193
- }
546
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
194
547
  maxHeight?.let { builder.maxHeight(it) }
195
548
  maxWidth?.let { builder.maxWidth(it) }
196
549
 
@@ -207,12 +560,10 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
207
560
  }
208
561
  nav.setNavigationRoutes(routes)
209
562
  nav.startTripSession()
563
+ safeCameraFollowing()
210
564
  }
211
565
 
212
- override fun onFailure(
213
- reasons: List<RouterFailure>,
214
- routeOptions: RouteOptions
215
- ) {
566
+ override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
216
567
  val msg = reasons.firstOrNull()?.message ?: "Unknown error"
217
568
  Log.e(TAG, "Route request failed: $msg")
218
569
  onRoutesFailed(mapOf("message" to msg))
@@ -249,8 +600,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
249
600
  // ── Lifecycle ─────────────────────────────────────────────────────────────
250
601
  override fun onDetachedFromWindow() {
251
602
  super.onDetachedFromWindow()
252
- routeLineApi?.cancel()
253
- routeLineView?.cancel()
603
+ maneuverApi.cancel()
604
+ routeLineApi.cancel()
605
+ routeLineView.cancel()
254
606
  mapboxNavigation?.stopTripSession()
255
607
  MapboxNavigationProvider.destroy()
256
608
  mapView.onStop()
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.8",
3
+ "version": "2.1.0",
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",
@@ -23,14 +23,14 @@
23
23
  ],
24
24
  "repository": {
25
25
  "type": "git",
26
- "url": "https://github.com/jacques_gordon/expo-mapbox-navigation.git"
26
+ "url": "https://github.com/jacquesngomeheffa/expo-mapbox-navigation.git"
27
27
  },
28
28
  "bugs": {
29
- "url": "https://github.com/jacques_gordon/expo-mapbox-navigation/issues"
29
+ "url": "https://github.com/jacquesngomeheffa/expo-mapbox-navigation/issues"
30
30
  },
31
31
  "author": "Jacques Gordon",
32
32
  "license": "MIT",
33
- "homepage": "https://github.com/jacques_gordon/expo-mapbox-navigation#readme",
33
+ "homepage": "https://github.com/jacquesngomeheffa/expo-mapbox-navigation#readme",
34
34
  "peerDependencies": {
35
35
  "expo": ">=53.0.0",
36
36
  "react": "*",
@@ -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