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

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 (56) hide show
  1. package/README.md +27 -0
  2. package/android/build.gradle +18 -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 +6 -35
  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/HttpRequestTest.kt +32 -0
  17. package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
  18. package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
  19. package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
  20. package/build/Crust.types.d.ts +25 -11
  21. package/build/Crust.types.d.ts.map +1 -1
  22. package/build/Crust.types.js.map +1 -1
  23. package/build/CrustModule.d.ts +11 -2
  24. package/build/CrustModule.d.ts.map +1 -1
  25. package/build/CrustModule.js.map +1 -1
  26. package/build/CrustModule.web.d.ts +3 -1
  27. package/build/CrustModule.web.d.ts.map +1 -1
  28. package/build/CrustModule.web.js +20 -8
  29. package/build/CrustModule.web.js.map +1 -1
  30. package/build/index.d.ts +1 -0
  31. package/build/index.d.ts.map +1 -1
  32. package/build/index.js +1 -0
  33. package/build/index.js.map +1 -1
  34. package/build/nativeHttpRequest.d.ts +13 -0
  35. package/build/nativeHttpRequest.d.ts.map +1 -0
  36. package/build/nativeHttpRequest.js +51 -0
  37. package/build/nativeHttpRequest.js.map +1 -0
  38. package/build/nativeHttpRequest.test.d.ts +2 -0
  39. package/build/nativeHttpRequest.test.d.ts.map +1 -0
  40. package/build/nativeHttpRequest.test.js +88 -0
  41. package/build/nativeHttpRequest.test.js.map +1 -0
  42. package/ios/Crust.podspec +1 -1
  43. package/ios/CrustModule.swift +22 -7
  44. package/ios/PermissionStatusReceipt.swift +105 -0
  45. package/package.json +2 -2
  46. package/plugin/build/index.d.ts +2 -0
  47. package/plugin/build/index.js +2 -0
  48. package/plugin/build/withAndroid.js +39 -0
  49. package/src/Crust.types.ts +26 -12
  50. package/src/CrustModule.ts +11 -1
  51. package/src/CrustModule.web.ts +18 -7
  52. package/src/index.ts +1 -0
  53. package/src/nativeHttpRequest.test.ts +94 -0
  54. package/src/nativeHttpRequest.ts +74 -0
  55. package/android/src/internal/AndroidManifest.xml +0 -9
  56. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
@@ -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
+ }
@@ -0,0 +1,133 @@
1
+ package com.mentra.crust.http
2
+
3
+ import java.io.IOException
4
+ import java.net.ServerSocket
5
+ import java.util.concurrent.CountDownLatch
6
+ import java.util.concurrent.TimeUnit
7
+ import okhttp3.Call
8
+ import okhttp3.Callback
9
+ import okhttp3.OkHttpClient
10
+ import okhttp3.Protocol
11
+ import okhttp3.Request
12
+ import okhttp3.Response
13
+ import okhttp3.ResponseBody.Companion.toResponseBody
14
+ import okio.Timeout
15
+ import org.junit.Assert.*
16
+ import org.junit.Test
17
+
18
+ class NativeHttpRequestsTest {
19
+ private val request = Request.Builder().url("https://example.com/report").build()
20
+
21
+ private class PendingCall(private val request: Request) : Call {
22
+ var callback: Callback? = null
23
+ var cancelled = false
24
+ var onEnqueue: (() -> Unit)? = null
25
+ override fun request() = request
26
+ override fun execute(): Response = error("Test only exercises asynchronous calls")
27
+ override fun enqueue(responseCallback: Callback) {
28
+ callback = responseCallback
29
+ onEnqueue?.invoke()
30
+ }
31
+ override fun cancel() {cancelled = true}
32
+ override fun isExecuted() = callback != null
33
+ override fun isCanceled() = cancelled
34
+ override fun timeout() = Timeout.NONE
35
+ override fun clone(): Call = PendingCall(request)
36
+ }
37
+
38
+ @Test fun `register before enqueue and cancel only the requested call`() {
39
+ val calls = mutableListOf<PendingCall>()
40
+ val owner = NativeHttpRequests(Call.Factory { req -> PendingCall(req).also {calls.add(it)} })
41
+ owner.enqueue("one", request, {}, {throw it})
42
+ calls[0].onEnqueue = {assertEquals(2, owner.activeCount)}
43
+ owner.enqueue("two", request, {}, {throw it})
44
+ assertEquals(2, owner.activeCount)
45
+ owner.cancel("one")
46
+ assertTrue(calls[0].cancelled)
47
+ assertFalse(calls[1].cancelled)
48
+ assertEquals(1, owner.activeCount)
49
+ owner.cancel("not-owned")
50
+ assertEquals(1, owner.activeCount)
51
+ owner.cancelAll()
52
+ assertTrue(calls[1].cancelled)
53
+ assertEquals(0, owner.activeCount)
54
+ }
55
+
56
+ @Test fun `completion removes the active request and preserves the response`() {
57
+ val call = PendingCall(request)
58
+ val owner = NativeHttpRequests(Call.Factory {call})
59
+ var result: NativeHttpRequests.Result? = null
60
+ call.onEnqueue = {assertEquals(1, owner.activeCount)}
61
+ owner.enqueue("complete", request, {result = it}, {throw it})
62
+ call.callback!!.onResponse(call, Response.Builder().request(request).protocol(Protocol.HTTP_1_1)
63
+ .code(200).message("OK").header("Content-Type", "application/json").body("{}".toResponseBody()).build())
64
+ assertEquals("{}", result!!.body)
65
+ assertEquals("application/json", result!!.headers["content-type"])
66
+ assertEquals(0, owner.activeCount)
67
+ owner.cancel("complete")
68
+ assertFalse(call.cancelled)
69
+ }
70
+
71
+ @Test fun `network failure and synchronous enqueue failure leave no registry entries`() {
72
+ val call = PendingCall(request)
73
+ val owner = NativeHttpRequests(Call.Factory {call})
74
+ var failures = 0
75
+ owner.enqueue("failed", request, {}, {failures++})
76
+ call.callback!!.onFailure(call, IOException("offline"))
77
+ assertEquals(0, owner.activeCount)
78
+ call.onEnqueue = {throw IllegalStateException("dispatcher closed")}
79
+ owner.enqueue("enqueue-failed", request, {}, {failures++})
80
+ assertEquals(0, owner.activeCount)
81
+ assertEquals(2, failures)
82
+ }
83
+
84
+ @Test fun `duplicate IDs cannot replace existing ownership and destruction prevents later requests`() {
85
+ val calls = mutableListOf<PendingCall>()
86
+ val owner = NativeHttpRequests(Call.Factory {req -> PendingCall(req).also {calls.add(it)} })
87
+ var failures = 0
88
+ owner.enqueue("one", request, {}, {failures++})
89
+ owner.enqueue("one", request, {}, {failures++})
90
+ assertEquals(1, failures)
91
+ assertEquals(1, owner.activeCount)
92
+ assertNull(calls[1].callback)
93
+ owner.cancelAll()
94
+ assertTrue(calls[0].cancelled)
95
+ owner.enqueue("later", request, {}, {failures++})
96
+ assertEquals(2, failures)
97
+ assertEquals(2, calls.size)
98
+ assertEquals(0, owner.activeCount)
99
+ }
100
+
101
+ @Test fun `cancel interrupts an actual OkHttp response body and closes the server socket`() {
102
+ val client = OkHttpClient()
103
+ val owner = NativeHttpRequests(client)
104
+ val bodyStarted = CountDownLatch(1)
105
+ val socketClosed = CountDownLatch(1)
106
+ val failed = CountDownLatch(1)
107
+ ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress()).use {server ->
108
+ val thread = Thread {
109
+ server.accept().use {socket ->
110
+ socket.soTimeout = 5_000
111
+ val reader = socket.getInputStream().bufferedReader()
112
+ while (!reader.readLine().isNullOrEmpty()) {}
113
+ socket.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".toByteArray())
114
+ socket.getOutputStream().flush()
115
+ bodyStarted.countDown()
116
+ if (socket.getInputStream().read() == -1) socketClosed.countDown()
117
+ }
118
+ }
119
+ thread.isDaemon = true
120
+ thread.start()
121
+ val req = Request.Builder().url("http://127.0.0.1:${server.localPort}/report").build()
122
+ owner.enqueue("stalled-body", req, {fail("Cancelled body must not complete")}, {failed.countDown()})
123
+ assertTrue("server started body", bodyStarted.await(5, TimeUnit.SECONDS))
124
+ owner.cancel("stalled-body")
125
+ assertTrue("native request rejected", failed.await(5, TimeUnit.SECONDS))
126
+ assertTrue("socket closed", socketClosed.await(5, TimeUnit.SECONDS))
127
+ assertEquals(0, owner.activeCount)
128
+ thread.join(5_000)
129
+ }
130
+ client.dispatcher.executorService.shutdownNow()
131
+ client.connectionPool.evictAll()
132
+ }
133
+ }
@@ -0,0 +1,32 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ import okhttp3.Request
4
+ import okio.Buffer
5
+ import org.junit.Assert.*
6
+ import org.junit.Test
7
+
8
+ class HttpRequestTest {
9
+ private fun request(method: String, body: String? = null): Request =
10
+ JSCPolyfillBridge.buildHttpRequest(method, "https://example.com/stream", mapOf("Content-Type" to "application/json"), body)
11
+
12
+ @Test fun `bodyless post put and patch produce valid zero byte requests`() {
13
+ for (method in listOf("post", "PUT", "PATCH")) for (body in listOf(null, "")) {
14
+ val request = request(method, body)
15
+ assertEquals(method.uppercase(), request.method)
16
+ assertNotNull(request.body)
17
+ assertEquals(0L, request.body!!.contentLength())
18
+ }
19
+ }
20
+
21
+ @Test fun `get head and delete can omit a body`() {
22
+ for (method in listOf("GET", "HEAD", "DELETE")) assertNull(request(method).body)
23
+ }
24
+
25
+ @Test fun `nonempty body and content type survive request construction`() {
26
+ val request = request("POST", "{\"status\":\"stopped\"}")
27
+ val buffer = Buffer()
28
+ request.body!!.writeTo(buffer)
29
+ assertEquals("{\"status\":\"stopped\"}", buffer.readUtf8())
30
+ assertEquals("application/json; charset=utf-8", request.body!!.contentType().toString())
31
+ }
32
+ }