@mentra/crust 3.2.1 → 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 +15 -9
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +44 -15
- 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 +1 -25
- 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 +4 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -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/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -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 +9 -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 +18 -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 +9 -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
|
@@ -125,13 +125,10 @@ dependencies {
|
|
|
125
125
|
// for installing Kotlin lambdas as JS globals via `JS_SetPropertyStr`.
|
|
126
126
|
// Mirrors iOS Apple JSC's `setObject(_, forKeyedSubscript:)` pattern.
|
|
127
127
|
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
// (`QuickJs.create`, `evaluate`, `function` extension) matches 1.0.5 1:1.
|
|
133
|
-
// Bump only when the host Kotlin compiler clears 2.3.0 metadata.
|
|
134
|
-
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'
|
|
135
132
|
|
|
136
133
|
// OkHttp for fetch / WebSocket — already used elsewhere in MentraOS;
|
|
137
134
|
// pinned here so the gradle plugin doesn't downgrade us.
|
|
@@ -144,8 +141,8 @@ dependencies {
|
|
|
144
141
|
|
|
145
142
|
// Kotlin coroutines: dokar3's `evaluate()` is suspend; we bridge it via
|
|
146
143
|
// `runBlocking(dispatcher)` from the per-miniapp single-thread executor.
|
|
147
|
-
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.
|
|
148
|
-
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'
|
|
149
146
|
}
|
|
150
147
|
|
|
151
148
|
// Notification lifecycle tests run against Android APIs without a device.
|
|
@@ -153,3 +150,12 @@ dependencies {
|
|
|
153
150
|
testImplementation 'junit:junit:4.13.2'
|
|
154
151
|
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
155
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import com.mentra.crust.services.NotificationProcessBridge
|
|
|
7
7
|
import expo.modules.kotlin.modules.Module
|
|
8
8
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
9
9
|
import expo.modules.kotlin.functions.Queues
|
|
10
|
+
import expo.modules.kotlin.functions.Coroutine
|
|
10
11
|
import java.net.URL
|
|
11
12
|
import java.util.concurrent.Executors
|
|
12
13
|
import kotlinx.coroutines.CoroutineName
|
|
@@ -17,9 +18,12 @@ import kotlinx.coroutines.cancel
|
|
|
17
18
|
|
|
18
19
|
import com.mentra.crust.navigation.NavigationManager
|
|
19
20
|
import com.mentra.crust.heading.HeadingManager
|
|
21
|
+
import com.mentra.crust.preview.PixelCopyPreview
|
|
20
22
|
import com.mentra.crust.jsc.JSCRuntime
|
|
21
23
|
import com.mentra.crust.jsc.InstalledMiniappManifest
|
|
22
24
|
import com.mentra.crust.jsc.JSCPolyfillBridge
|
|
25
|
+
import com.mentra.crust.http.NativeHttpRequests
|
|
26
|
+
import com.mentra.crust.http.nativeHttpFailureCode
|
|
23
27
|
|
|
24
28
|
class CrustModule : Module() {
|
|
25
29
|
companion object {
|
|
@@ -57,10 +61,6 @@ class CrustModule : Module() {
|
|
|
57
61
|
NotificationProcessBridge.emitDismissed(context, notificationKey, packageName)
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
fun emitCaptionsTesterIncident(data: Map<String, Any>) {
|
|
61
|
-
emitEvent("captions_tester_incident", data)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
64
|
private fun emitEvent(eventName: String, data: Map<String, Any>) {
|
|
65
65
|
val emitter = eventEmitter
|
|
66
66
|
if (emitter == null) {
|
|
@@ -101,6 +101,7 @@ class CrustModule : Module() {
|
|
|
101
101
|
)
|
|
102
102
|
private var notificationEventReceiver: BroadcastReceiver? = null
|
|
103
103
|
private var notificationBridgeContext: android.content.Context? = null
|
|
104
|
+
private val nativeHttpRequests = NativeHttpRequests(JSCPolyfillBridge.httpClient)
|
|
104
105
|
|
|
105
106
|
private fun registerNotificationBridgeIfPossible(): Boolean {
|
|
106
107
|
if (notificationEventReceiver != null) return true
|
|
@@ -143,7 +144,6 @@ class CrustModule : Module() {
|
|
|
143
144
|
"onChange",
|
|
144
145
|
"phone_notification",
|
|
145
146
|
"phone_notification_dismissed",
|
|
146
|
-
"captions_tester_incident",
|
|
147
147
|
"onNavManeuver",
|
|
148
148
|
"onNavRerouting",
|
|
149
149
|
"onNavArrived",
|
|
@@ -172,6 +172,7 @@ class CrustModule : Module() {
|
|
|
172
172
|
notificationEventReceiver = null
|
|
173
173
|
notificationBridgeContext = null
|
|
174
174
|
eventEmitter = null
|
|
175
|
+
nativeHttpRequests.cancelAll()
|
|
175
176
|
mentraJsQueue.cancel()
|
|
176
177
|
mentraJsExecutor.shutdown()
|
|
177
178
|
}
|
|
@@ -184,17 +185,41 @@ class CrustModule : Module() {
|
|
|
184
185
|
sendEvent("onChange", mapOf("value" to value))
|
|
185
186
|
}
|
|
186
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
|
+
|
|
187
193
|
AsyncFunction("nativeHttpRequest") {
|
|
188
|
-
method: String, url: String, headers: Map<String, String>, body: String
|
|
189
|
-
val
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
"
|
|
193
|
-
|
|
194
|
-
|
|
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) },
|
|
195
215
|
)
|
|
196
216
|
}
|
|
197
217
|
|
|
218
|
+
// Same serial Expo queue as nativeHttpRequest: cancellation cannot precede registration.
|
|
219
|
+
AsyncFunction("cancelNativeHttpRequest") { requestId: String ->
|
|
220
|
+
nativeHttpRequests.cancel(requestId)
|
|
221
|
+
}
|
|
222
|
+
|
|
198
223
|
Function("showAVRoutePicker") { _: String? ->
|
|
199
224
|
// iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
|
|
200
225
|
}
|
|
@@ -303,7 +328,8 @@ class CrustModule : Module() {
|
|
|
303
328
|
?: appContext.currentActivity
|
|
304
329
|
?: throw IllegalStateException("MentraJS: no context")
|
|
305
330
|
val json = org.json.JSONObject(envelope as Map<*, *>).toString()
|
|
306
|
-
|
|
331
|
+
val deliveryId = (envelope["deliveryId"] as? Number)?.toLong()?.takeIf { it > 0 }
|
|
332
|
+
JSCRuntime.shared(ctx).dispatchToJs(packageName, json, deliveryId)
|
|
307
333
|
}.runOnQueue(mentraJsQueue)
|
|
308
334
|
|
|
309
335
|
AsyncFunction("mentraJsSetManifest") { packageName: String, permissions: List<String> ->
|
|
@@ -816,7 +842,7 @@ class CrustModule : Module() {
|
|
|
816
842
|
sendEvent("onNavArrived", emptyMap<String, Any?>())
|
|
817
843
|
}
|
|
818
844
|
override fun onError(message: String) {
|
|
819
|
-
sendEvent("onNavError", mapOf("message" to message))
|
|
845
|
+
sendEvent("onNavError", mapOf("message" to message, "terminal" to true))
|
|
820
846
|
}
|
|
821
847
|
override fun onLocation(payload: NavigationManager.LocationPayload) {
|
|
822
848
|
sendEvent(
|
|
@@ -876,7 +902,10 @@ class CrustModule : Module() {
|
|
|
876
902
|
)
|
|
877
903
|
sendEvent(
|
|
878
904
|
"onNavError",
|
|
879
|
-
mapOf(
|
|
905
|
+
mapOf(
|
|
906
|
+
"message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again",
|
|
907
|
+
"terminal" to true,
|
|
908
|
+
),
|
|
880
909
|
)
|
|
881
910
|
return@runOnUiThread
|
|
882
911
|
}
|
|
@@ -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,30 +45,6 @@ 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 {
|
|
57
|
-
val request = buildHttpRequest(method, url, headers, bodyString)
|
|
58
|
-
httpClient.newCall(request).execute().use { response ->
|
|
59
|
-
val responseHeaders = mutableMapOf<String, String>()
|
|
60
|
-
for (name in response.headers.names()) {
|
|
61
|
-
responseHeaders[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
62
|
-
}
|
|
63
|
-
return HttpResult(
|
|
64
|
-
status = response.code,
|
|
65
|
-
statusText = response.message,
|
|
66
|
-
headers = responseHeaders,
|
|
67
|
-
body = response.body?.string() ?: "",
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
48
|
/** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
|
|
73
49
|
internal fun buildHttpRequest(method: String, url: String, headers: Map<String, String>, bodyString: String?): Request {
|
|
74
50
|
val builder = Request.Builder().url(url)
|
|
@@ -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
|
+
}
|
|
@@ -461,6 +461,7 @@ object NavigationManager {
|
|
|
461
461
|
override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
|
|
462
462
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
463
463
|
if (routes.isEmpty()) {
|
|
464
|
+
stop()
|
|
464
465
|
callbacks.onError("no route found")
|
|
465
466
|
return
|
|
466
467
|
}
|
|
@@ -477,12 +478,15 @@ object NavigationManager {
|
|
|
477
478
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
478
479
|
val msg = reasons.firstOrNull()?.message ?: "route request failed"
|
|
479
480
|
Log.e(TAG, "requestRoutes failed: $msg")
|
|
481
|
+
stop()
|
|
480
482
|
callbacks.onError(msg)
|
|
481
483
|
}
|
|
482
484
|
|
|
483
485
|
override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
|
|
484
486
|
if (!sessionGuard.acceptsCallback(session)) return
|
|
485
487
|
Log.w(TAG, "route request canceled")
|
|
488
|
+
stop()
|
|
489
|
+
callbacks.onError("route request canceled")
|
|
486
490
|
}
|
|
487
491
|
},
|
|
488
492
|
)
|