@mentra/crust 3.3.0-dev.316 → 3.3.0-dev.320

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.
@@ -82,6 +82,8 @@ android {
82
82
  }
83
83
 
84
84
  dependencies {
85
+ testImplementation 'junit:junit:4.13.2'
86
+
85
87
  // Mapbox Navigation SDK v3 (replaces Google Navigation SDK).
86
88
  // Drives headless turn-by-turn nav in NavigationManager.kt: route
87
89
  // progress, off-route + automatic rerouting, enhanced (map-matched)
@@ -6,6 +6,7 @@ import com.mentra.crust.services.NotificationListener
6
6
  import com.mentra.crust.services.NotificationProcessBridge
7
7
  import expo.modules.kotlin.modules.Module
8
8
  import expo.modules.kotlin.modules.ModuleDefinition
9
+ import expo.modules.kotlin.functions.Queues
9
10
  import java.net.URL
10
11
 
11
12
  import com.mentra.crust.navigation.NavigationManager
@@ -922,7 +923,7 @@ class CrustModule : Module() {
922
923
  android.util.Log.e("CrustModule", "stopNavigation failed", e)
923
924
  mapOf("ok" to false, "error" to (e.message ?: "stop failed"))
924
925
  }
925
- }
926
+ }.runOnQueue(Queues.MAIN)
926
927
 
927
928
  // Dev-only: nudge the simulated position ~offsetMeters off-route to
928
929
  // exercise the Nav SDK's onRerouting() pipeline without having to
@@ -88,6 +88,7 @@ object NavigationManager {
88
88
 
89
89
  private var mapboxNavigation: MapboxNavigation? = null
90
90
  private var appContext: Context? = null
91
+ private val sessionGuard = NavigationSessionGuard()
91
92
 
92
93
  private var activeCallbacks: Callbacks? = null
93
94
  private var lastEmittedKey: String? = null
@@ -153,6 +154,9 @@ object NavigationManager {
153
154
  */
154
155
  private var skipCrossingsEnabled: Boolean = false
155
156
  private var skipCrossingsTimer: java.util.Timer? = null
157
+ private val simulationHandler = android.os.Handler(android.os.Looper.getMainLooper())
158
+ @Volatile private var deviationRerouteRunnable: Runnable? = null
159
+ @Volatile private var deviationReattachRunnable: Runnable? = null
156
160
  private val SKIP_CROSSINGS_BASE_M_PER_TICK = 0.56
157
161
  private val SKIP_CROSSINGS_TICK_MS = 400L
158
162
  private val CROSSING_MAX_LEG_METERS = 25.0
@@ -382,6 +386,12 @@ object NavigationManager {
382
386
  return
383
387
  }
384
388
 
389
+ // Invalidate every callback owned by the previous session before clearing
390
+ // its native state. Mapbox can replay cached observer values on registration
391
+ // and an earlier route request can finish after stop/start.
392
+ val session = sessionGuard.begin()
393
+ cleanupSession(nav)
394
+
385
395
  activeCallbacks = callbacks
386
396
  activeOptions = options
387
397
  simulating = options.simulate
@@ -389,7 +399,7 @@ object NavigationManager {
389
399
  offRouteFired = false
390
400
  arrivedHandled = false
391
401
 
392
- attachObservers(nav, callbacks)
402
+ attachObservers(nav, callbacks, session)
393
403
 
394
404
  // Origin handling — the crux of a correct trip start.
395
405
  //
@@ -407,7 +417,9 @@ object NavigationManager {
407
417
  // params until the gate fires; `handleLocation` invokes it on the first
408
418
  // location and clears it.
409
419
  pendingRouteRequest = { originLat, originLng ->
410
- requestAndStartRoute(nav, activity, options, originLat, originLng, callbacks)
420
+ if (sessionGuard.acceptsCallback(session)) {
421
+ requestAndStartRoute(nav, activity, options, originLat, originLng, callbacks, session)
422
+ }
411
423
  }
412
424
  startTripSession(nav, options)
413
425
  }
@@ -424,6 +436,7 @@ object NavigationManager {
424
436
  originLat: Double,
425
437
  originLng: Double,
426
438
  callbacks: Callbacks,
439
+ session: NavigationSessionGuard.Token,
427
440
  ) {
428
441
  val coordinates = ArrayList<Point>()
429
442
  coordinates.add(Point.fromLngLat(originLng, originLat))
@@ -446,24 +459,29 @@ object NavigationManager {
446
459
  routeOptions,
447
460
  object : NavigationRouterCallback {
448
461
  override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
462
+ if (!sessionGuard.acceptsCallback(session)) return
449
463
  if (routes.isEmpty()) {
450
464
  callbacks.onError("no route found")
451
465
  return
452
466
  }
467
+ val route = routes.first()
468
+ if (!sessionGuard.activateInitialRoute(session, route.id)) return
453
469
  nav.setNavigationRoutes(routes)
454
470
  // In sim mode the ReplayRouteSession observes setNavigationRoutes
455
471
  // and starts driving the puck automatically — no manual replay
456
472
  // kick needed here.
457
- emitRoute(routes.first(), callbacks)
473
+ emitRoute(route, callbacks)
458
474
  }
459
475
 
460
476
  override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
477
+ if (!sessionGuard.acceptsCallback(session)) return
461
478
  val msg = reasons.firstOrNull()?.message ?: "route request failed"
462
479
  Log.e(TAG, "requestRoutes failed: $msg")
463
480
  callbacks.onError(msg)
464
481
  }
465
482
 
466
483
  override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
484
+ if (!sessionGuard.acceptsCallback(session)) return
467
485
  Log.w(TAG, "route request canceled")
468
486
  }
469
487
  },
@@ -588,7 +606,11 @@ object NavigationManager {
588
606
 
589
607
  fun stop() {
590
608
  Log.d(TAG, "stop")
591
- val nav = mapboxNavigation
609
+ sessionGuard.invalidate()
610
+ cleanupSession(mapboxNavigation)
611
+ }
612
+
613
+ private fun cleanupSession(nav: MapboxNavigation?) {
592
614
  activeCallbacks = null
593
615
  lastEmittedKey = null
594
616
  simulating = false
@@ -597,6 +619,10 @@ object NavigationManager {
597
619
  // Tear down dev walkers first.
598
620
  skipCrossingsEnabled = false
599
621
  skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
622
+ deviationRerouteRunnable?.let(simulationHandler::removeCallbacks)
623
+ deviationRerouteRunnable = null
624
+ deviationReattachRunnable?.let(simulationHandler::removeCallbacks)
625
+ deviationReattachRunnable = null
600
626
  wrongSidewalkOffsetEnabled = false
601
627
 
602
628
  if (nav != null) {
@@ -637,7 +663,11 @@ object NavigationManager {
637
663
  // Observers — translate Mapbox events into the existing Callbacks shape.
638
664
  // =====================================================================
639
665
 
640
- private fun attachObservers(nav: MapboxNavigation, callbacks: Callbacks) {
666
+ private fun attachObservers(
667
+ nav: MapboxNavigation,
668
+ callbacks: Callbacks,
669
+ session: NavigationSessionGuard.Token,
670
+ ) {
641
671
  // REROUTING IS FULLY OWNED BY MAPBOX. Auto-reroute is ON by default in
642
672
  // v3 — when the user diverges, the SDK detects off-route AND fetches a
643
673
  // new route on its own. We do NOT hand-roll perpendicular-distance
@@ -653,6 +683,7 @@ object NavigationManager {
653
683
  // clear our off-route flag and (for the host) reads as a fresh route.
654
684
  val routesObs = RoutesObserver { result: RoutesUpdatedResult ->
655
685
  val route = result.navigationRoutes.firstOrNull() ?: return@RoutesObserver
686
+ if (!sessionGuard.acceptRouteUpdate(session, route.id)) return@RoutesObserver
656
687
  val reason = result.reason
657
688
  // Ignore refresh (same geometry, just live traffic) — it would
658
689
  // needlessly re-emit the whole polyline + rebuild pivots.
@@ -669,6 +700,7 @@ object NavigationManager {
669
700
  // RouteProgressObserver — every location tick. Carries current step,
670
701
  // upcoming maneuver, distance-to-maneuver, and trip totals.
671
702
  val progressObs = RouteProgressObserver { progress: RouteProgress ->
703
+ if (!sessionGuard.acceptsProgress(session, progress.navigationRoute.id)) return@RouteProgressObserver
672
704
  handleRouteProgress(progress, callbacks)
673
705
  }
674
706
  routeProgressObserver = progressObs
@@ -679,17 +711,22 @@ object NavigationManager {
679
711
  val locObs = object : LocationObserver {
680
712
  override fun onNewRawLocation(rawLocation: Location) { /* prefer matched */ }
681
713
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
714
+ if (!sessionGuard.acceptsLocation(session)) return
682
715
  handleLocation(result, callbacks)
683
716
  }
684
717
  }
685
718
  locationObserver = locObs
686
719
  nav.registerLocationObserver(locObs)
720
+ // Mapbox synchronously replays its cached matcher result from register().
721
+ // Only locations delivered after registration belong to this fresh trip.
722
+ sessionGuard.enableLocationCallbacks(session)
687
723
 
688
724
  // OffRouteObserver — Mapbox's automatic off-route edge signal. PURELY
689
725
  // OBSERVATIONAL: registering it does NOT disable auto-reroute. We
690
726
  // surface onOffRoute for the UI's "off route" banner; the SDK handles
691
727
  // the actual reroute and the new route arrives via RoutesObserver.
692
728
  val offRouteObs = OffRouteObserver { offRoute: Boolean ->
729
+ if (!sessionGuard.acceptsCallback(session)) return@OffRouteObserver
693
730
  if (offRoute && !offRouteFired) {
694
731
  offRouteFired = true
695
732
  // Distance from the route is best-effort context for the banner;
@@ -707,6 +744,7 @@ object NavigationManager {
707
744
  // route then lands via RoutesObserver(REROUTE). Available only while
708
745
  // auto-reroute is enabled (getRerouteController() is null otherwise).
709
746
  val rerouteObs = RerouteController.RerouteStateObserver { state: RerouteState ->
747
+ if (!sessionGuard.acceptsCallback(session)) return@RerouteStateObserver
710
748
  when (state) {
711
749
  is RerouteState.FetchingRoute -> {
712
750
  Log.d(TAG, "reroute state: FetchingRoute → onRerouting")
@@ -1054,6 +1092,10 @@ object NavigationManager {
1054
1092
  */
1055
1093
  @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
1056
1094
  fun simulateDeviation(offsetMeters: Double = 0.0) {
1095
+ val session = sessionGuard.captureActive() ?: run {
1096
+ Log.w(TAG, "simulateDeviation: no active navigation session")
1097
+ return
1098
+ }
1057
1099
  val replayer = replayer ?: run {
1058
1100
  Log.w(TAG, "simulateDeviation: not in simulate mode — nothing to push off-route")
1059
1101
  return
@@ -1138,8 +1180,11 @@ object NavigationManager {
1138
1180
  // onRoute exactly like an automatic reroute. Fire it slightly after
1139
1181
  // the off-route events start playing so the controller reroutes from
1140
1182
  // the diverged position, not the on-route one.
1141
- val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
1142
- mainHandler.postDelayed({
1183
+ deviationRerouteRunnable?.let(simulationHandler::removeCallbacks)
1184
+ lateinit var rerouteRunnable: Runnable
1185
+ rerouteRunnable = Runnable {
1186
+ if (deviationRerouteRunnable === rerouteRunnable) deviationRerouteRunnable = null
1187
+ if (!sessionGuard.acceptsCallback(session)) return@Runnable
1143
1188
  try {
1144
1189
  val controller = mapboxNavigation?.getRerouteController()
1145
1190
  if (controller != null) {
@@ -1148,6 +1193,7 @@ object NavigationManager {
1148
1193
  Log.d(TAG, "simulateDeviation: requesting reroute() directly")
1149
1194
  controller.reroute(
1150
1195
  RerouteController.RoutesCallback { routes: List<NavigationRoute>, _: String ->
1196
+ if (!sessionGuard.acceptsCallback(session)) return@RoutesCallback
1151
1197
  // RoutesObserver(REROUTE) will also fire and emit the route;
1152
1198
  // we don't setNavigationRoutes here (the controller does).
1153
1199
  Log.d(TAG, "simulateDeviation: reroute() returned ${routes.size} route(s)")
@@ -1159,20 +1205,28 @@ object NavigationManager {
1159
1205
  } catch (e: Throwable) {
1160
1206
  Log.e(TAG, "simulateDeviation reroute() failed", e)
1161
1207
  }
1162
- }, 1500L)
1208
+ }
1209
+ deviationRerouteRunnable = rerouteRunnable
1210
+ simulationHandler.postDelayed(rerouteRunnable, 1500L)
1163
1211
 
1164
1212
  // Re-attach the session so it resumes driving the puck once a route is
1165
1213
  // active again (locationResetEnabled snaps it onto the new route).
1166
1214
  // Delay until AFTER the off-route events + reroute fetch.
1167
1215
  val playSeconds = (ts / simulationSpeed.coerceAtLeast(0.1f)).toDouble()
1168
1216
  val reattachMs = ((playSeconds + 4.0) * 1000.0).toLong().coerceIn(5000L, 20000L)
1169
- mainHandler.postDelayed({
1217
+ deviationReattachRunnable?.let(simulationHandler::removeCallbacks)
1218
+ lateinit var reattachRunnable: Runnable
1219
+ reattachRunnable = Runnable {
1220
+ if (deviationReattachRunnable === reattachRunnable) deviationReattachRunnable = null
1221
+ if (!sessionGuard.acceptsCallback(session)) return@Runnable
1170
1222
  try {
1171
1223
  if (simulating) replaySession?.onAttached(nav)
1172
1224
  } catch (e: Throwable) {
1173
1225
  Log.w(TAG, "simulateDeviation: re-attach session failed: ${e.message}")
1174
1226
  }
1175
- }, reattachMs)
1227
+ }
1228
+ deviationReattachRunnable = reattachRunnable
1229
+ simulationHandler.postDelayed(reattachRunnable, reattachMs)
1176
1230
  } catch (e: Throwable) {
1177
1231
  Log.e(TAG, "simulateDeviation push failed", e)
1178
1232
  }
@@ -1199,6 +1253,10 @@ object NavigationManager {
1199
1253
  }
1200
1254
 
1201
1255
  private fun startSkipCrossingsWalker() {
1256
+ val session = sessionGuard.captureActive() ?: run {
1257
+ Log.w(TAG, "startSkipCrossingsWalker: no active navigation session")
1258
+ return
1259
+ }
1202
1260
  val flat = activePolyline
1203
1261
  if (flat == null || flat.size < 2) { Log.w(TAG, "startSkipCrossingsWalker: no route polyline"); return }
1204
1262
  try { replayer?.stop() } catch (_: Throwable) {}
@@ -1215,6 +1273,8 @@ object NavigationManager {
1215
1273
  timer.scheduleAtFixedRate(object : java.util.TimerTask() {
1216
1274
  override fun run() {
1217
1275
  mainHandler.post {
1276
+ if (!sessionGuard.acceptsCallback(session)) return@post
1277
+ if (skipCrossingsTimer !== timer) return@post
1218
1278
  try {
1219
1279
  if (!skipCrossingsEnabled) { cancel(); return@post }
1220
1280
  cursor = advanceCursor(modified, cursor, stepMeters)
@@ -0,0 +1,74 @@
1
+ package com.mentra.crust.navigation
2
+
3
+ /**
4
+ * Separates callbacks from successive uses of the process-wide MapboxNavigation instance.
5
+ * Mapbox observers replay cached values when registered, and route requests can complete
6
+ * after stop(), so callers must pass this guard before touching shared navigation state.
7
+ */
8
+ internal class NavigationSessionGuard {
9
+ @JvmInline
10
+ value class Token internal constructor(internal val value: Long)
11
+
12
+ private var generation = 0L
13
+ private var activeSession = false
14
+ private var activeRouteId: String? = null
15
+ private var locationCallbacksEnabled = false
16
+
17
+ @Synchronized
18
+ fun begin(): Token {
19
+ generation += 1
20
+ activeSession = true
21
+ activeRouteId = null
22
+ locationCallbacksEnabled = false
23
+ return Token(generation)
24
+ }
25
+
26
+ @Synchronized
27
+ fun invalidate() {
28
+ generation += 1
29
+ activeSession = false
30
+ activeRouteId = null
31
+ locationCallbacksEnabled = false
32
+ }
33
+
34
+ @Synchronized
35
+ fun acceptsCallback(token: Token): Boolean = token.value == generation
36
+
37
+ /** Snapshot the active session for work that will execute asynchronously. */
38
+ @Synchronized
39
+ fun captureActive(): Token? = if (activeSession) Token(generation) else null
40
+
41
+ /** Enable locations only after observer registration has replayed its cached values. */
42
+ @Synchronized
43
+ fun enableLocationCallbacks(token: Token) {
44
+ if (token.value == generation) locationCallbacksEnabled = true
45
+ }
46
+
47
+ @Synchronized
48
+ fun acceptsLocation(token: Token): Boolean =
49
+ token.value == generation && locationCallbacksEnabled
50
+
51
+ /** Accept the guarded initial request and establish the route progress may belong to. */
52
+ @Synchronized
53
+ fun activateInitialRoute(token: Token, routeId: String): Boolean {
54
+ if (token.value != generation) return false
55
+ activeRouteId = routeId
56
+ return true
57
+ }
58
+
59
+ /**
60
+ * Accept a route observer update only after this session established its initial route.
61
+ * A different route id is valid here: Mapbox uses the observer for automatic reroutes.
62
+ */
63
+ @Synchronized
64
+ fun acceptRouteUpdate(token: Token, routeId: String): Boolean {
65
+ if (token.value != generation || activeRouteId == null) return false
66
+ activeRouteId = routeId
67
+ return true
68
+ }
69
+
70
+ /** Reject cached or queued progress from another route, including after a reroute. */
71
+ @Synchronized
72
+ fun acceptsProgress(token: Token, routeId: String): Boolean =
73
+ token.value == generation && routeId == activeRouteId
74
+ }
@@ -0,0 +1,68 @@
1
+ package com.mentra.crust.navigation
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class NavigationSessionGuardTest {
8
+ @Test
9
+ fun cachedCallbacksAreRejectedUntilTheCurrentInitialRouteIsReady() {
10
+ val guard = NavigationSessionGuard()
11
+ val session = guard.begin()
12
+
13
+ assertTrue(guard.acceptsCallback(session))
14
+ assertFalse(guard.acceptsLocation(session))
15
+ assertFalse(guard.acceptRouteUpdate(session, "cached-route"))
16
+ assertFalse(guard.acceptsProgress(session, "cached-route"))
17
+
18
+ assertTrue(guard.activateInitialRoute(session, "current-route"))
19
+ assertTrue(guard.acceptsProgress(session, "current-route"))
20
+
21
+ guard.enableLocationCallbacks(session)
22
+ assertTrue(guard.acceptsLocation(session))
23
+ }
24
+
25
+ @Test
26
+ fun stoppedAndSupersededSessionsCannotMutateCurrentState() {
27
+ val guard = NavigationSessionGuard()
28
+ val oldSession = guard.begin()
29
+ assertTrue(guard.activateInitialRoute(oldSession, "old-route"))
30
+
31
+ guard.invalidate()
32
+ assertFalse(guard.acceptsCallback(oldSession))
33
+ guard.enableLocationCallbacks(oldSession)
34
+ assertFalse(guard.acceptsLocation(oldSession))
35
+ assertFalse(guard.activateInitialRoute(oldSession, "late-route"))
36
+ assertFalse(guard.acceptRouteUpdate(oldSession, "late-reroute"))
37
+ assertFalse(guard.acceptsProgress(oldSession, "old-route"))
38
+
39
+ val currentSession = guard.begin()
40
+ assertTrue(guard.activateInitialRoute(currentSession, "current-route"))
41
+ assertTrue(guard.acceptsProgress(currentSession, "current-route"))
42
+ }
43
+
44
+ @Test
45
+ fun rerouteMovesProgressIdentityToTheNewRoute() {
46
+ val guard = NavigationSessionGuard()
47
+ val session = guard.begin()
48
+ assertTrue(guard.activateInitialRoute(session, "initial-route"))
49
+
50
+ assertTrue(guard.acceptRouteUpdate(session, "rerouted-route"))
51
+ assertFalse(guard.acceptsProgress(session, "initial-route"))
52
+ assertTrue(guard.acceptsProgress(session, "rerouted-route"))
53
+ }
54
+
55
+ @Test
56
+ fun delayedWorkCapturedBeforeStopCannotRunInTheNextSession() {
57
+ val guard = NavigationSessionGuard()
58
+ val oldWork = guard.begin()
59
+ assertTrue(guard.captureActive() == oldWork)
60
+
61
+ guard.invalidate()
62
+ assertTrue(guard.captureActive() == null)
63
+ val nextSession = guard.begin()
64
+
65
+ assertFalse(guard.acceptsCallback(oldWork))
66
+ assertTrue(guard.acceptsCallback(nextSession))
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/crust",
3
- "version": "3.3.0-dev.316",
3
+ "version": "3.3.0-dev.320",
4
4
  "description": "Mentra Native Module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  "license": "Apache-2.0",
36
36
  "homepage": "https://github.com/Mentra-Community/MentraOS/tree/dev/mobile/modules/crust#readme",
37
37
  "dependencies": {
38
- "@mentra/jspolyfill": "3.3.0-dev.316"
38
+ "@mentra/jspolyfill": "3.3.0-dev.320"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@expo/config-plugins": ">=8.0.0",