@mentra/crust 3.2.1 → 3.3.0-beta.1001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +27 -0
  2. package/android/build.gradle +24 -9
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/com/mentra/crust/CrustModule.kt +44 -15
  5. package/android/src/main/java/com/mentra/crust/http/NativeHttpFailure.kt +31 -0
  6. package/android/src/main/java/com/mentra/crust/http/NativeHttpRequests.kt +86 -0
  7. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +69 -54
  8. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +65 -8
  9. package/android/src/main/java/com/mentra/crust/jsc/JSDeliveryDiagnostics.kt +44 -0
  10. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +4 -0
  11. package/android/src/main/java/com/mentra/crust/preview/PixelCopyPreview.kt +76 -0
  12. package/android/src/test/java/com/mentra/crust/http/NativeHttpFailureTest.kt +44 -0
  13. package/android/src/test/java/com/mentra/crust/http/NativeHttpRequestsTest.kt +133 -0
  14. package/android/src/test/java/com/mentra/crust/jsc/FetchBridgeTest.kt +199 -0
  15. package/android/src/test/java/com/mentra/crust/jsc/JSDeliveryDiagnosticsTest.kt +54 -0
  16. package/android/src/test/java/com/mentra/crust/jsc/QuickJsLifecycleTest.kt +87 -0
  17. package/build/Crust.types.d.ts +25 -11
  18. package/build/Crust.types.d.ts.map +1 -1
  19. package/build/Crust.types.js.map +1 -1
  20. package/build/CrustModule.d.ts +9 -2
  21. package/build/CrustModule.d.ts.map +1 -1
  22. package/build/CrustModule.js.map +1 -1
  23. package/build/CrustModule.web.d.ts +3 -1
  24. package/build/CrustModule.web.d.ts.map +1 -1
  25. package/build/CrustModule.web.js +20 -8
  26. package/build/CrustModule.web.js.map +1 -1
  27. package/build/index.d.ts +1 -0
  28. package/build/index.d.ts.map +1 -1
  29. package/build/index.js +1 -0
  30. package/build/index.js.map +1 -1
  31. package/build/nativeHttpRequest.d.ts +13 -0
  32. package/build/nativeHttpRequest.d.ts.map +1 -0
  33. package/build/nativeHttpRequest.js +51 -0
  34. package/build/nativeHttpRequest.js.map +1 -0
  35. package/build/nativeHttpRequest.test.d.ts +2 -0
  36. package/build/nativeHttpRequest.test.d.ts.map +1 -0
  37. package/build/nativeHttpRequest.test.js +88 -0
  38. package/build/nativeHttpRequest.test.js.map +1 -0
  39. package/ios/Crust.podspec +1 -1
  40. package/ios/CrustModule.swift +18 -7
  41. package/ios/PermissionStatusReceipt.swift +105 -0
  42. package/ios/Resources/startup.js +154 -68
  43. package/ios/Source/JSCPolyfillBridge.swift +31 -14
  44. package/package.json +2 -2
  45. package/plugin/build/index.d.ts +2 -0
  46. package/plugin/build/index.js +2 -0
  47. package/plugin/build/withAndroid.js +39 -0
  48. package/src/Crust.types.ts +26 -12
  49. package/src/CrustModule.ts +9 -1
  50. package/src/CrustModule.web.ts +18 -7
  51. package/src/index.ts +1 -0
  52. package/src/nativeHttpRequest.test.ts +94 -0
  53. package/src/nativeHttpRequest.ts +74 -0
  54. package/android/src/internal/AndroidManifest.xml +0 -9
  55. 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.
@@ -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
- // Version pin: this Expo SDK 55 build uses Kotlin 2.1.20, which can read
129
- // metadata up to 2.2.0. dokar3 1.0.1+ ships classes with metadata 2.3.0
130
- // (compiled with Kotlin 2.3.x) and is unreadable here. 1.0.0-alpha13 is
131
- // the last release compiled with Kotlin 2.0 (metadata 2.0.0) — its API
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,12 +141,30 @@ 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.8.1'
148
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
144
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0'
145
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
149
146
  }
150
147
 
151
148
  // Notification lifecycle tests run against Android APIs without a device.
152
149
  dependencies {
153
150
  testImplementation 'junit:junit:4.13.2'
154
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
+ }
155
170
  }
@@ -5,6 +5,7 @@
5
5
  <uses-permission android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE" />
6
6
 
7
7
  <application>
8
+
8
9
  <meta-data
9
10
  android:name="com.mentra.bluetoothsdk.RUNTIME_RECOVERY_SERVICE"
10
11
  android:value="com.mentra.crust.services.RuntimeRecoveryService" />
@@ -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 result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
190
- mapOf(
191
- "status" to result.status,
192
- "statusText" to result.statusText,
193
- "headers" to result.headers,
194
- "body" to result.body,
194
+ requestId: String, method: String, url: String, headers: Map<String, String>, body: String?, promise: expo.modules.kotlin.Promise ->
195
+ val request = try {
196
+ JSCPolyfillBridge.buildHttpRequest(method, url, headers, body)
197
+ } catch (error: Throwable) {
198
+ promise.reject("E_NATIVE_HTTP", error.message ?: "Native HTTP request failed", error)
199
+ return@AsyncFunction
200
+ }
201
+ nativeHttpRequests.enqueue(
202
+ requestId,
203
+ request,
204
+ onResult = { result ->
205
+ promise.resolve(
206
+ mapOf(
207
+ "status" to result.status,
208
+ "statusText" to result.statusText,
209
+ "headers" to result.headers,
210
+ "body" to result.body,
211
+ )
212
+ )
213
+ },
214
+ onError = { error -> promise.reject(nativeHttpFailureCode(error), error.message ?: "Native HTTP request failed", error) },
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
- JSCRuntime.shared(ctx).dispatchToJs(packageName, json)
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("message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again"),
905
+ mapOf(
906
+ "message" to "ACCESS_FINE_LOCATION not granted — accept the prompt and tap Start again",
907
+ "terminal" to true,
908
+ ),
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
+ }
@@ -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
- private val httpClient: OkHttpClient by lazy {
43
+ internal val httpClient: OkHttpClient by lazy {
41
44
  OkHttpClient.Builder()
42
45
  .connectTimeout(30, TimeUnit.SECONDS)
43
46
  .readTimeout(60, TimeUnit.SECONDS)
@@ -45,40 +48,24 @@ object JSCPolyfillBridge {
45
48
  .build()
46
49
  }
47
50
 
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
51
  /** Keep host HTTP and miniapp fetch body semantics identical, including empty POSTs. */
73
- internal fun buildHttpRequest(method: String, url: String, headers: Map<String, String>, bodyString: String?): Request {
52
+ internal fun buildHttpRequest(
53
+ method: String,
54
+ url: String,
55
+ headers: Map<String, String>,
56
+ bodyString: String?,
57
+ bodyBytes: ByteString? = null,
58
+ ): Request {
74
59
  val builder = Request.Builder().url(url)
75
60
  for ((name, value) in headers) builder.header(name, value)
76
61
  val contentType = headers.entries
77
62
  .firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
78
63
  ?.value
64
+ val mediaType = (contentType ?: "application/octet-stream").toMediaTypeOrNull()
79
65
  val upperMethod = method.uppercase()
80
66
  val requestBody = when {
81
- bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
67
+ bodyBytes != null -> bodyBytes.toRequestBody(mediaType)
68
+ bodyString != null -> bodyString.toRequestBody(mediaType)
82
69
  upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
83
70
  else -> null
84
71
  }
@@ -86,6 +73,49 @@ object JSCPolyfillBridge {
86
73
  return builder.build()
87
74
  }
88
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())
102
+ }
103
+ put("ok", response.code in 200..299)
104
+ }
105
+ }
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
+
89
119
  /** Idempotent. Call once on host boot, after the dispatcher is created. */
90
120
  fun install(runtime: JSCRuntime) {
91
121
  installFetch(runtime)
@@ -225,8 +255,12 @@ object JSCPolyfillBridge {
225
255
  ks to vs
226
256
  }?.toMap() ?: emptyMap()
227
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
+ }
228
262
 
229
- val request = buildHttpRequest(method, url, headers, bodyString)
263
+ val request = buildHttpRequest(method, url, headers, bodyString, bodyBytes)
230
264
 
231
265
  httpClient.newCall(request).enqueue(object : Callback {
232
266
  override fun onFailure(call: Call, e: IOException) {
@@ -234,33 +268,14 @@ object JSCPolyfillBridge {
234
268
  }
235
269
 
236
270
  override fun onResponse(call: Call, response: Response) {
237
- response.use { r ->
238
- val status = r.code
239
- val statusText = r.message
240
- val headerMap = mutableMapOf<String, String>()
241
- for (name in r.headers.names()) {
242
- headerMap[name.lowercase()] = r.headers.values(name).joinToString(", ")
243
- }
244
- val bodyStr = try {
245
- r.body?.string() ?: ""
246
- } catch (e: Throwable) {
247
- Log.w(TAG, "fetch read body threw: ${e.message}")
248
- ""
249
- }
250
- val envelope = JSONObject().apply {
251
- put("kind", "response")
252
- put("reqId", reqId)
253
- put("ok", true)
254
- put("result", JSONObject().apply {
255
- put("status", status)
256
- put("statusText", statusText)
257
- put("headers", JSONObject(headerMap as Map<*, *>))
258
- put("body", bodyStr)
259
- put("ok", status in 200..299)
260
- })
261
- }
262
- 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)
263
277
  }
278
+ runtime.dispatchToJs(packageName, envelope.toString())
264
279
  }
265
280
  })
266
281
  JSCDispatchOutcome.Async