@mentra/crust 0.1.0-beta.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.
- package/README.md +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +1042 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +182 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +34 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +63 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +781 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +191 -0
- package/src/CrustModule.web.ts +66 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- 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,287 @@
|
|
|
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
|
+
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 builder = Request.Builder().url(url)
|
|
58
|
+
for ((name, value) in headers) builder.header(name, value)
|
|
59
|
+
val contentType = headers.entries
|
|
60
|
+
.firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
|
|
61
|
+
?.value
|
|
62
|
+
val upperMethod = method.uppercase()
|
|
63
|
+
val requestBody = when {
|
|
64
|
+
bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
|
|
65
|
+
upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
|
|
66
|
+
else -> null
|
|
67
|
+
}
|
|
68
|
+
builder.method(upperMethod, requestBody)
|
|
69
|
+
httpClient.newCall(builder.build()).execute().use { response ->
|
|
70
|
+
val responseHeaders = mutableMapOf<String, String>()
|
|
71
|
+
for (name in response.headers.names()) {
|
|
72
|
+
responseHeaders[name.lowercase()] = response.headers.values(name).joinToString(", ")
|
|
73
|
+
}
|
|
74
|
+
return HttpResult(
|
|
75
|
+
status = response.code,
|
|
76
|
+
statusText = response.message,
|
|
77
|
+
headers = responseHeaders,
|
|
78
|
+
body = response.body?.string() ?: "",
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Idempotent. Call once on host boot, after the dispatcher is created. */
|
|
84
|
+
fun install(runtime: JSCRuntime) {
|
|
85
|
+
installFetch(runtime)
|
|
86
|
+
installWebSocket(runtime)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private val sockets = ConcurrentHashMap<String, OkSocketRecord>()
|
|
90
|
+
|
|
91
|
+
private data class OkSocketRecord(
|
|
92
|
+
val packageName: String,
|
|
93
|
+
val socket: WebSocket,
|
|
94
|
+
@Volatile var closedSent: Boolean = false,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
private fun installWebSocket(runtime: JSCRuntime) {
|
|
98
|
+
runtime.dispatcher.register("ws", "open") { packageName, args, _ ->
|
|
99
|
+
val req = args.firstOrNull() as? Map<*, *>
|
|
100
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open expects {sid, url, protocols?}")
|
|
101
|
+
val sid = req["sid"] as? String
|
|
102
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing sid")
|
|
103
|
+
val url = req["url"] as? String
|
|
104
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.open: missing url")
|
|
105
|
+
val protocols = (req["protocols"] as? List<*>)?.mapNotNull { it as? String } ?: emptyList()
|
|
106
|
+
val builder = Request.Builder().url(url)
|
|
107
|
+
if (protocols.isNotEmpty()) {
|
|
108
|
+
builder.header("Sec-WebSocket-Protocol", protocols.joinToString(", "))
|
|
109
|
+
}
|
|
110
|
+
val listener = object : WebSocketListener() {
|
|
111
|
+
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
|
112
|
+
val proto = response.header("Sec-WebSocket-Protocol")
|
|
113
|
+
val payload = if (proto.isNullOrEmpty()) emptyMap<String, Any?>() else mapOf("protocol" to proto)
|
|
114
|
+
deliverEvent(runtime, packageName, sid, "open", payload)
|
|
115
|
+
}
|
|
116
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
117
|
+
deliverEvent(runtime, packageName, sid, "message", mapOf("kind" to "text", "data" to text))
|
|
118
|
+
}
|
|
119
|
+
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
|
120
|
+
val b64 = Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)
|
|
121
|
+
deliverEvent(runtime, packageName, sid, "message", mapOf("kind" to "binary", "data" to b64))
|
|
122
|
+
}
|
|
123
|
+
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
|
124
|
+
webSocket.close(code, reason)
|
|
125
|
+
}
|
|
126
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
127
|
+
val rec = sockets.remove(sid) ?: return
|
|
128
|
+
if (!rec.closedSent) {
|
|
129
|
+
rec.closedSent = true
|
|
130
|
+
deliverEvent(runtime, packageName, sid, "close", mapOf("code" to code, "reason" to reason))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
|
134
|
+
deliverEvent(runtime, packageName, sid, "error", mapOf("message" to (t.message ?: "ws error")))
|
|
135
|
+
val rec = sockets.remove(sid) ?: return
|
|
136
|
+
if (!rec.closedSent) {
|
|
137
|
+
rec.closedSent = true
|
|
138
|
+
deliverEvent(runtime, packageName, sid, "close", mapOf("code" to 1006, "reason" to (t.message ?: "")))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
val socket = httpClient.newWebSocket(builder.build(), listener)
|
|
143
|
+
sockets[sid] = OkSocketRecord(packageName, socket)
|
|
144
|
+
JSCDispatchOutcome.Sync("null")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
runtime.dispatcher.register("ws", "send") { _, args, _ ->
|
|
148
|
+
val req = args.firstOrNull() as? Map<*, *>
|
|
149
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send expects {sid, kind, payload}")
|
|
150
|
+
val sid = req["sid"] as? String
|
|
151
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing sid")
|
|
152
|
+
val kind = req["kind"] as? String
|
|
153
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing kind")
|
|
154
|
+
val payload = req["payload"] as? String
|
|
155
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: missing payload")
|
|
156
|
+
val rec = sockets[sid]
|
|
157
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: unknown sid")
|
|
158
|
+
val ok = when (kind) {
|
|
159
|
+
"text" -> rec.socket.send(payload)
|
|
160
|
+
"binary" -> {
|
|
161
|
+
val bytes = try {
|
|
162
|
+
Base64.decode(payload, Base64.DEFAULT)
|
|
163
|
+
} catch (e: IllegalArgumentException) {
|
|
164
|
+
return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: bad base64")
|
|
165
|
+
}
|
|
166
|
+
rec.socket.send(bytes.toByteString())
|
|
167
|
+
}
|
|
168
|
+
else -> return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.send: kind must be text|binary")
|
|
169
|
+
}
|
|
170
|
+
if (!ok) {
|
|
171
|
+
Log.w(TAG, "ws.send returned false for sid=$sid (queue full?)")
|
|
172
|
+
}
|
|
173
|
+
JSCDispatchOutcome.Sync("null")
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
runtime.dispatcher.register("ws", "close") { _, args, _ ->
|
|
177
|
+
val req = args.firstOrNull() as? Map<*, *>
|
|
178
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.close expects {sid, code?, reason?}")
|
|
179
|
+
val sid = req["sid"] as? String
|
|
180
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "ws.close: missing sid")
|
|
181
|
+
val rec = sockets[sid] ?: return@register JSCDispatchOutcome.Sync("null")
|
|
182
|
+
val code = (req["code"] as? Number)?.toInt() ?: 1000
|
|
183
|
+
val reason = (req["reason"] as? String) ?: ""
|
|
184
|
+
rec.socket.close(code, reason)
|
|
185
|
+
JSCDispatchOutcome.Sync("null")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun deliverEvent(
|
|
190
|
+
runtime: JSCRuntime,
|
|
191
|
+
packageName: String,
|
|
192
|
+
sid: String,
|
|
193
|
+
wsType: String,
|
|
194
|
+
payload: Map<String, Any?>,
|
|
195
|
+
) {
|
|
196
|
+
val envelope = JSONObject().apply {
|
|
197
|
+
put("kind", "ws-event")
|
|
198
|
+
put("sid", sid)
|
|
199
|
+
put("wsType", wsType)
|
|
200
|
+
put("payload", JSONObject(payload as Map<*, *>))
|
|
201
|
+
}
|
|
202
|
+
runtime.dispatchToJs(packageName, envelope.toString())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun installFetch(runtime: JSCRuntime) {
|
|
206
|
+
runtime.dispatcher.register("fetch", "request") { packageName, args, reqId ->
|
|
207
|
+
val req = args.firstOrNull() as? Map<*, *>
|
|
208
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request expects {url,...}")
|
|
209
|
+
val url = req["url"] as? String
|
|
210
|
+
?: return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request: missing url")
|
|
211
|
+
if (reqId == null) {
|
|
212
|
+
return@register JSCDispatchOutcome.Error("INVALID_ARGS", "fetch.request requires reqId (use __mentraSendRequest)")
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val method = (req["method"] as? String) ?: "GET"
|
|
216
|
+
val headers = (req["headers"] as? Map<*, *>)?.mapNotNull { (k, v) ->
|
|
217
|
+
val ks = k as? String ?: return@mapNotNull null
|
|
218
|
+
val vs = (v as? String) ?: v?.toString() ?: return@mapNotNull null
|
|
219
|
+
ks to vs
|
|
220
|
+
}?.toMap() ?: emptyMap()
|
|
221
|
+
val bodyString = req["body"] as? String
|
|
222
|
+
|
|
223
|
+
val builder = Request.Builder().url(url)
|
|
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)
|
|
235
|
+
|
|
236
|
+
httpClient.newCall(builder.build()).enqueue(object : Callback {
|
|
237
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
238
|
+
deliverError(runtime, packageName, reqId, "fetch: ${e.message}")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
override fun onResponse(call: Call, response: Response) {
|
|
242
|
+
response.use { r ->
|
|
243
|
+
val status = r.code
|
|
244
|
+
val statusText = r.message
|
|
245
|
+
val headerMap = mutableMapOf<String, String>()
|
|
246
|
+
for (name in r.headers.names()) {
|
|
247
|
+
headerMap[name.lowercase()] = r.headers.values(name).joinToString(", ")
|
|
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())
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
JSCDispatchOutcome.Async
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun deliverError(runtime: JSCRuntime, packageName: String, reqId: String, message: String) {
|
|
276
|
+
val envelope = JSONObject().apply {
|
|
277
|
+
put("kind", "response")
|
|
278
|
+
put("reqId", reqId)
|
|
279
|
+
put("ok", false)
|
|
280
|
+
put("error", JSONObject().apply {
|
|
281
|
+
put("code", "NATIVE_THROW")
|
|
282
|
+
put("message", message)
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
runtime.dispatchToJs(packageName, envelope.toString())
|
|
286
|
+
}
|
|
287
|
+
}
|