@mentra/crust 3.2.1-dev.314 → 3.3.0-beta.1000

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.
Files changed (59) hide show
  1. package/README.md +27 -0
  2. package/android/build.gradle +27 -9
  3. package/android/src/main/AndroidManifest.xml +7 -0
  4. package/android/src/main/java/com/mentra/crust/CrustModule.kt +76 -22
  5. package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
  6. package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
  7. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +70 -60
  8. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
  9. package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
  10. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +74 -10
  11. package/android/src/main/java/com/mentra/crust/navigation/NavigationSessionGuard.kt +74 -0
  12. package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
  13. package/android/src/main/java/com/mentra/crust/services/RuntimeRecoveryService.kt +12 -0
  14. package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
  15. package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
  16. package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
  17. package/android/src/test/java/com/mentra/crust/jsc/HttpRequestTest.kt +32 -0
  18. package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
  19. package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
  20. package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
  21. package/build/Crust.types.d.ts +25 -11
  22. package/build/Crust.types.d.ts.map +1 -1
  23. package/build/Crust.types.js.map +1 -1
  24. package/build/CrustModule.d.ts +11 -2
  25. package/build/CrustModule.d.ts.map +1 -1
  26. package/build/CrustModule.js.map +1 -1
  27. package/build/CrustModule.web.d.ts +3 -1
  28. package/build/CrustModule.web.d.ts.map +1 -1
  29. package/build/CrustModule.web.js +20 -8
  30. package/build/CrustModule.web.js.map +1 -1
  31. package/build/index.d.ts +1 -0
  32. package/build/index.d.ts.map +1 -1
  33. package/build/index.js +1 -0
  34. package/build/index.js.map +1 -1
  35. package/build/nativeHttpRequest.d.ts +13 -0
  36. package/build/nativeHttpRequest.d.ts.map +1 -0
  37. package/build/nativeHttpRequest.js +51 -0
  38. package/build/nativeHttpRequest.js.map +1 -0
  39. package/build/nativeHttpRequest.test.d.ts +2 -0
  40. package/build/nativeHttpRequest.test.d.ts.map +1 -0
  41. package/build/nativeHttpRequest.test.js +88 -0
  42. package/build/nativeHttpRequest.test.js.map +1 -0
  43. package/ios/Crust.podspec +1 -1
  44. package/ios/CrustModule.swift +22 -7
  45. package/ios/PermissionStatusReceipt.swift +105 -0
  46. package/ios/Resources/startup.js +154 -68
  47. package/ios/Source/JSCPolyfillBridge.swift +31 -14
  48. package/package.json +2 -2
  49. package/plugin/build/index.d.ts +2 -0
  50. package/plugin/build/index.js +2 -0
  51. package/plugin/build/withAndroid.js +39 -0
  52. package/src/Crust.types.ts +26 -12
  53. package/src/CrustModule.ts +11 -1
  54. package/src/CrustModule.web.ts +18 -7
  55. package/src/index.ts +1 -0
  56. package/src/nativeHttpRequest.test.ts +94 -0
  57. package/src/nativeHttpRequest.ts +74 -0
  58. package/android/src/internal/AndroidManifest.xml +0 -9
  59. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
@@ -1,6 +1,7 @@
1
1
  package com.mentra.crust.jsc
2
2
 
3
3
  import android.content.Context
4
+ import android.os.SystemClock
4
5
  import android.util.Log
5
6
  import com.dokar.quickjs.QuickJs
6
7
  import com.dokar.quickjs.binding.function
@@ -11,6 +12,7 @@ import java.util.concurrent.Executors
11
12
  import java.util.concurrent.ScheduledExecutorService
12
13
  import java.util.concurrent.ScheduledFuture
13
14
  import java.util.concurrent.TimeUnit
15
+ import java.util.concurrent.atomic.AtomicLong
14
16
  import kotlinx.coroutines.Dispatchers
15
17
  import kotlinx.coroutines.runBlocking
16
18
 
@@ -133,6 +135,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
133
135
  val dispatcher: JSCDispatcher = JSCDispatcher(appContext)
134
136
 
135
137
  private val contexts = ConcurrentHashMap<String, ContextRecord>()
138
+ private val nextContextId = AtomicLong()
136
139
  private val polyfillBundle: String by lazy { loadPolyfillBundle() }
137
140
 
138
141
  // Schedule pool for timers; per-context the executor is the same as the
@@ -146,6 +149,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
146
149
  val packageName: String,
147
150
  val qjs: QuickJs,
148
151
  val executor: ExecutorService,
152
+ val contextId: Long,
149
153
  val pendingTimers: MutableMap<Int, ScheduledFuture<*>> = ConcurrentHashMap(),
150
154
  @Volatile var readyAcked: Boolean = false,
151
155
  @Volatile var readyNackTimer: ScheduledFuture<*>? = null,
@@ -273,6 +277,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
273
277
  packageName = packageName,
274
278
  qjs = qjs,
275
279
  executor = executor,
280
+ contextId = nextContextId.incrementAndGet(),
276
281
  )
277
282
  contexts[packageName] = record
278
283
 
@@ -288,10 +293,10 @@ class JSCRuntime private constructor(private val appContext: Context) {
288
293
  runBlocking {
289
294
  installGlobals(qjs, packageName)
290
295
  if (bundle.isNotEmpty()) {
291
- qjs.evaluate<Any?>(bundle, filename = "mentrajs:startup.js")
296
+ qjs.evaluateForEffect(bundle, filename = "mentrajs:startup.js")
292
297
  }
293
298
  if (miniappJs.isNotEmpty()) {
294
- qjs.evaluate<Any?>(miniappJs, filename = "mentrajs:miniapp.js")
299
+ qjs.evaluateForEffect(miniappJs, filename = "mentrajs:miniapp.js")
295
300
  }
296
301
  }
297
302
  Log.i(TAG, "spawned $packageName")
@@ -415,7 +420,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
415
420
  record.executor.submit {
416
421
  runBlocking {
417
422
  try {
418
- record.qjs.evaluate<Any?>(
423
+ record.qjs.evaluateForEffect(
419
424
  "globalThis.__deliverTimer && globalThis.__deliverTimer($token);",
420
425
  filename = "mentrajs:timer-$token.js",
421
426
  )
@@ -457,10 +462,23 @@ class JSCRuntime private constructor(private val appContext: Context) {
457
462
  * Push a `{kind: "event"|"response", …}` envelope into the named
458
463
  * context's globalThis.__deliver. Hops onto the per-context executor.
459
464
  */
460
- fun dispatchToJs(packageName: String, envelopeJson: String) {
461
- val record = contexts[packageName] ?: return
465
+ fun dispatchToJs(packageName: String, envelopeJson: String, deliveryId: Long? = null) {
466
+ val record = contexts[packageName]
467
+ // Only host-generated PINGs carry this ID. Ordinary media/event deliveries stay quiet.
468
+ val diagnostic = deliveryId?.let {
469
+ JSDeliveryDiagnostics(it, record?.contextId, SystemClock::elapsedRealtime) { metadata ->
470
+ reportDeliveryDiagnostic(packageName, metadata)
471
+ }
472
+ }
473
+ diagnostic?.entry()
474
+ if (record == null) {
475
+ diagnostic?.dropped("context-missing")
476
+ return
477
+ }
478
+ diagnostic?.queued()
462
479
  try {
463
480
  record.executor.submit {
481
+ diagnostic?.started()
464
482
  // Arm only once evaluate is about to run. Queue wait during a
465
483
  // long previous turn (WHIP start, etc.) must not count — that
466
484
  // was firing ready_nack while the context was merely busy.
@@ -489,14 +507,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
489
507
  kill(packageName)
490
508
  }, WATCHDOG_KILL_MS, TimeUnit.MILLISECONDS)
491
509
  record.watchdogTimer = killTimer
510
+ var failure: Throwable? = null
492
511
  try {
493
512
  val source = "globalThis.__deliver(${jsStringLiteral(envelopeJson)});"
494
513
  runBlocking {
495
- record.qjs.evaluate<Any?>(source, filename = "mentrajs:deliver.js")
514
+ record.qjs.evaluateForEffect(source, filename = "mentrajs:deliver.js")
496
515
  }
497
516
  } catch (e: Throwable) {
517
+ failure = e
498
518
  Log.w(TAG, "dispatchToJs threw in $packageName: ${e.message}", e)
519
+ if (diagnostic == null) {
520
+ reportDeliveryDiagnostic(packageName, mapOf(
521
+ "event" to "native-delivery-failed",
522
+ "contextId" to record.contextId,
523
+ "phase" to "evaluate-failed",
524
+ "errorClass" to e.javaClass.simpleName,
525
+ "causeClass" to e.cause?.javaClass?.simpleName,
526
+ ))
527
+ }
499
528
  } finally {
529
+ diagnostic?.finished(failure)
500
530
  // Success or throw: the host observed the turn end.
501
531
  record.readyNackTimer?.cancel(false)
502
532
  record.readyNackTimer = null
@@ -505,8 +535,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
505
535
  if (record.watchdogTimer === killTimer) record.watchdogTimer = null
506
536
  }
507
537
  }
508
- } catch (_: java.util.concurrent.RejectedExecutionException) {
509
- // Context killed mid-flight — drop. dispatchToJs is best-effort.
538
+ } catch (e: java.util.concurrent.RejectedExecutionException) {
539
+ diagnostic?.dropped("executor-rejected", e)
540
+ Log.w(TAG, "dispatchToJs rejected for $packageName", e)
541
+ }
542
+ }
543
+
544
+ private fun reportDeliveryDiagnostic(packageName: String, metadata: Map<String, Any?>) {
545
+ val line = org.json.JSONObject(metadata as Map<*, *>).toString()
546
+ Log.i(TAG, line)
547
+ // Use the existing miniapp log route so console interception includes this in reports.
548
+ try {
549
+ deliverOrDrop(OutboundMessage(packageName, mapOf(
550
+ "packageName" to packageName,
551
+ "iface" to "__log",
552
+ "method" to if (metadata["errorClass"] == null) "log" else "warn",
553
+ "argsJson" to org.json.JSONArray().put(org.json.JSONObject(metadata as Map<*, *>)).toString(),
554
+ )))
555
+ } catch (e: Throwable) {
556
+ // Observability cannot change delivery or hide its original failure.
557
+ Log.w(TAG, "delivery diagnostic forwarding failed: ${e.javaClass.simpleName}")
510
558
  }
511
559
  }
512
560
 
@@ -542,6 +590,15 @@ class JSCRuntime private constructor(private val appContext: Context) {
542
590
 
543
591
  // ------------------------------------------------------------------
544
592
 
593
+ /**
594
+ * Evaluate for side effects. A completion value is never converted to
595
+ * Kotlin: quickjs-kt 1.0.15 hangs converting a Set, Map or array that
596
+ * contains itself, and miniapp code chooses its bundle's last value.
597
+ */
598
+ private suspend fun QuickJs.evaluateForEffect(source: String, filename: String) {
599
+ evaluate<Any?>("$source\n;undefined", filename = filename)
600
+ }
601
+
545
602
  private fun parseArgsEnvelope(argsJson: String): Pair<List<Any?>, String?> {
546
603
  return try {
547
604
  if (argsJson.startsWith("{")) {
@@ -0,0 +1,44 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ /** Metadata for one host PING. Never retains the envelope or exception message. */
4
+ internal class JSDeliveryDiagnostics(
5
+ private val deliveryId: Long,
6
+ private val contextId: Long?,
7
+ private val clockMs: () -> Long,
8
+ private val emit: (Map<String, Any?>) -> Unit,
9
+ ) {
10
+ private val enteredAtMs = clockMs()
11
+ private var queuedAtMs: Long? = null
12
+ private var startedAtMs: Long? = null
13
+
14
+ fun entry() = report("native-entry")
15
+
16
+ fun queued() {
17
+ queuedAtMs = clockMs()
18
+ report("queued")
19
+ }
20
+
21
+ fun started() {
22
+ startedAtMs = clockMs()
23
+ report("started")
24
+ }
25
+
26
+ fun finished(error: Throwable? = null) = report(if (error == null) "finished" else "evaluate-failed", error)
27
+
28
+ fun dropped(reason: String, error: Throwable? = null) = report(reason, error)
29
+
30
+ private fun report(phase: String, error: Throwable? = null) {
31
+ val now = clockMs()
32
+ emit(mapOf(
33
+ "event" to "native-ping-delivery",
34
+ "deliveryId" to deliveryId,
35
+ "contextId" to contextId,
36
+ "phase" to phase,
37
+ "elapsedMs" to now - enteredAtMs,
38
+ "queueWaitMs" to queuedAtMs?.let { (startedAtMs ?: now) - it },
39
+ "evaluateMs" to startedAtMs?.let { now - it },
40
+ "errorClass" to error?.javaClass?.simpleName,
41
+ "causeClass" to error?.cause?.javaClass?.simpleName,
42
+ ))
43
+ }
44
+ }
@@ -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,25 +459,34 @@ 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()) {
464
+ stop()
450
465
  callbacks.onError("no route found")
451
466
  return
452
467
  }
468
+ val route = routes.first()
469
+ if (!sessionGuard.activateInitialRoute(session, route.id)) return
453
470
  nav.setNavigationRoutes(routes)
454
471
  // In sim mode the ReplayRouteSession observes setNavigationRoutes
455
472
  // and starts driving the puck automatically — no manual replay
456
473
  // kick needed here.
457
- emitRoute(routes.first(), callbacks)
474
+ emitRoute(route, callbacks)
458
475
  }
459
476
 
460
477
  override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
478
+ if (!sessionGuard.acceptsCallback(session)) return
461
479
  val msg = reasons.firstOrNull()?.message ?: "route request failed"
462
480
  Log.e(TAG, "requestRoutes failed: $msg")
481
+ stop()
463
482
  callbacks.onError(msg)
464
483
  }
465
484
 
466
485
  override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
486
+ if (!sessionGuard.acceptsCallback(session)) return
467
487
  Log.w(TAG, "route request canceled")
488
+ stop()
489
+ callbacks.onError("route request canceled")
468
490
  }
469
491
  },
470
492
  )
@@ -588,7 +610,11 @@ object NavigationManager {
588
610
 
589
611
  fun stop() {
590
612
  Log.d(TAG, "stop")
591
- val nav = mapboxNavigation
613
+ sessionGuard.invalidate()
614
+ cleanupSession(mapboxNavigation)
615
+ }
616
+
617
+ private fun cleanupSession(nav: MapboxNavigation?) {
592
618
  activeCallbacks = null
593
619
  lastEmittedKey = null
594
620
  simulating = false
@@ -597,6 +623,10 @@ object NavigationManager {
597
623
  // Tear down dev walkers first.
598
624
  skipCrossingsEnabled = false
599
625
  skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
626
+ deviationRerouteRunnable?.let(simulationHandler::removeCallbacks)
627
+ deviationRerouteRunnable = null
628
+ deviationReattachRunnable?.let(simulationHandler::removeCallbacks)
629
+ deviationReattachRunnable = null
600
630
  wrongSidewalkOffsetEnabled = false
601
631
 
602
632
  if (nav != null) {
@@ -637,7 +667,11 @@ object NavigationManager {
637
667
  // Observers — translate Mapbox events into the existing Callbacks shape.
638
668
  // =====================================================================
639
669
 
640
- private fun attachObservers(nav: MapboxNavigation, callbacks: Callbacks) {
670
+ private fun attachObservers(
671
+ nav: MapboxNavigation,
672
+ callbacks: Callbacks,
673
+ session: NavigationSessionGuard.Token,
674
+ ) {
641
675
  // REROUTING IS FULLY OWNED BY MAPBOX. Auto-reroute is ON by default in
642
676
  // v3 — when the user diverges, the SDK detects off-route AND fetches a
643
677
  // new route on its own. We do NOT hand-roll perpendicular-distance
@@ -653,6 +687,7 @@ object NavigationManager {
653
687
  // clear our off-route flag and (for the host) reads as a fresh route.
654
688
  val routesObs = RoutesObserver { result: RoutesUpdatedResult ->
655
689
  val route = result.navigationRoutes.firstOrNull() ?: return@RoutesObserver
690
+ if (!sessionGuard.acceptRouteUpdate(session, route.id)) return@RoutesObserver
656
691
  val reason = result.reason
657
692
  // Ignore refresh (same geometry, just live traffic) — it would
658
693
  // needlessly re-emit the whole polyline + rebuild pivots.
@@ -669,6 +704,7 @@ object NavigationManager {
669
704
  // RouteProgressObserver — every location tick. Carries current step,
670
705
  // upcoming maneuver, distance-to-maneuver, and trip totals.
671
706
  val progressObs = RouteProgressObserver { progress: RouteProgress ->
707
+ if (!sessionGuard.acceptsProgress(session, progress.navigationRoute.id)) return@RouteProgressObserver
672
708
  handleRouteProgress(progress, callbacks)
673
709
  }
674
710
  routeProgressObserver = progressObs
@@ -679,17 +715,22 @@ object NavigationManager {
679
715
  val locObs = object : LocationObserver {
680
716
  override fun onNewRawLocation(rawLocation: Location) { /* prefer matched */ }
681
717
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
718
+ if (!sessionGuard.acceptsLocation(session)) return
682
719
  handleLocation(result, callbacks)
683
720
  }
684
721
  }
685
722
  locationObserver = locObs
686
723
  nav.registerLocationObserver(locObs)
724
+ // Mapbox synchronously replays its cached matcher result from register().
725
+ // Only locations delivered after registration belong to this fresh trip.
726
+ sessionGuard.enableLocationCallbacks(session)
687
727
 
688
728
  // OffRouteObserver — Mapbox's automatic off-route edge signal. PURELY
689
729
  // OBSERVATIONAL: registering it does NOT disable auto-reroute. We
690
730
  // surface onOffRoute for the UI's "off route" banner; the SDK handles
691
731
  // the actual reroute and the new route arrives via RoutesObserver.
692
732
  val offRouteObs = OffRouteObserver { offRoute: Boolean ->
733
+ if (!sessionGuard.acceptsCallback(session)) return@OffRouteObserver
693
734
  if (offRoute && !offRouteFired) {
694
735
  offRouteFired = true
695
736
  // Distance from the route is best-effort context for the banner;
@@ -707,6 +748,7 @@ object NavigationManager {
707
748
  // route then lands via RoutesObserver(REROUTE). Available only while
708
749
  // auto-reroute is enabled (getRerouteController() is null otherwise).
709
750
  val rerouteObs = RerouteController.RerouteStateObserver { state: RerouteState ->
751
+ if (!sessionGuard.acceptsCallback(session)) return@RerouteStateObserver
710
752
  when (state) {
711
753
  is RerouteState.FetchingRoute -> {
712
754
  Log.d(TAG, "reroute state: FetchingRoute → onRerouting")
@@ -1054,6 +1096,10 @@ object NavigationManager {
1054
1096
  */
1055
1097
  @OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
1056
1098
  fun simulateDeviation(offsetMeters: Double = 0.0) {
1099
+ val session = sessionGuard.captureActive() ?: run {
1100
+ Log.w(TAG, "simulateDeviation: no active navigation session")
1101
+ return
1102
+ }
1057
1103
  val replayer = replayer ?: run {
1058
1104
  Log.w(TAG, "simulateDeviation: not in simulate mode — nothing to push off-route")
1059
1105
  return
@@ -1138,8 +1184,11 @@ object NavigationManager {
1138
1184
  // onRoute exactly like an automatic reroute. Fire it slightly after
1139
1185
  // the off-route events start playing so the controller reroutes from
1140
1186
  // the diverged position, not the on-route one.
1141
- val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
1142
- mainHandler.postDelayed({
1187
+ deviationRerouteRunnable?.let(simulationHandler::removeCallbacks)
1188
+ lateinit var rerouteRunnable: Runnable
1189
+ rerouteRunnable = Runnable {
1190
+ if (deviationRerouteRunnable === rerouteRunnable) deviationRerouteRunnable = null
1191
+ if (!sessionGuard.acceptsCallback(session)) return@Runnable
1143
1192
  try {
1144
1193
  val controller = mapboxNavigation?.getRerouteController()
1145
1194
  if (controller != null) {
@@ -1148,6 +1197,7 @@ object NavigationManager {
1148
1197
  Log.d(TAG, "simulateDeviation: requesting reroute() directly")
1149
1198
  controller.reroute(
1150
1199
  RerouteController.RoutesCallback { routes: List<NavigationRoute>, _: String ->
1200
+ if (!sessionGuard.acceptsCallback(session)) return@RoutesCallback
1151
1201
  // RoutesObserver(REROUTE) will also fire and emit the route;
1152
1202
  // we don't setNavigationRoutes here (the controller does).
1153
1203
  Log.d(TAG, "simulateDeviation: reroute() returned ${routes.size} route(s)")
@@ -1159,20 +1209,28 @@ object NavigationManager {
1159
1209
  } catch (e: Throwable) {
1160
1210
  Log.e(TAG, "simulateDeviation reroute() failed", e)
1161
1211
  }
1162
- }, 1500L)
1212
+ }
1213
+ deviationRerouteRunnable = rerouteRunnable
1214
+ simulationHandler.postDelayed(rerouteRunnable, 1500L)
1163
1215
 
1164
1216
  // Re-attach the session so it resumes driving the puck once a route is
1165
1217
  // active again (locationResetEnabled snaps it onto the new route).
1166
1218
  // Delay until AFTER the off-route events + reroute fetch.
1167
1219
  val playSeconds = (ts / simulationSpeed.coerceAtLeast(0.1f)).toDouble()
1168
1220
  val reattachMs = ((playSeconds + 4.0) * 1000.0).toLong().coerceIn(5000L, 20000L)
1169
- mainHandler.postDelayed({
1221
+ deviationReattachRunnable?.let(simulationHandler::removeCallbacks)
1222
+ lateinit var reattachRunnable: Runnable
1223
+ reattachRunnable = Runnable {
1224
+ if (deviationReattachRunnable === reattachRunnable) deviationReattachRunnable = null
1225
+ if (!sessionGuard.acceptsCallback(session)) return@Runnable
1170
1226
  try {
1171
1227
  if (simulating) replaySession?.onAttached(nav)
1172
1228
  } catch (e: Throwable) {
1173
1229
  Log.w(TAG, "simulateDeviation: re-attach session failed: ${e.message}")
1174
1230
  }
1175
- }, reattachMs)
1231
+ }
1232
+ deviationReattachRunnable = reattachRunnable
1233
+ simulationHandler.postDelayed(reattachRunnable, reattachMs)
1176
1234
  } catch (e: Throwable) {
1177
1235
  Log.e(TAG, "simulateDeviation push failed", e)
1178
1236
  }
@@ -1199,6 +1257,10 @@ object NavigationManager {
1199
1257
  }
1200
1258
 
1201
1259
  private fun startSkipCrossingsWalker() {
1260
+ val session = sessionGuard.captureActive() ?: run {
1261
+ Log.w(TAG, "startSkipCrossingsWalker: no active navigation session")
1262
+ return
1263
+ }
1202
1264
  val flat = activePolyline
1203
1265
  if (flat == null || flat.size < 2) { Log.w(TAG, "startSkipCrossingsWalker: no route polyline"); return }
1204
1266
  try { replayer?.stop() } catch (_: Throwable) {}
@@ -1215,6 +1277,8 @@ object NavigationManager {
1215
1277
  timer.scheduleAtFixedRate(object : java.util.TimerTask() {
1216
1278
  override fun run() {
1217
1279
  mainHandler.post {
1280
+ if (!sessionGuard.acceptsCallback(session)) return@post
1281
+ if (skipCrossingsTimer !== timer) return@post
1218
1282
  try {
1219
1283
  if (!skipCrossingsEnabled) { cancel(); return@post }
1220
1284
  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,76 @@
1
+ package com.mentra.crust.preview
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Rect
6
+ import android.net.Uri
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.view.PixelCopy
10
+ import android.view.View
11
+ import java.io.File
12
+ import kotlin.coroutines.resumeWithException
13
+ import kotlinx.coroutines.Dispatchers
14
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
15
+ import kotlinx.coroutines.suspendCancellableCoroutine
16
+ import kotlinx.coroutines.withContext
17
+
18
+ /** Copies the displayed miniapp pixels without invoking WebView's software renderer. */
19
+ object PixelCopyPreview {
20
+ @OptIn(ExperimentalCoroutinesApi::class)
21
+ suspend fun capture(activity: Activity, viewTag: Int): String =
22
+ withContext(Dispatchers.Main.immediate) {
23
+ check(!activity.isFinishing && !activity.isDestroyed) { "Screenshot activity is unavailable" }
24
+ val window = activity.window
25
+ val decor = window.decorView
26
+ val view = decor.findViewById<View>(viewTag)
27
+ ?: error("Screenshot view $viewTag is unavailable")
28
+ check(view.isAttachedToWindow && view.isLaidOut && view.width > 0 && view.height > 0) {
29
+ "Screenshot view is not ready"
30
+ }
31
+
32
+ val location = IntArray(2)
33
+ view.getLocationInWindow(location)
34
+ val bounds = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
35
+ check(Rect(0, 0, decor.width, decor.height).contains(bounds)) {
36
+ "Screenshot view must be fully inside the window"
37
+ }
38
+
39
+ val bitmap = Bitmap.createBitmap(bounds.width(), bounds.height(), Bitmap.Config.ARGB_8888)
40
+ val captured = suspendCancellableCoroutine<Bitmap> { continuation ->
41
+ try {
42
+ PixelCopy.request(window, bounds, bitmap, { result ->
43
+ if (result == PixelCopy.SUCCESS) {
44
+ // A cancelled request still owns its bitmap until PixelCopy finishes.
45
+ continuation.resume(bitmap) { bitmap.recycle() }
46
+ } else {
47
+ bitmap.recycle()
48
+ continuation.resumeWithException(IllegalStateException("PixelCopy failed: $result"))
49
+ }
50
+ }, Handler(Looper.getMainLooper()))
51
+ } catch (error: Exception) {
52
+ bitmap.recycle()
53
+ continuation.resumeWithException(error)
54
+ }
55
+ }
56
+
57
+ try {
58
+ withContext(Dispatchers.IO) {
59
+ val file = File.createTempFile("miniapp-preview-", ".jpg", activity.cacheDir)
60
+ try {
61
+ file.outputStream().use { output ->
62
+ check(captured.compress(Bitmap.CompressFormat.JPEG, 50, output)) {
63
+ "Unable to encode miniapp screenshot"
64
+ }
65
+ }
66
+ Uri.fromFile(file).toString()
67
+ } catch (error: Exception) {
68
+ file.delete()
69
+ throw error
70
+ }
71
+ }
72
+ } finally {
73
+ captured.recycle()
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,12 @@
1
+ package com.mentra.crust.services
2
+
3
+ import android.content.Intent
4
+ import com.facebook.react.HeadlessJsTaskService
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig
7
+
8
+ /** Boots the normal app runtime while the Bluetooth foreground service keeps it alive. */
9
+ class RuntimeRecoveryService : HeadlessJsTaskService() {
10
+ override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
11
+ HeadlessJsTaskConfig("MentraRuntimeRecovery", Arguments.createMap(), 120_000L, true)
12
+ }
@@ -0,0 +1,44 @@
1
+ package com.mentra.crust.http
2
+
3
+ import java.io.IOException
4
+ import java.io.InterruptedIOException
5
+ import java.net.ConnectException
6
+ import java.net.NoRouteToHostException
7
+ import java.net.SocketException
8
+ import java.net.SocketTimeoutException
9
+ import java.net.UnknownHostException
10
+ import javax.net.ssl.SSLHandshakeException
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Test
13
+
14
+ class NativeHttpFailureTest {
15
+ @Test fun `preserves failure category without exception text`() {
16
+ val privateMessage = "https://private.example?token=secret SSID private-address"
17
+ val cases = listOf(
18
+ UnknownHostException(privateMessage) to "E_NATIVE_HTTP_DNS",
19
+ ConnectException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
20
+ NoRouteToHostException(privateMessage) to "E_NATIVE_HTTP_CONNECT",
21
+ SocketTimeoutException(privateMessage) to "E_NATIVE_HTTP_TIMEOUT",
22
+ SSLHandshakeException(privateMessage) to "E_NATIVE_HTTP_TLS",
23
+ SocketException(privateMessage) to "E_NATIVE_HTTP_SOCKET",
24
+ InterruptedIOException(privateMessage) to "E_NATIVE_HTTP_INTERRUPTED",
25
+ IOException(privateMessage) to "E_NATIVE_HTTP_IO",
26
+ IllegalStateException(privateMessage) to "E_NATIVE_HTTP_OTHER",
27
+ )
28
+ cases.forEach { (error, expected) -> assertEquals(expected, nativeHttpFailureCode(error)) }
29
+ }
30
+
31
+ @Test fun `retains a specific cause inside a generic native wrapper`() {
32
+ val wrapped = IOException("private wrapper", UnknownHostException("private hostname"))
33
+ assertEquals("E_NATIVE_HTTP_DNS", nativeHttpFailureCode(wrapped))
34
+ assertEquals("E_NATIVE_HTTP_IO", nativeHttpFailureCode(IllegalStateException(IOException("private"))))
35
+ }
36
+
37
+ @Test fun `never parses a category from private error text and bounds cyclic causes`() {
38
+ assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(Exception("E_NATIVE_HTTP_DNS")))
39
+ val first = Exception("private one")
40
+ val second = Exception("private two", first)
41
+ first.initCause(second)
42
+ assertEquals("E_NATIVE_HTTP_OTHER", nativeHttpFailureCode(first))
43
+ }
44
+ }