@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
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.
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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.
|
|
145
|
-
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android: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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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("{")) {
|