@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.
- package/README.md +27 -0
- package/android/build.gradle +27 -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 +70 -60
- 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/FetchBridgeTest.kt +199 -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/ios/Resources/startup.js +154 -68
- package/ios/Source/JSCPolyfillBridge.swift +31 -14
- 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
|
@@ -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.
|
|
296
|
+
qjs.evaluateForEffect(bundle, filename = "mentrajs:startup.js")
|
|
292
297
|
}
|
|
293
298
|
if (miniappJs.isNotEmpty()) {
|
|
294
|
-
qjs.
|
|
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.
|
|
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]
|
|
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.
|
|
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 (
|
|
509
|
-
|
|
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
|
-
|
|
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
|
+
}
|