@mentra/crust 0.1.0-dev.0

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 (66) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +175 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +26 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +54 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +544 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +186 -0
  63. package/src/CrustModule.web.ts +57 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. package/src/index.ts +5 -0
@@ -0,0 +1,30 @@
1
+ package com.mentra.crust
2
+
3
+ import android.content.Context
4
+ import android.webkit.WebView
5
+ import android.webkit.WebViewClient
6
+ import expo.modules.kotlin.AppContext
7
+ import expo.modules.kotlin.viewevent.EventDispatcher
8
+ import expo.modules.kotlin.views.ExpoView
9
+
10
+ class CrustView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
11
+ // Creates and initializes an event dispatcher for the `onLoad` event.
12
+ // The name of the event is inferred from the value and needs to match the event name defined in the module.
13
+ private val onLoad by EventDispatcher()
14
+
15
+ // Defines a WebView that will be used as the root subview.
16
+ internal val webView = WebView(context).apply {
17
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
18
+ webViewClient = object : WebViewClient() {
19
+ override fun onPageFinished(view: WebView, url: String) {
20
+ // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
21
+ onLoad(mapOf("url" to url))
22
+ }
23
+ }
24
+ }
25
+
26
+ init {
27
+ // Adds the WebView to the view hierarchy.
28
+ addView(webView)
29
+ }
30
+ }
@@ -0,0 +1,150 @@
1
+ package com.mentra.crust.heading
2
+
3
+ import android.content.Context
4
+ import android.hardware.Sensor
5
+ import android.hardware.SensorEvent
6
+ import android.hardware.SensorEventListener
7
+ import android.hardware.SensorManager
8
+ import android.util.Log
9
+ import kotlin.math.abs
10
+ import kotlin.math.atan2
11
+
12
+ /**
13
+ * HeadingManager
14
+ *
15
+ * Reads the OS's fused rotation vector (magnetometer + accelerometer +
16
+ * gyro) and produces a tilt-robust compass heading in degrees (0 = north,
17
+ * clockwise). Behaves like Google Maps' compass: stable across the full
18
+ * range of phone hold angles, including when the user raises the phone
19
+ * toward their face.
20
+ *
21
+ * Independent of nav — works whenever an Android Activity is around.
22
+ *
23
+ * --- Why projection instead of getOrientation + remapCoordinateSystem ---
24
+ * The previous implementation called `remapCoordinateSystem(AXIS_X, AXIS_Z)`
25
+ * and read azimuth via `getOrientation`. That assumes the phone is held
26
+ * near-vertical. As the phone tilts toward horizontal — or up toward the
27
+ * user — the assumed device→world axis mapping breaks down and azimuth
28
+ * drifts or jumps.
29
+ *
30
+ * Instead we directly project the device's "top edge" (+Y in device
31
+ * coords) into the world horizontal plane and read the bearing of that
32
+ * projection. The full 3D rotation matrix R from
33
+ * `getRotationMatrixFromVector` carries the device→world transform with
34
+ * world axes (X = east, Y = north, Z = up). The image of device +Y
35
+ * under R is the second column of R; its (east, north) components are
36
+ * R[0][1] and R[1][1] (row-major indices 1 and 4). We discard the up
37
+ * component — that's exactly the tilt we want to ignore — and take
38
+ * `atan2(east, north)` for the heading.
39
+ *
40
+ * --- Smoothing ---
41
+ * Low-pass on the rotation MATRIX (not the angle) so we never average
42
+ * across the 0°↔360° wrap. Alpha 0.88 ≈ 0.4 s tau at SENSOR_DELAY_UI,
43
+ * matching the perceived smoothness of Google Maps' compass without
44
+ * adding noticeable lag.
45
+ */
46
+ object HeadingManager {
47
+ private const val TAG = "HeadingManager"
48
+
49
+ /** Minimum delta (degrees) before re-emitting. Avoids flooding JS. */
50
+ private const val MIN_DEGREE_DELTA = 1.0f
51
+
52
+ /**
53
+ * Low-pass coefficient applied per-sample to each entry of the 3×3
54
+ * rotation matrix. Higher = smoother but laggier. 0.88 at
55
+ * SENSOR_DELAY_UI (~16 Hz) gives ~0.4 s tau.
56
+ */
57
+ private const val ALPHA = 0.88f
58
+
59
+ private var sensorManager: SensorManager? = null
60
+ private var rotationVectorSensor: Sensor? = null
61
+ private var listener: SensorEventListener? = null
62
+ private var lastEmitted: Float = -1000f
63
+
64
+ interface Callback {
65
+ fun onHeading(degrees: Float)
66
+ }
67
+
68
+ fun start(context: Context, cb: Callback) {
69
+ if (listener != null) {
70
+ Log.d(TAG, "already started")
71
+ return
72
+ }
73
+ val sm = context.applicationContext.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
74
+ if (sm == null) {
75
+ Log.w(TAG, "SensorManager unavailable")
76
+ return
77
+ }
78
+ val sensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
79
+ if (sensor == null) {
80
+ Log.w(TAG, "TYPE_ROTATION_VECTOR sensor unavailable")
81
+ return
82
+ }
83
+
84
+ val raw = FloatArray(9)
85
+ val filtered = FloatArray(9)
86
+ var seeded = false
87
+
88
+ val sl = object : SensorEventListener {
89
+ override fun onSensorChanged(event: SensorEvent) {
90
+ SensorManager.getRotationMatrixFromVector(raw, event.values)
91
+
92
+ if (!seeded) {
93
+ // Seed the filter from the first sample so we don't slow-pan
94
+ // from the identity matrix toward the real orientation over
95
+ // the first ~30 samples after start().
96
+ System.arraycopy(raw, 0, filtered, 0, 9)
97
+ seeded = true
98
+ } else {
99
+ for (i in 0 until 9) {
100
+ filtered[i] = ALPHA * filtered[i] + (1f - ALPHA) * raw[i]
101
+ }
102
+ }
103
+
104
+ // R is row-major device→world. The image of device +Y
105
+ // (the top edge of the phone in portrait) under R is the
106
+ // second column, whose east / north components live at
107
+ // indices 1 and 4. The up component (index 7) is exactly
108
+ // the tilt we want to ignore — discard it.
109
+ val east = filtered[1]
110
+ val north = filtered[4]
111
+ val deg = ((Math.toDegrees(atan2(east, north).toDouble()).toFloat()) + 360f) % 360f
112
+ if (!deg.isFinite()) return
113
+
114
+ if (lastEmitted < -360f || abs(angleDiff(deg, lastEmitted)) >= MIN_DEGREE_DELTA) {
115
+ lastEmitted = deg
116
+ cb.onHeading(deg)
117
+ }
118
+ }
119
+
120
+ override fun onAccuracyChanged(s: Sensor?, a: Int) {}
121
+ }
122
+
123
+ sm.registerListener(sl, sensor, SensorManager.SENSOR_DELAY_UI)
124
+ sensorManager = sm
125
+ rotationVectorSensor = sensor
126
+ listener = sl
127
+ Log.d(TAG, "started")
128
+ }
129
+
130
+ fun stop() {
131
+ val sm = sensorManager
132
+ val sl = listener
133
+ if (sm != null && sl != null) {
134
+ sm.unregisterListener(sl)
135
+ Log.d(TAG, "stopped")
136
+ }
137
+ sensorManager = null
138
+ rotationVectorSensor = null
139
+ listener = null
140
+ lastEmitted = -1000f
141
+ }
142
+
143
+ /** Smallest signed difference between two compass headings. */
144
+ private fun angleDiff(a: Float, b: Float): Float {
145
+ var d = (a - b) % 360f
146
+ if (d > 180f) d -= 360f
147
+ if (d < -180f) d += 360f
148
+ return d
149
+ }
150
+ }
@@ -0,0 +1,189 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.util.Log
6
+ import java.security.SecureRandom
7
+ import org.json.JSONArray
8
+
9
+ /**
10
+ * MentraJS dispatch table + permission gate for the per-miniapp
11
+ * `__dispatch(iface, method, args)` bridge. Symmetric with iOS
12
+ * [JSCDispatcher.swift].
13
+ *
14
+ * Built-in local routes (handled inline, no RN round-trip):
15
+ * - `__runtime.ready` — polyfill signals install complete
16
+ * - `localStorage.{get,set,remove,clear,key,length}` — SharedPreferences
17
+ * - `crypto.getRandomBytes` — SecureRandom
18
+ *
19
+ * Everything else returns [JSCDispatchOutcome.ForwardToRn]; the RN-side
20
+ * [MentraJSRouter] is the actual handler.
21
+ */
22
+ sealed class JSCDispatchOutcome {
23
+ /** Local sync handler returns a JSON string (or null for void). */
24
+ data class Sync(val json: String?) : JSCDispatchOutcome()
25
+ /** Handler accepted; response will arrive later via dispatchToJs. */
26
+ object Async : JSCDispatchOutcome()
27
+ /** Refused. JS sees a thrown Error with `code` + `message`. */
28
+ data class Error(val code: String, val message: String?) : JSCDispatchOutcome()
29
+ /** Forward to RN via the `mentrajs_message` Expo event. */
30
+ data class ForwardToRn(val payload: Map<String, Any?>) : JSCDispatchOutcome()
31
+ }
32
+
33
+ data class InstalledMiniappManifest(val permissions: Set<String>)
34
+
35
+ class JSCDispatcher(private val appContext: Context) {
36
+ companion object {
37
+ private const val TAG = "MentraJS.Dispatcher"
38
+ }
39
+
40
+ private val routes = mutableMapOf<String, Handler>()
41
+ private val manifests = mutableMapOf<String, InstalledMiniappManifest>()
42
+ private val implicitGrants = setOf("STORAGE", "DISPLAY", "BUTTONS")
43
+ private val random = SecureRandom()
44
+
45
+ var permissionRequirements: Map<String, String> = mapOf(
46
+ "mic" to "MICROPHONE",
47
+ "transcription" to "MICROPHONE",
48
+ "translation" to "MICROPHONE",
49
+ "camera" to "CAMERA",
50
+ "location" to "LOCATION",
51
+ "navigation" to "LOCATION",
52
+ "heading" to "LOCATION",
53
+ "calendar" to "CALENDAR",
54
+ )
55
+
56
+ /** Handler runs on the JSContext's queue. */
57
+ fun interface Handler {
58
+ fun handle(packageName: String, args: List<Any?>, reqId: String?): JSCDispatchOutcome
59
+ }
60
+
61
+ init {
62
+ installBuiltinRoutes()
63
+ }
64
+
65
+ fun register(iface: String, method: String, handler: Handler) {
66
+ synchronized(routes) {
67
+ routes["$iface.$method"] = handler
68
+ }
69
+ }
70
+
71
+ fun unregister(iface: String, method: String) {
72
+ synchronized(routes) {
73
+ routes.remove("$iface.$method")
74
+ }
75
+ }
76
+
77
+ fun setManifest(packageName: String, manifest: InstalledMiniappManifest) {
78
+ synchronized(manifests) {
79
+ manifests[packageName] = manifest
80
+ }
81
+ }
82
+
83
+ fun clearManifest(packageName: String) {
84
+ synchronized(manifests) {
85
+ manifests.remove(packageName)
86
+ }
87
+ }
88
+
89
+ fun manifest(packageName: String): InstalledMiniappManifest? {
90
+ synchronized(manifests) {
91
+ return manifests[packageName]
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Main entry. Called from the `__dispatch` JS global installed on each
97
+ * QuickJs context by [JSCRuntime.installGlobals]. The JS side invokes
98
+ * `__dispatch(iface, method, argsJson)` and gets back a JSON string, null,
99
+ * or a thrown Error.
100
+ *
101
+ * Permission gate: bridge-internal calls (`__runtime`, `__log`, crypto,
102
+ * localStorage) are exempt — they don't touch OS sensors. For everything
103
+ * else, only the manifest declaration matters. There is no JIT prompt;
104
+ * install is the consent step (matches the cloud-WebView lifecycle).
105
+ */
106
+ fun handle(packageName: String, iface: String, method: String, args: List<Any?>, reqId: String?): JSCDispatchOutcome {
107
+ val required = permissionRequirements[iface]
108
+ if (required != null && required !in implicitGrants) {
109
+ val declared = manifest(packageName)?.permissions ?: emptySet()
110
+ if (required !in declared) {
111
+ return JSCDispatchOutcome.Error("PERMISSION_NOT_DECLARED", required)
112
+ }
113
+ }
114
+ val handler = synchronized(routes) { routes["$iface.$method"] }
115
+ if (handler != null) {
116
+ return handler.handle(packageName, args, reqId)
117
+ }
118
+ return JSCDispatchOutcome.ForwardToRn(mapOf("args" to args))
119
+ }
120
+
121
+ private fun installBuiltinRoutes() {
122
+ register("__runtime", "ready") { packageName, _, _ ->
123
+ JSCRuntime.shared(appContext).markReady(packageName)
124
+ JSCDispatchOutcome.Sync("null")
125
+ }
126
+
127
+ // localStorage — SharedPreferences-backed, scoped per packageName.
128
+ // String-only contract; the JS shim coerces non-strings via String(v).
129
+ register("localStorage", "getItem") { pkg, args, _ ->
130
+ val key = args.firstOrNull() as? String ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "localStorage.getItem(key)")
131
+ val prefs = prefs(pkg)
132
+ val v = prefs.getString(key, null)
133
+ JSCDispatchOutcome.Sync(if (v == null) "null" else JSONArray().put(v).toString().let { arr -> arr.substring(1, arr.length - 1) })
134
+ }
135
+ register("localStorage", "setItem") { pkg, args, _ ->
136
+ if (args.size < 2 || args[0] !is String || args[1] !is String) {
137
+ return@register JSCDispatchOutcome.Error("INVALID_ARGS", "localStorage.setItem(key, value)")
138
+ }
139
+ prefs(pkg).edit().putString(args[0] as String, args[1] as String).apply()
140
+ JSCDispatchOutcome.Sync("null")
141
+ }
142
+ register("localStorage", "removeItem") { pkg, args, _ ->
143
+ val key = args.firstOrNull() as? String ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "localStorage.removeItem(key)")
144
+ prefs(pkg).edit().remove(key).apply()
145
+ JSCDispatchOutcome.Sync("null")
146
+ }
147
+ register("localStorage", "clear") { pkg, _, _ ->
148
+ prefs(pkg).edit().clear().apply()
149
+ JSCDispatchOutcome.Sync("null")
150
+ }
151
+ register("localStorage", "length") { pkg, _, _ ->
152
+ JSCDispatchOutcome.Sync(prefs(pkg).all.size.toString())
153
+ }
154
+ register("localStorage", "key") { pkg, args, _ ->
155
+ val idx = (args.firstOrNull() as? Number)?.toInt()
156
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "localStorage.key(index)")
157
+ val keys = prefs(pkg).all.keys.sorted()
158
+ if (idx < 0 || idx >= keys.size) {
159
+ JSCDispatchOutcome.Sync("null")
160
+ } else {
161
+ val str = JSONArray().put(keys[idx]).toString()
162
+ JSCDispatchOutcome.Sync(str.substring(1, str.length - 1))
163
+ }
164
+ }
165
+
166
+ register("crypto", "getRandomBytes") { _, args, _ ->
167
+ val n = (args.firstOrNull() as? Number)?.toInt()
168
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "getRandomBytes(n)")
169
+ if (n < 0 || n > (1 shl 20)) {
170
+ return@register JSCDispatchOutcome.Error("INVALID_ARGS", "getRandomBytes max 1MB")
171
+ }
172
+ val bytes = ByteArray(n)
173
+ try {
174
+ random.nextBytes(bytes)
175
+ } catch (e: Throwable) {
176
+ Log.w(TAG, "SecureRandom failed: ${e.message}")
177
+ return@register JSCDispatchOutcome.Error("NATIVE_THROW", "SecureRandom: ${e.message}")
178
+ }
179
+ // Return as JSON array of unsigned bytes.
180
+ val arr = JSONArray()
181
+ for (b in bytes) arr.put(b.toInt() and 0xff)
182
+ JSCDispatchOutcome.Sync(arr.toString())
183
+ }
184
+ }
185
+
186
+ private fun prefs(packageName: String): SharedPreferences {
187
+ return appContext.getSharedPreferences("MentraJS-$packageName", Context.MODE_PRIVATE)
188
+ }
189
+ }
@@ -0,0 +1,246 @@
1
+ package com.mentra.crust.jsc
2
+
3
+ import android.util.Log
4
+ import java.io.IOException
5
+ import okhttp3.MediaType.Companion.toMediaTypeOrNull
6
+ import okhttp3.OkHttpClient
7
+ import okhttp3.Request
8
+ import okhttp3.RequestBody.Companion.toRequestBody
9
+ import okhttp3.Response
10
+ import okhttp3.Call
11
+ import okhttp3.Callback
12
+ import okhttp3.WebSocket
13
+ import okhttp3.WebSocketListener
14
+ import okio.ByteString
15
+ import okio.ByteString.Companion.toByteString
16
+ import org.json.JSONObject
17
+ import org.json.JSONArray
18
+ import java.util.concurrent.ConcurrentHashMap
19
+ import java.util.concurrent.TimeUnit
20
+ import android.util.Base64
21
+
22
+ /**
23
+ * Native bridge for the network polyfills that can't be implemented in
24
+ * pure JS (fetch, WebSocket). Mirrors iOS [JSCPolyfillBridge.swift].
25
+ *
26
+ * Each request opens an OkHttp call that outlives the originating
27
+ * __dispatch invocation, so we route the response back via
28
+ * [JSCRuntime.dispatchToJs] when the call completes. The JS polyfill's
29
+ * Promise correlator (the `__mentraSendRequest` reqId map) takes it from
30
+ * there.
31
+ *
32
+ * Microtask discipline: the response evaluator goes through the
33
+ * QuickJs.evaluate path (bridge re-entry) which drains pending Promise
34
+ * jobs automatically. We never need to call QuickJS's private
35
+ * `JS_ExecutePendingJob` — see the spec's Android microtask section.
36
+ */
37
+ object JSCPolyfillBridge {
38
+ private const val TAG = "MentraJS.PolyfillBridge"
39
+
40
+ private val httpClient: OkHttpClient by lazy {
41
+ OkHttpClient.Builder()
42
+ .connectTimeout(30, TimeUnit.SECONDS)
43
+ .readTimeout(60, TimeUnit.SECONDS)
44
+ .callTimeout(120, TimeUnit.SECONDS)
45
+ .build()
46
+ }
47
+
48
+ /** Idempotent. Call once on host boot, after the dispatcher is created. */
49
+ fun install(runtime: JSCRuntime) {
50
+ installFetch(runtime)
51
+ installWebSocket(runtime)
52
+ }
53
+
54
+ private val sockets = ConcurrentHashMap<String, OkSocketRecord>()
55
+
56
+ private data class OkSocketRecord(
57
+ val packageName: String,
58
+ val socket: WebSocket,
59
+ @Volatile var closedSent: Boolean = false,
60
+ )
61
+
62
+ private fun installWebSocket(runtime: JSCRuntime) {
63
+ runtime.dispatcher.register("ws", "open") { packageName, args, _ ->
64
+ val req = args.firstOrNull() as? Map<*, *>
65
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open expects {sid, url, protocols?}")
66
+ val sid = req["sid"] as? String
67
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing sid")
68
+ val url = req["url"] as? String
69
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing url")
70
+ val protocols = (req["protocols"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
71
+ val builder = Request.Builder().url(url)
72
+ if (protocols.isNotEmpty()) {
73
+ builder.header("Sec-WebSocket-Protocol", protocols.joinToString(", "))
74
+ }
75
+ val listener = object : WebSocketListener() {
76
+ override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
77
+ val proto = response.header("Sec-WebSocket-Protocol")
78
+ val payload = if (proto.isNullOrEmpty()) emptyMap<String, Any?>() else mapOf("protocol" to proto)
79
+ deliverEvent(runtime, packageName, sid, "open", payload)
80
+ }
81
+ override fun onMessage(webSocket: WebSocket, text: String) {
82
+ deliverEvent(runtime, packageName, sid, "message", mapOf("kind" to "text", "data" to text))
83
+ }
84
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
85
+ val b64 = Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)
86
+ deliverEvent(runtime, packageName, sid, "message", mapOf("kind" to "binary", "data" to b64))
87
+ }
88
+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
89
+ webSocket.close(code, reason)
90
+ }
91
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
92
+ val rec = sockets.remove(sid) ?: return
93
+ if (!rec.closedSent) {
94
+ rec.closedSent = true
95
+ deliverEvent(runtime, packageName, sid, "close", mapOf("code" to code, "reason" to reason))
96
+ }
97
+ }
98
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
99
+ deliverEvent(runtime, packageName, sid, "error", mapOf("message" to (t.message ?: "ws error")))
100
+ val rec = sockets.remove(sid) ?: return
101
+ if (!rec.closedSent) {
102
+ rec.closedSent = true
103
+ deliverEvent(runtime, packageName, sid, "close", mapOf("code" to 1006, "reason" to (t.message ?: "")))
104
+ }
105
+ }
106
+ }
107
+ val socket = httpClient.newWebSocket(builder.build(), listener)
108
+ sockets[sid] = OkSocketRecord(packageName, socket)
109
+ JSCDispatchOutcome.Sync("null")
110
+ }
111
+
112
+ runtime.dispatcher.register("ws", "send") { _, args, _ ->
113
+ val req = args.firstOrNull() as? Map<*, *>
114
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send expects {sid, kind, payload}")
115
+ val sid = req["sid"] as? String
116
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing sid")
117
+ val kind = req["kind"] as? String
118
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing kind")
119
+ val payload = req["payload"] as? String
120
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing payload")
121
+ val rec = sockets[sid]
122
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: unknown sid")
123
+ val ok = when (kind) {
124
+ "text" -> rec.socket.send(payload)
125
+ "binary" -> {
126
+ val bytes = try {
127
+ Base64.decode(payload, Base64.DEFAULT)
128
+ } catch (e: IllegalArgumentException) {
129
+ return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: bad base64")
130
+ }
131
+ rec.socket.send(bytes.toByteString())
132
+ }
133
+ else -> return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: kind must be text|binary")
134
+ }
135
+ if (!ok) {
136
+ Log.w(TAG, "ws.send returned false for sid=$sid (queue full?)")
137
+ }
138
+ JSCDispatchOutcome.Sync("null")
139
+ }
140
+
141
+ runtime.dispatcher.register("ws", "close") { _, args, _ ->
142
+ val req = args.firstOrNull() as? Map<*, *>
143
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.close expects {sid, code?, reason?}")
144
+ val sid = req["sid"] as? String
145
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.close: missing sid")
146
+ val rec = sockets[sid] ?: return@register JSCDispatchOutcome.Sync("null")
147
+ val code = (req["code"] as? Number)?.toInt() ?: 1000
148
+ val reason = (req["reason"] as? String) ?: ""
149
+ rec.socket.close(code, reason)
150
+ JSCDispatchOutcome.Sync("null")
151
+ }
152
+ }
153
+
154
+ private fun deliverEvent(
155
+ runtime: JSCRuntime,
156
+ packageName: String,
157
+ sid: String,
158
+ wsType: String,
159
+ payload: Map<String, Any?>,
160
+ ) {
161
+ val envelope = JSONObject().apply {
162
+ put("kind", "ws-event")
163
+ put("sid", sid)
164
+ put("wsType", wsType)
165
+ put("payload", JSONObject(payload as Map<*, *>))
166
+ }
167
+ runtime.dispatchToJs(packageName, envelope.toString())
168
+ }
169
+
170
+ private fun installFetch(runtime: JSCRuntime) {
171
+ runtime.dispatcher.register("fetch", "request") { packageName, args, reqId ->
172
+ val req = args.firstOrNull() as? Map<*, *>
173
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request expects {url,...}")
174
+ val url = req["url"] as? String
175
+ ?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: missing url")
176
+ if (reqId == null) {
177
+ return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request requires reqId (use __mentraSendRequest)")
178
+ }
179
+
180
+ val method = (req["method"] as? String) ?: "GET"
181
+ val headers = (req["headers"] as? Map<*, *>)?.mapNotNull { (k, v) ->
182
+ val ks = k as? String ?: return@mapNotNull null
183
+ val vs = (v as? String) ?: v?.toString() ?: return@mapNotNull null
184
+ ks to vs
185
+ }?.toMap() ?: emptyMap()
186
+ val bodyString = req["body"] as? String
187
+
188
+ val builder = Request.Builder().url(url)
189
+ for ((k, v) in headers) builder.header(k, v)
190
+ val body = if (bodyString.isNullOrEmpty()) null else bodyString.toRequestBody(
191
+ (headers["content-type"] ?: "application/octet-stream").toMediaTypeOrNull()
192
+ )
193
+ builder.method(method.uppercase(), body)
194
+
195
+ httpClient.newCall(builder.build()).enqueue(object : Callback {
196
+ override fun onFailure(call: Call, e: IOException) {
197
+ deliverError(runtime, packageName, reqId, "fetch: ${e.message}")
198
+ }
199
+
200
+ override fun onResponse(call: Call, response: Response) {
201
+ response.use { r ->
202
+ val status = r.code
203
+ val statusText = r.message
204
+ val headerMap = mutableMapOf<String, String>()
205
+ for (name in r.headers.names()) {
206
+ headerMap[name.lowercase()] = r.headers.values(name).joinToString(", ")
207
+ }
208
+ val bodyStr = try {
209
+ r.body?.string() ?: ""
210
+ } catch (e: Throwable) {
211
+ Log.w(TAG, "fetch read body threw: ${e.message}")
212
+ ""
213
+ }
214
+ val envelope = JSONObject().apply {
215
+ put("kind", "response")
216
+ put("reqId", reqId)
217
+ put("ok", true)
218
+ put("result", JSONObject().apply {
219
+ put("status", status)
220
+ put("statusText", statusText)
221
+ put("headers", JSONObject(headerMap as Map<*, *>))
222
+ put("body", bodyStr)
223
+ put("ok", status in 200..299)
224
+ })
225
+ }
226
+ runtime.dispatchToJs(packageName, envelope.toString())
227
+ }
228
+ }
229
+ })
230
+ JSCDispatchOutcome.Async
231
+ }
232
+ }
233
+
234
+ private fun deliverError(runtime: JSCRuntime, packageName: String, reqId: String, message: String) {
235
+ val envelope = JSONObject().apply {
236
+ put("kind", "response")
237
+ put("reqId", reqId)
238
+ put("ok", false)
239
+ put("error", JSONObject().apply {
240
+ put("code", "NATIVE_THROW")
241
+ put("message", message)
242
+ })
243
+ }
244
+ runtime.dispatchToJs(packageName, envelope.toString())
245
+ }
246
+ }