@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.
- package/README.md +27 -0
- package/android/build.gradle +18 -9
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +76 -22
- package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
- package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +6 -35
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +74 -10
- package/android/src/main/java/com/mentra/crust/navigation/NavigationSessionGuard.kt +74 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
- package/android/src/main/java/com/mentra/crust/services/RuntimeRecoveryService.kt +12 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
- package/android/src/test/java/com/mentra/crust/jsc/HttpRequestTest.kt +32 -0
- package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
- package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
- package/build/Crust.types.d.ts +25 -11
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +11 -2
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +3 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +20 -8
- package/build/CrustModule.web.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/nativeHttpRequest.d.ts +13 -0
- package/build/nativeHttpRequest.d.ts.map +1 -0
- package/build/nativeHttpRequest.js +51 -0
- package/build/nativeHttpRequest.js.map +1 -0
- package/build/nativeHttpRequest.test.d.ts +2 -0
- package/build/nativeHttpRequest.test.d.ts.map +1 -0
- package/build/nativeHttpRequest.test.js +88 -0
- package/build/nativeHttpRequest.test.js.map +1 -0
- package/ios/Crust.podspec +1 -1
- package/ios/CrustModule.swift +22 -7
- package/ios/PermissionStatusReceipt.swift +105 -0
- package/package.json +2 -2
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +39 -0
- package/src/Crust.types.ts +26 -12
- package/src/CrustModule.ts +11 -1
- package/src/CrustModule.web.ts +18 -7
- package/src/index.ts +1 -0
- package/src/nativeHttpRequest.test.ts +94 -0
- package/src/nativeHttpRequest.ts +74 -0
- package/android/src/internal/AndroidManifest.xml +0 -9
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1142
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|