@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
package/README.md CHANGED
@@ -31,6 +31,13 @@ core-library desugaring. Add it to the host app's Expo config:
31
31
  Building with the navigation feature requires a `MAPBOX_DOWNLOADS_TOKEN` in
32
32
  the Android build environment (Mapbox's SDK repository is authenticated).
33
33
 
34
+ The plugin also raises the host's Android build to Kotlin 2.3.0 (crust's
35
+ QuickJS library, `quickjs-kt` 1.0.15, is compiled with Kotlin 2.4) and pins
36
+ the root Kotlin Gradle plugin to that version. Every native module in the
37
+ host then compiles with Kotlin 2.3. Known incompatibility:
38
+ `react-native-gesture-handler` before 2.31.0 fails to compile; use 2.31.0 or
39
+ later.
40
+
34
41
  At build time the Android side also reads the MentraJS polyfill bundle from
35
42
  its [`@mentra/jspolyfill`](https://www.npmjs.com/package/@mentra/jspolyfill)
36
43
  sibling, which is declared as a dependency.
@@ -39,3 +46,23 @@ sibling, which is declared as a dependency.
39
46
 
40
47
  Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
41
48
  under `mobile/modules/crust`. Issues and contributions welcome there.
49
+
50
+ ## Android native HTTP cancellation
51
+
52
+ Engine's cloud client uses `nativeHttpRequest(CrustModule, method, url, headers,
53
+ body, signal?)` from `@mentra/crust`. The helper preserves Android's native
54
+ background HTTP transport and connects the caller's `AbortSignal` to cancellation
55
+ of that exact OkHttp call. Request IDs and native call ownership stay inside the
56
+ transport. Completion removes its listener and native registry entry; module
57
+ shutdown cancels only that module's outstanding host requests.
58
+
59
+ Engine callers use the ordinary cloud-client API with a signal. They do not call
60
+ native start/cancel methods or maintain their own request registry.
61
+
62
+ Android native HTTP failures carry fixed `E_NATIVE_HTTP_*` codes for DNS,
63
+ connection, timeout, TLS, socket, interrupted I/O, generic I/O, or other failures.
64
+ The cloud client records only those allowlisted codes in its existing network
65
+ warning; report submission still rejects with `NETWORK_ERROR`. Exception text,
66
+ URLs, credentials and stacks are not copied into that warning. These categories
67
+ describe a failed request; they do not establish the phone's network routing or
68
+ why its Internet connection was unavailable.
@@ -82,6 +82,9 @@ android {
82
82
  }
83
83
 
84
84
  dependencies {
85
+ testImplementation 'junit:junit:4.13.2'
86
+
87
+ implementation 'com.facebook.react:react-android'
85
88
  // Mapbox Navigation SDK v3 (replaces Google Navigation SDK).
86
89
  // Drives headless turn-by-turn nav in NavigationManager.kt: route
87
90
  // progress, off-route + automatic rerouting, enhanced (map-matched)
@@ -122,13 +125,10 @@ dependencies {
122
125
  // for installing Kotlin lambdas as JS globals via `JS_SetPropertyStr`.
123
126
  // Mirrors iOS Apple JSC's `setObject(_, forKeyedSubscript:)` pattern.
124
127
  //
125
- // Version pin: this Expo SDK 55 build uses Kotlin 2.1.20, which can read
126
- // metadata up to 2.2.0. dokar3 1.0.1+ ships classes with metadata 2.3.0
127
- // (compiled with Kotlin 2.3.x) and is unreadable here. 1.0.0-alpha13 is
128
- // the last release compiled with Kotlin 2.0 (metadata 2.0.0) — its API
129
- // (`QuickJs.create`, `evaluate`, `function` extension) matches 1.0.5 1:1.
130
- // Bump only when the host Kotlin compiler clears 2.3.0 metadata.
131
- implementation 'io.github.dokar3:quickjs-kt-android:1.0.0-alpha13'
128
+ // 1.0.15 is compiled with Kotlin 2.4.10. The crust config plugin raises the
129
+ // host to Kotlin 2.3.0, which reads one metadata version ahead. The AAR ships
130
+ // its own 16 KB-aligned JNI libraries and R8 keep rules.
131
+ implementation 'io.github.dokar3:quickjs-kt-android:1.0.15'
132
132
 
133
133
  // OkHttp for fetch / WebSocket — already used elsewhere in MentraOS;
134
134
  // pinned here so the gradle plugin doesn't downgrade us.
@@ -141,8 +141,8 @@ dependencies {
141
141
 
142
142
  // Kotlin coroutines: dokar3's `evaluate()` is suspend; we bridge it via
143
143
  // `runBlocking(dispatcher)` from the per-miniapp single-thread executor.
144
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1'
145
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
144
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0'
145
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
146
146
  }
147
147
 
148
148
  // Notification lifecycle tests run against Android APIs without a device.
@@ -150,3 +150,12 @@ dependencies {
150
150
  testImplementation 'junit:junit:4.13.2'
151
151
  testImplementation 'org.robolectric:robolectric:4.11.1'
152
152
  }
153
+
154
+ // A desktop JVM cannot load the Android QuickJS library, so local unit tests
155
+ // run the same QuickJS build through its desktop JVM artifact.
156
+ configurations.matching { it.name.endsWith('UnitTestRuntimeClasspath') }.configureEach {
157
+ resolutionStrategy.dependencySubstitution {
158
+ substitute(module('io.github.dokar3:quickjs-kt-android'))
159
+ .using(module('io.github.dokar3:quickjs-kt-jvm:1.0.15'))
160
+ }
161
+ }
@@ -5,6 +5,13 @@
5
5
  <uses-permission android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE" />
6
6
 
7
7
  <application>
8
+
9
+ <meta-data
10
+ android:name="com.mentra.bluetoothsdk.RUNTIME_RECOVERY_SERVICE"
11
+ android:value="com.mentra.crust.services.RuntimeRecoveryService" />
12
+ <service
13
+ android:name="com.mentra.crust.services.RuntimeRecoveryService"
14
+ android:exported="false" />
8
15
  <service
9
16
  android:name="com.mentra.crust.services.NotificationListenerServiceImpl"
10
17
  android:label="@string/mentra_crust_notification_listener_label"
@@ -6,13 +6,24 @@ import com.mentra.crust.services.NotificationListener
6
6
  import com.mentra.crust.services.NotificationProcessBridge
7
7
  import expo.modules.kotlin.modules.Module
8
8
  import expo.modules.kotlin.modules.ModuleDefinition
9
+ import expo.modules.kotlin.functions.Queues
10
+ import expo.modules.kotlin.functions.Coroutine
9
11
  import java.net.URL
12
+ import java.util.concurrent.Executors
13
+ import kotlinx.coroutines.CoroutineName
14
+ import kotlinx.coroutines.CoroutineScope
15
+ import kotlinx.coroutines.SupervisorJob
16
+ import kotlinx.coroutines.asCoroutineDispatcher
17
+ import kotlinx.coroutines.cancel
10
18
 
11
19
  import com.mentra.crust.navigation.NavigationManager
12
20
  import com.mentra.crust.heading.HeadingManager
21
+ import com.mentra.crust.preview.PixelCopyPreview
13
22
  import com.mentra.crust.jsc.JSCRuntime
14
23
  import com.mentra.crust.jsc.InstalledMiniappManifest
15
24
  import com.mentra.crust.jsc.JSCPolyfillBridge
25
+ import com.mentra.crust.http.NativeHttpRequests
26
+ import com.mentra.crust.http.nativeHttpFailureCode
16
27
 
17
28
  class CrustModule : Module() {
18
29
  companion object {
@@ -50,10 +61,6 @@ class CrustModule : Module() {
50
61
  NotificationProcessBridge.emitDismissed(context, notificationKey, packageName)
51
62
  }
52
63
 
53
- fun emitCaptionsTesterIncident(data: Map<String, Any>) {
54
- emitEvent("captions_tester_incident", data)
55
- }
56
-
57
64
  private fun emitEvent(eventName: String, data: Map<String, Any>) {
58
65
  val emitter = eventEmitter
59
66
  if (emitter == null) {
@@ -76,8 +83,25 @@ class CrustModule : Module() {
76
83
  // __dispatch from a per-miniapp QuickJS context (SUBSCRIBE, mic, location,
77
84
  // display, send, etc.) would be silently dropped on Android.
78
85
  @Volatile private var runtimeInstalled: Boolean = false
86
+
87
+ // MentraJS calls get their own serial queue. Expo runs every module's default-queue
88
+ // AsyncFunction on one shared thread, so a blocking call in another module held every
89
+ // host→miniapp message behind it: the ACS scoped Wi-Fi join waits on the glasses hotspot for
90
+ // tens of seconds, the miniapp never saw the host's PING, and the host respawned Mentra Call
91
+ // mid-join. Serial, so spawn, dispatch and kill still run in the order JS issued them.
92
+ private val mentraJsExecutor =
93
+ Executors.newSingleThreadExecutor { r ->
94
+ Thread(r, "MentraJS-bridge").apply { isDaemon = true }
95
+ }
96
+ private val mentraJsQueue =
97
+ CoroutineScope(
98
+ mentraJsExecutor.asCoroutineDispatcher() +
99
+ SupervisorJob() +
100
+ CoroutineName("MentraJS-bridge"),
101
+ )
79
102
  private var notificationEventReceiver: BroadcastReceiver? = null
80
103
  private var notificationBridgeContext: android.content.Context? = null
104
+ private val nativeHttpRequests = NativeHttpRequests(JSCPolyfillBridge.httpClient)
81
105
 
82
106
  private fun registerNotificationBridgeIfPossible(): Boolean {
83
107
  if (notificationEventReceiver != null) return true
@@ -120,7 +144,6 @@ class CrustModule : Module() {
120
144
  "onChange",
121
145
  "phone_notification",
122
146
  "phone_notification_dismissed",
123
- "captions_tester_incident",
124
147
  "onNavManeuver",
125
148
  "onNavRerouting",
126
149
  "onNavArrived",
@@ -149,6 +172,9 @@ class CrustModule : Module() {
149
172
  notificationEventReceiver = null
150
173
  notificationBridgeContext = null
151
174
  eventEmitter = null
175
+ nativeHttpRequests.cancelAll()
176
+ mentraJsQueue.cancel()
177
+ mentraJsExecutor.shutdown()
152
178
  }
153
179
 
154
180
  Function("hello") {
@@ -159,17 +185,41 @@ class CrustModule : Module() {
159
185
  sendEvent("onChange", mapOf("value" to value))
160
186
  }
161
187
 
188
+ AsyncFunction("captureMiniappPreview") Coroutine { viewTag: Int ->
189
+ val activity = appContext.currentActivity ?: error("No activity available for screenshot")
190
+ PixelCopyPreview.capture(activity, viewTag)
191
+ }
192
+
162
193
  AsyncFunction("nativeHttpRequest") {
163
- method: String, url: String, headers: Map<String, String>, body: String? ->
164
- val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
165
- mapOf(
166
- "status" to result.status,
167
- "statusText" to result.statusText,
168
- "headers" to result.headers,
169
- "body" to result.body,
194
+ requestId: String, method: String, url: String, headers: Map<String, String>, body: String?, promise: expo.modules.kotlin.Promise ->
195
+ val request = try {
196
+ JSCPolyfillBridge.buildHttpRequest(method, url, headers, body)
197
+ } catch (error: Throwable) {
198
+ promise.reject("E_NATIVE_HTTP", error.message ?: "Native HTTP request failed", error)
199
+ return@AsyncFunction
200
+ }
201
+ nativeHttpRequests.enqueue(
202
+ requestId,
203
+ request,
204
+ onResult = { result ->
205
+ promise.resolve(
206
+ mapOf(
207
+ "status" to result.status,
208
+ "statusText" to result.statusText,
209
+ "headers" to result.headers,
210
+ "body" to result.body,
211
+ )
212
+ )
213
+ },
214
+ onError = { error -> promise.reject(nativeHttpFailureCode(error), error.message ?: "Native HTTP request failed", error) },
170
215
  )
171
216
  }
172
217
 
218
+ // Same serial Expo queue as nativeHttpRequest: cancellation cannot precede registration.
219
+ AsyncFunction("cancelNativeHttpRequest") { requestId: String ->
220
+ nativeHttpRequests.cancel(requestId)
221
+ }
222
+
173
223
  Function("showAVRoutePicker") { _: String? ->
174
224
  // iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
175
225
  }
@@ -254,7 +304,7 @@ class CrustModule : Module() {
254
304
  polyfillBundleOverride = polyfillBundle.takeIf { it.isNotEmpty() },
255
305
  miniappJs = miniappJs,
256
306
  )
257
- }
307
+ }.runOnQueue(mentraJsQueue)
258
308
 
259
309
  AsyncFunction("mentraJsEvaluate") { packageName: String, source: String ->
260
310
  val ctx =
@@ -262,7 +312,7 @@ class CrustModule : Module() {
262
312
  ?: appContext.currentActivity
263
313
  ?: throw IllegalStateException("MentraJS: no context")
264
314
  JSCRuntime.shared(ctx).evaluate(packageName, source)
265
- }
315
+ }.runOnQueue(mentraJsQueue)
266
316
 
267
317
  AsyncFunction("mentraJsKill") { packageName: String ->
268
318
  val ctx =
@@ -270,7 +320,7 @@ class CrustModule : Module() {
270
320
  ?: appContext.currentActivity
271
321
  ?: throw IllegalStateException("MentraJS: no context")
272
322
  JSCRuntime.shared(ctx).kill(packageName)
273
- }
323
+ }.runOnQueue(mentraJsQueue)
274
324
 
275
325
  AsyncFunction("mentraJsDispatchToJs") { packageName: String, envelope: Map<String, Any?> ->
276
326
  val ctx =
@@ -278,8 +328,9 @@ class CrustModule : Module() {
278
328
  ?: appContext.currentActivity
279
329
  ?: throw IllegalStateException("MentraJS: no context")
280
330
  val json = org.json.JSONObject(envelope as Map<*, *>).toString()
281
- JSCRuntime.shared(ctx).dispatchToJs(packageName, json)
282
- }
331
+ val deliveryId = (envelope["deliveryId"] as? Number)?.toLong()?.takeIf { it > 0 }
332
+ JSCRuntime.shared(ctx).dispatchToJs(packageName, json, deliveryId)
333
+ }.runOnQueue(mentraJsQueue)
283
334
 
284
335
  AsyncFunction("mentraJsSetManifest") { packageName: String, permissions: List<String> ->
285
336
  val ctx =
@@ -290,7 +341,7 @@ class CrustModule : Module() {
290
341
  packageName,
291
342
  InstalledMiniappManifest(permissions.toSet()),
292
343
  )
293
- }
344
+ }.runOnQueue(mentraJsQueue)
294
345
 
295
346
  Function("mentraJsAlivePackages") {
296
347
  val ctx =
@@ -308,7 +359,7 @@ class CrustModule : Module() {
308
359
  ?: appContext.currentActivity
309
360
  ?: return@AsyncFunction false
310
361
  JSCRuntime.shared(ctx).debugForceGC(packageName)
311
- }
362
+ }.runOnQueue(mentraJsQueue)
312
363
 
313
364
  Function("mentraJsLoadPolyfillBundle") {
314
365
  val ctx =
@@ -791,7 +842,7 @@ class CrustModule : Module() {
791
842
  sendEvent("onNavArrived", emptyMap<String, Any?>())
792
843
  }
793
844
  override fun onError(message: String) {
794
- sendEvent("onNavError", mapOf("message" to message))
845
+ sendEvent("onNavError", mapOf("message" to message, "terminal" to true))
795
846
  }
796
847
  override fun onLocation(payload: NavigationManager.LocationPayload) {
797
848
  sendEvent(
@@ -851,7 +902,10 @@ class CrustModule : Module() {
851
902
  )
852
903
  sendEvent(
853
904
  "onNavError",
854
- mapOf("message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again"),
905
+ mapOf(
906
+ "message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again",
907
+ "terminal" to true,
908
+ ),
855
909
  )
856
910
  return@runOnUiThread
857
911
  }
@@ -922,7 +976,7 @@ class CrustModule : Module() {
922
976
  android.util.Log.e("CrustModule", "stopNavigation failed", e)
923
977
  mapOf("ok" to false, "error" to (e.message ?: "stop failed"))
924
978
  }
925
- }
979
+ }.runOnQueue(Queues.MAIN)
926
980
 
927
981
  // Dev-only: nudge the simulated position ~offsetMeters off-route to
928
982
  // exercise the Nav SDK's onRerouting() pipeline without having to
@@ -0,0 +1,31 @@
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.SSLException
11
+
12
+ /** Fixed diagnostic codes only: exception messages can contain private URLs and addresses. */
13
+ internal fun nativeHttpFailureCode(error: Throwable): String {
14
+ var current: Throwable? = error
15
+ var fallback = "E_NATIVE_HTTP_OTHER"
16
+ // Native wrappers can retain the useful cause. Bound traversal even for a cyclic cause chain.
17
+ repeat(8) {
18
+ val cause = current ?: return fallback
19
+ when (cause) {
20
+ is UnknownHostException -> return "E_NATIVE_HTTP_DNS"
21
+ is SocketTimeoutException -> return "E_NATIVE_HTTP_TIMEOUT"
22
+ is SSLException -> return "E_NATIVE_HTTP_TLS"
23
+ is ConnectException, is NoRouteToHostException -> return "E_NATIVE_HTTP_CONNECT"
24
+ is SocketException -> return "E_NATIVE_HTTP_SOCKET"
25
+ is InterruptedIOException -> return "E_NATIVE_HTTP_INTERRUPTED"
26
+ is IOException -> fallback = "E_NATIVE_HTTP_IO"
27
+ }
28
+ current = cause.cause
29
+ }
30
+ return fallback
31
+ }
@@ -0,0 +1,86 @@
1
+ package com.mentra.crust.http
2
+
3
+ import java.io.IOException
4
+ import java.util.concurrent.ConcurrentHashMap
5
+ import java.util.concurrent.atomic.AtomicBoolean
6
+ import okhttp3.Call
7
+ import okhttp3.Callback
8
+ import okhttp3.Request
9
+ import okhttp3.Response
10
+
11
+ /** Calls owned by one Expo module, separate from miniapp fetch and WebSocket traffic. */
12
+ internal class NativeHttpRequests(private val client: Call.Factory) {
13
+ data class Result(
14
+ val status: Int,
15
+ val statusText: String,
16
+ val headers: Map<String, String>,
17
+ val body: String,
18
+ )
19
+
20
+ private val calls = ConcurrentHashMap<String, Call>()
21
+ private val closed = AtomicBoolean(false)
22
+ internal val activeCount: Int get() = calls.size
23
+
24
+ fun enqueue(
25
+ requestId: String,
26
+ request: Request,
27
+ onResult: (Result) -> Unit,
28
+ onError: (Throwable) -> Unit,
29
+ ) {
30
+ if (closed.get()) {
31
+ onError(IllegalStateException("Native HTTP module is closed"))
32
+ return
33
+ }
34
+ val call = client.newCall(request)
35
+ if (calls.putIfAbsent(requestId, call) != null) {
36
+ onError(IllegalArgumentException("Native HTTP request ID is already active"))
37
+ return
38
+ }
39
+ // Destruction may run on a different thread from Expo's serial request queue.
40
+ if (closed.get()) {
41
+ calls.remove(requestId, call)
42
+ call.cancel()
43
+ onError(IllegalStateException("Native HTTP module is closed"))
44
+ return
45
+ }
46
+ try {
47
+ call.enqueue(object : Callback {
48
+ override fun onFailure(call: Call, e: IOException) {
49
+ calls.remove(requestId, call)
50
+ onError(e)
51
+ }
52
+
53
+ override fun onResponse(call: Call, response: Response) {
54
+ val result = try {
55
+ response.use { r ->
56
+ val headers = r.headers.names().associate { name ->
57
+ name.lowercase() to r.headers.values(name).joinToString(", ")
58
+ }
59
+ Result(r.code, r.message, headers, r.body?.string() ?: "")
60
+ }
61
+ } catch (e: Throwable) {
62
+ calls.remove(requestId, call)
63
+ onError(e)
64
+ return
65
+ }
66
+ calls.remove(requestId, call)
67
+ onResult(result)
68
+ }
69
+ })
70
+ } catch (e: Throwable) {
71
+ calls.remove(requestId, call)
72
+ onError(e)
73
+ }
74
+ }
75
+
76
+ fun cancel(requestId: String) {
77
+ calls.remove(requestId)?.cancel()
78
+ }
79
+
80
+ fun cancelAll() {
81
+ closed.set(true)
82
+ for ((requestId, call) in calls) {
83
+ if (calls.remove(requestId, call)) call.cancel()
84
+ }
85
+ }
86
+ }
@@ -37,7 +37,7 @@ import android.util.Base64
37
37
  object JSCPolyfillBridge {
38
38
  private const val TAG = "MentraJS.PolyfillBridge"
39
39
 
40
- private val httpClient: OkHttpClient by lazy {
40
+ internal val httpClient: OkHttpClient by lazy {
41
41
  OkHttpClient.Builder()
42
42
  .connectTimeout(30, TimeUnit.SECONDS)
43
43
  .readTimeout(60, TimeUnit.SECONDS)
@@ -45,15 +45,8 @@ object JSCPolyfillBridge {
45
45
  .build()
46
46
  }
47
47
 
48
- data class HttpResult(
49
- val status: Int,
50
- val statusText: String,
51
- val headers: Map<String, String>,
52
- val body: String,
53
- )
54
-
55
- /** Shared OkHttp execution path for host cloud-client requests. */
56
- fun executeHttp(method: String, url: String, headers: Map<String, String>, bodyString: String?): HttpResult {
48
+ /** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
49
+ internal fun buildHttpRequest(method: String, url: String, headers: Map<String, String>, bodyString: String?): Request {
57
50
  val builder = Request.Builder().url(url)
58
51
  for ((name, value) in headers) builder.header(name, value)
59
52
  val contentType = headers.entries
@@ -66,18 +59,7 @@ object JSCPolyfillBridge {
66
59
  else -> null
67
60
  }
68
61
  builder.method(upperMethod, requestBody)
69
- httpClient.newCall(builder.build()).execute().use { response ->
70
- val responseHeaders = mutableMapOf<String, String>()
71
- for (name in response.headers.names()) {
72
- responseHeaders[name.lowercase()] = response.headers.values(name).joinToString(", ")
73
- }
74
- return HttpResult(
75
- status = response.code,
76
- statusText = response.message,
77
- headers = responseHeaders,
78
- body = response.body?.string() ?: "",
79
- )
80
- }
62
+ return builder.build()
81
63
  }
82
64
 
83
65
  /** Idempotent. Call once on host boot, after the dispatcher is created. */
@@ -220,20 +202,9 @@ object JSCPolyfillBridge {
220
202
  }?.toMap() ?: emptyMap()
221
203
  val bodyString = req["body"] as? String
222
204
 
223
- val builder = Request.Builder().url(url)
224
- for ((k, v) in headers) builder.header(k, v)
225
- // HTTP header names are case-insensitive. The JS fetch caller will
226
- // commonly provide `Content-Type`; a direct lowercase map lookup
227
- // misses that value and causes OkHttp to emit application/octet-stream.
228
- val contentType = headers.entries
229
- .firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
230
- ?.value
231
- val body = if (bodyString.isNullOrEmpty()) null else bodyString.toRequestBody(
232
- (contentType ?: "application/octet-stream").toMediaTypeOrNull()
233
- )
234
- builder.method(method.uppercase(), body)
205
+ val request = buildHttpRequest(method, url, headers, bodyString)
235
206
 
236
- httpClient.newCall(builder.build()).enqueue(object : Callback {
207
+ httpClient.newCall(request).enqueue(object : Callback {
237
208
  override fun onFailure(call: Call, e: IOException) {
238
209
  deliverError(runtime, packageName, reqId, "fetch: ${e.message}")
239
210
  }
@@ -1,6 +1,7 @@
1
1
  package com.mentra.crust.jsc
2
2
 
3
3
  import android.content.Context
4
+ import android.os.SystemClock
4
5
  import android.util.Log
5
6
  import com.dokar.quickjs.QuickJs
6
7
  import com.dokar.quickjs.binding.function
@@ -11,6 +12,7 @@ import java.util.concurrent.Executors
11
12
  import java.util.concurrent.ScheduledExecutorService
12
13
  import java.util.concurrent.ScheduledFuture
13
14
  import java.util.concurrent.TimeUnit
15
+ import java.util.concurrent.atomic.AtomicLong
14
16
  import kotlinx.coroutines.Dispatchers
15
17
  import kotlinx.coroutines.runBlocking
16
18
 
@@ -133,6 +135,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
133
135
  val dispatcher: JSCDispatcher = JSCDispatcher(appContext)
134
136
 
135
137
  private val contexts = ConcurrentHashMap<String, ContextRecord>()
138
+ private val nextContextId = AtomicLong()
136
139
  private val polyfillBundle: String by lazy { loadPolyfillBundle() }
137
140
 
138
141
  // Schedule pool for timers; per-context the executor is the same as the
@@ -146,6 +149,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
146
149
  val packageName: String,
147
150
  val qjs: QuickJs,
148
151
  val executor: ExecutorService,
152
+ val contextId: Long,
149
153
  val pendingTimers: MutableMap<Int, ScheduledFuture<*>> = ConcurrentHashMap(),
150
154
  @Volatile var readyAcked: Boolean = false,
151
155
  @Volatile var readyNackTimer: ScheduledFuture<*>? = null,
@@ -273,6 +277,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
273
277
  packageName = packageName,
274
278
  qjs = qjs,
275
279
  executor = executor,
280
+ contextId = nextContextId.incrementAndGet(),
276
281
  )
277
282
  contexts[packageName] = record
278
283
 
@@ -288,10 +293,10 @@ class JSCRuntime private constructor(private val appContext: Context) {
288
293
  runBlocking {
289
294
  installGlobals(qjs, packageName)
290
295
  if (bundle.isNotEmpty()) {
291
- qjs.evaluate<Any?>(bundle, filename = "mentrajs:startup.js")
296
+ qjs.evaluateForEffect(bundle, filename = "mentrajs:startup.js")
292
297
  }
293
298
  if (miniappJs.isNotEmpty()) {
294
- qjs.evaluate<Any?>(miniappJs, filename = "mentrajs:miniapp.js")
299
+ qjs.evaluateForEffect(miniappJs, filename = "mentrajs:miniapp.js")
295
300
  }
296
301
  }
297
302
  Log.i(TAG, "spawned $packageName")
@@ -415,7 +420,7 @@ class JSCRuntime private constructor(private val appContext: Context) {
415
420
  record.executor.submit {
416
421
  runBlocking {
417
422
  try {
418
- record.qjs.evaluate<Any?>(
423
+ record.qjs.evaluateForEffect(
419
424
  "globalThis.__deliverTimer && globalThis.__deliverTimer($token);",
420
425
  filename = "mentrajs:timer-$token.js",
421
426
  )
@@ -457,10 +462,23 @@ class JSCRuntime private constructor(private val appContext: Context) {
457
462
  * Push a `{kind: "event"|"response", …}` envelope into the named
458
463
  * context's globalThis.__deliver. Hops onto the per-context executor.
459
464
  */
460
- fun dispatchToJs(packageName: String, envelopeJson: String) {
461
- val record = contexts[packageName] ?: return
465
+ fun dispatchToJs(packageName: String, envelopeJson: String, deliveryId: Long? = null) {
466
+ val record = contexts[packageName]
467
+ // Only host-generated PINGs carry this ID. Ordinary media/event deliveries stay quiet.
468
+ val diagnostic = deliveryId?.let {
469
+ JSDeliveryDiagnostics(it, record?.contextId, SystemClock::elapsedRealtime) { metadata ->
470
+ reportDeliveryDiagnostic(packageName, metadata)
471
+ }
472
+ }
473
+ diagnostic?.entry()
474
+ if (record == null) {
475
+ diagnostic?.dropped("context-missing")
476
+ return
477
+ }
478
+ diagnostic?.queued()
462
479
  try {
463
480
  record.executor.submit {
481
+ diagnostic?.started()
464
482
  // Arm only once evaluate is about to run. Queue wait during a
465
483
  // long previous turn (WHIP start, etc.) must not count — that
466
484
  // was firing ready_nack while the context was merely busy.
@@ -489,14 +507,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
489
507
  kill(packageName)
490
508
  }, WATCHDOG_KILL_MS, TimeUnit.MILLISECONDS)
491
509
  record.watchdogTimer = killTimer
510
+ var failure: Throwable? = null
492
511
  try {
493
512
  val source = "globalThis.__deliver(${jsStringLiteral(envelopeJson)});"
494
513
  runBlocking {
495
- record.qjs.evaluate<Any?>(source, filename = "mentrajs:deliver.js")
514
+ record.qjs.evaluateForEffect(source, filename = "mentrajs:deliver.js")
496
515
  }
497
516
  } catch (e: Throwable) {
517
+ failure = e
498
518
  Log.w(TAG, "dispatchToJs threw in $packageName: ${e.message}", e)
519
+ if (diagnostic == null) {
520
+ reportDeliveryDiagnostic(packageName, mapOf(
521
+ "event" to "native-delivery-failed",
522
+ "contextId" to record.contextId,
523
+ "phase" to "evaluate-failed",
524
+ "errorClass" to e.javaClass.simpleName,
525
+ "causeClass" to e.cause?.javaClass?.simpleName,
526
+ ))
527
+ }
499
528
  } finally {
529
+ diagnostic?.finished(failure)
500
530
  // Success or throw: the host observed the turn end.
501
531
  record.readyNackTimer?.cancel(false)
502
532
  record.readyNackTimer = null
@@ -505,8 +535,26 @@ class JSCRuntime private constructor(private val appContext: Context) {
505
535
  if (record.watchdogTimer === killTimer) record.watchdogTimer = null
506
536
  }
507
537
  }
508
- } catch (_: java.util.concurrent.RejectedExecutionException) {
509
- // Context killed mid-flight — drop. dispatchToJs is best-effort.
538
+ } catch (e: java.util.concurrent.RejectedExecutionException) {
539
+ diagnostic?.dropped("executor-rejected", e)
540
+ Log.w(TAG, "dispatchToJs rejected for $packageName", e)
541
+ }
542
+ }
543
+
544
+ private fun reportDeliveryDiagnostic(packageName: String, metadata: Map<String, Any?>) {
545
+ val line = org.json.JSONObject(metadata as Map<*, *>).toString()
546
+ Log.i(TAG, line)
547
+ // Use the existing miniapp log route so console interception includes this in reports.
548
+ try {
549
+ deliverOrDrop(OutboundMessage(packageName, mapOf(
550
+ "packageName" to packageName,
551
+ "iface" to "__log",
552
+ "method" to if (metadata["errorClass"] == null) "log" else "warn",
553
+ "argsJson" to org.json.JSONArray().put(org.json.JSONObject(metadata as Map<*, *>)).toString(),
554
+ )))
555
+ } catch (e: Throwable) {
556
+ // Observability cannot change delivery or hide its original failure.
557
+ Log.w(TAG, "delivery diagnostic forwarding failed: ${e.javaClass.simpleName}")
510
558
  }
511
559
  }
512
560
 
@@ -542,6 +590,15 @@ class JSCRuntime private constructor(private val appContext: Context) {
542
590
 
543
591
  // ------------------------------------------------------------------
544
592
 
593
+ /**
594
+ * Evaluate for side effects. A completion value is never converted to
595
+ * Kotlin: quickjs-kt 1.0.15 hangs converting a Set, Map or array that
596
+ * contains itself, and miniapp code chooses its bundle's last value.
597
+ */
598
+ private suspend fun QuickJs.evaluateForEffect(source: String, filename: String) {
599
+ evaluate<Any?>("$source\n;undefined", filename = filename)
600
+ }
601
+
545
602
  private fun parseArgsEnvelope(argsJson: String): Pair<List<Any?>, String?> {
546
603
  return try {
547
604
  if (argsJson.startsWith("{")) {