@mentra/crust 3.2.1-dev.314 → 3.3.0-beta.1000
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/android/build.gradle +27 -9
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +76 -22
- package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
- package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +70 -60
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
- package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +74 -10
- package/android/src/main/java/com/mentra/crust/navigation/NavigationSessionGuard.kt +74 -0
- package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
- package/android/src/main/java/com/mentra/crust/services/RuntimeRecoveryService.kt +12 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
- package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
- package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
- package/android/src/test/java/com/mentra/crust/jsc/HttpRequestTest.kt +32 -0
- package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
- package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
- package/android/src/test/java/com/mentra/crust/navigation/NavigationSessionGuardTest.kt +68 -0
- package/build/Crust.types.d.ts +25 -11
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +11 -2
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +3 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +20 -8
- package/build/CrustModule.web.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/nativeHttpRequest.d.ts +13 -0
- package/build/nativeHttpRequest.d.ts.map +1 -0
- package/build/nativeHttpRequest.js +51 -0
- package/build/nativeHttpRequest.js.map +1 -0
- package/build/nativeHttpRequest.test.d.ts +2 -0
- package/build/nativeHttpRequest.test.d.ts.map +1 -0
- package/build/nativeHttpRequest.test.js +88 -0
- package/build/nativeHttpRequest.test.js.map +1 -0
- package/ios/Crust.podspec +1 -1
- package/ios/CrustModule.swift +22 -7
- package/ios/PermissionStatusReceipt.swift +105 -0
- package/ios/Resources/startup.js +154 -68
- package/ios/Source/JSCPolyfillBridge.swift +31 -14
- package/package.json +2 -2
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +39 -0
- package/src/Crust.types.ts +26 -12
- package/src/CrustModule.ts +11 -1
- package/src/CrustModule.web.ts +18 -7
- package/src/index.ts +1 -0
- package/src/nativeHttpRequest.test.ts +94 -0
- package/src/nativeHttpRequest.ts +74 -0
- package/android/src/internal/AndroidManifest.xml +0 -9
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +0 -46
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,12 +141,30 @@ 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.
|
|
149
149
|
dependencies {
|
|
150
150
|
testImplementation 'junit:junit:4.13.2'
|
|
151
151
|
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
152
|
+
// Local HTTP server for the miniapp fetch bridge tests; matches okhttp above.
|
|
153
|
+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Robolectric loads classes, and so QuickJS's JNI library, in its own sandbox
|
|
157
|
+
// class loader. Plain JUnit QuickJS tests in the same JVM then crash inside
|
|
158
|
+
// JNI, so every test class runs in a fresh JVM.
|
|
159
|
+
tasks.withType(Test).configureEach {
|
|
160
|
+
forkEvery = 1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A desktop JVM cannot load the Android QuickJS library, so local unit tests
|
|
164
|
+
// run the same QuickJS build through its desktop JVM artifact.
|
|
165
|
+
configurations.matching { it.name.endsWith('UnitTestRuntimeClasspath') }.configureEach {
|
|
166
|
+
resolutionStrategy.dependencySubstitution {
|
|
167
|
+
substitute(module('io.github.dokar3:quickjs-kt-android'))
|
|
168
|
+
.using(module('io.github.dokar3:quickjs-kt-jvm:1.0.15'))
|
|
169
|
+
}
|
|
152
170
|
}
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,8 @@ package com.mentra.crust.jsc
|
|
|
2
2
|
|
|
3
3
|
import android.util.Log
|
|
4
4
|
import java.io.IOException
|
|
5
|
+
import java.nio.ByteBuffer
|
|
6
|
+
import java.nio.charset.CharacterCodingException
|
|
5
7
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
6
8
|
import okhttp3.OkHttpClient
|
|
7
9
|
import okhttp3.Request
|
|
@@ -12,6 +14,7 @@ import okhttp3.Callback
|
|
|
12
14
|
import okhttp3.WebSocket
|
|
13
15
|
import okhttp3.WebSocketListener
|
|
14
16
|
import okio.ByteString
|
|
17
|
+
import okio.ByteString.Companion.decodeBase64
|
|
15
18
|
import okio.ByteString.Companion.toByteString
|
|
16
19
|
import org.json.JSONObject
|
|
17
20
|
import org.json.JSONArray
|
|
@@ -37,7 +40,7 @@ import android.util.Base64
|
|
|
37
40
|
object JSCPolyfillBridge {
|
|
38
41
|
private const val TAG = "MentraJS.PolyfillBridge"
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
internal val httpClient: OkHttpClient by lazy {
|
|
41
44
|
OkHttpClient.Builder()
|
|
42
45
|
.connectTimeout(30, TimeUnit.SECONDS)
|
|
43
46
|
.readTimeout(60, TimeUnit.SECONDS)
|
|
@@ -45,41 +48,74 @@ object JSCPolyfillBridge {
|
|
|
45
48
|
.build()
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
fun executeHttp(method: String, url: String, headers: Map<String, String>, bodyString: String?): HttpResult {
|
|
51
|
+
/** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
|
|
52
|
+
internal fun buildHttpRequest(
|
|
53
|
+
method: String,
|
|
54
|
+
url: String,
|
|
55
|
+
headers: Map<String, String>,
|
|
56
|
+
bodyString: String?,
|
|
57
|
+
bodyBytes: ByteString? = null,
|
|
58
|
+
): Request {
|
|
57
59
|
val builder = Request.Builder().url(url)
|
|
58
60
|
for ((name, value) in headers) builder.header(name, value)
|
|
59
61
|
val contentType = headers.entries
|
|
60
62
|
.firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
|
|
61
63
|
?.value
|
|
64
|
+
val mediaType = (contentType ?: "application/octet-stream").toMediaTypeOrNull()
|
|
62
65
|
val upperMethod = method.uppercase()
|
|
63
66
|
val requestBody = when {
|
|
64
|
-
|
|
67
|
+
bodyBytes != null -> bodyBytes.toRequestBody(mediaType)
|
|
68
|
+
bodyString != null -> bodyString.toRequestBody(mediaType)
|
|
65
69
|
upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
|
|
66
70
|
else -> null
|
|
67
71
|
}
|
|
68
72
|
builder.method(upperMethod, requestBody)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
return builder.build()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `result` of a `fetch.request` response envelope. The body is sent
|
|
78
|
+
* as the string `body` only when its bytes are exactly that string's
|
|
79
|
+
* UTF-8 encoding, and as `bodyBase64` otherwise, so binary bodies keep
|
|
80
|
+
* every byte. The JS polyfill decodes base64 bodies and applies UTF-8 rules.
|
|
81
|
+
*/
|
|
82
|
+
internal fun fetchResult(response: Response): JSONObject {
|
|
83
|
+
val headerMap = mutableMapOf<String, String>()
|
|
84
|
+
for (name in response.headers.names()) {
|
|
85
|
+
headerMap[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
86
|
+
}
|
|
87
|
+
val body = try {
|
|
88
|
+
response.body?.bytes() ?: ByteArray(0)
|
|
89
|
+
} catch (e: Throwable) {
|
|
90
|
+
Log.w(TAG, "fetch read body threw: ${e.message}")
|
|
91
|
+
ByteArray(0)
|
|
92
|
+
}
|
|
93
|
+
val text = exactUtf8(body)
|
|
94
|
+
return JSONObject().apply {
|
|
95
|
+
put("status", response.code)
|
|
96
|
+
put("statusText", response.message)
|
|
97
|
+
put("headers", JSONObject(headerMap as Map<*, *>))
|
|
98
|
+
if (text != null) {
|
|
99
|
+
put("body", text)
|
|
100
|
+
} else {
|
|
101
|
+
put("bodyBase64", body.toByteString().base64())
|
|
73
102
|
}
|
|
74
|
-
|
|
75
|
-
status = response.code,
|
|
76
|
-
statusText = response.message,
|
|
77
|
-
headers = responseHeaders,
|
|
78
|
-
body = response.body?.string() ?: "",
|
|
79
|
-
)
|
|
103
|
+
put("ok", response.code in 200..299)
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
107
|
+
/** [bytes] as a String when they are exactly its UTF-8 encoding, else null. */
|
|
108
|
+
private fun exactUtf8(bytes: ByteArray): String? {
|
|
109
|
+
// A new decoder reports malformed input, so binary fails at its first invalid byte.
|
|
110
|
+
val text = try {
|
|
111
|
+
Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()
|
|
112
|
+
} catch (_: CharacterCodingException) {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
// Re-encoding proves the round trip even if a decoder lets an unpaired surrogate through.
|
|
116
|
+
return text.takeIf { it.toByteArray(Charsets.UTF_8).contentEquals(bytes) }
|
|
117
|
+
}
|
|
118
|
+
|
|
83
119
|
/** Idempotent. Call once on host boot, after the dispatcher is created. */
|
|
84
120
|
fun install(runtime: JSCRuntime) {
|
|
85
121
|
installFetch(runtime)
|
|
@@ -219,53 +255,27 @@ object JSCPolyfillBridge {
|
|
|
219
255
|
ks to vs
|
|
220
256
|
}?.toMap() ?: emptyMap()
|
|
221
257
|
val bodyString = req["body"] as? String
|
|
258
|
+
val bodyBytes = (req["bodyBase64"] as? String)?.let { encoded ->
|
|
259
|
+
encoded.decodeBase64()
|
|
260
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: bodyBase64 is not base64")
|
|
261
|
+
}
|
|
222
262
|
|
|
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)
|
|
263
|
+
val request = buildHttpRequest(method, url, headers, bodyString, bodyBytes)
|
|
235
264
|
|
|
236
|
-
httpClient.newCall(
|
|
265
|
+
httpClient.newCall(request).enqueue(object : Callback {
|
|
237
266
|
override fun onFailure(call: Call, e: IOException) {
|
|
238
267
|
deliverError(runtime, packageName, reqId, "fetch: ${e.message}")
|
|
239
268
|
}
|
|
240
269
|
|
|
241
270
|
override fun onResponse(call: Call, response: Response) {
|
|
242
|
-
response.use {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
val bodyStr = try {
|
|
250
|
-
r.body?.string() ?: ""
|
|
251
|
-
} catch (e: Throwable) {
|
|
252
|
-
Log.w(TAG, "fetch read body threw: ${e.message}")
|
|
253
|
-
""
|
|
254
|
-
}
|
|
255
|
-
val envelope = JSONObject().apply {
|
|
256
|
-
put("kind", "response")
|
|
257
|
-
put("reqId", reqId)
|
|
258
|
-
put("ok", true)
|
|
259
|
-
put("result", JSONObject().apply {
|
|
260
|
-
put("status", status)
|
|
261
|
-
put("statusText", statusText)
|
|
262
|
-
put("headers", JSONObject(headerMap as Map<*, *>))
|
|
263
|
-
put("body", bodyStr)
|
|
264
|
-
put("ok", status in 200..299)
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
runtime.dispatchToJs(packageName, envelope.toString())
|
|
271
|
+
val result = response.use { fetchResult(it) }
|
|
272
|
+
val envelope = JSONObject().apply {
|
|
273
|
+
put("kind", "response")
|
|
274
|
+
put("reqId", reqId)
|
|
275
|
+
put("ok", true)
|
|
276
|
+
put("result", result)
|
|
268
277
|
}
|
|
278
|
+
runtime.dispatchToJs(packageName, envelope.toString())
|
|
269
279
|
}
|
|
270
280
|
})
|
|
271
281
|
JSCDispatchOutcome.Async
|