@mentra/crust 0.1.0-dev.0 → 3.1.0-dev.1

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 CHANGED
@@ -1,35 +1,41 @@
1
- # crust
1
+ # @mentra/crust
2
2
 
3
- Mentra Native Module
3
+ The MentraOS native runtime layer: an [Expo module](https://docs.expo.dev/modules/overview/)
4
+ providing the native capabilities the Mentra Engine's miniapp runtime sits on —
5
+ per-miniapp JS contexts (QuickJS on Android, JavaScriptCore on iOS), the
6
+ native side of the MentraJS bridge, navigation, and device utilities.
4
7
 
5
- # API documentation
8
+ You don't call crust directly from app code: it's a **peer dependency of
9
+ [`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine)**. A host app
10
+ embedding the engine installs crust alongside it and Expo autolinking picks it
11
+ up.
6
12
 
7
- - [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/crust/)
8
- - [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/crust/)
13
+ ## Install
9
14
 
10
- # Installation in managed Expo projects
11
-
12
- For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
13
-
14
- # Installation in bare React Native projects
15
-
16
- For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
17
-
18
- ### Add the package to your npm dependencies
19
-
20
- ```
21
- npm install crust
15
+ ```sh
16
+ npm install @mentra/crust@dev
22
17
  ```
23
18
 
24
- ### Configure for Android
19
+ > Currently published on the `dev` dist-tag (prerelease channel).
25
20
 
21
+ ## Config plugin
26
22
 
23
+ The package ships an Expo config plugin (`app.plugin.js`) that carries its
24
+ Android build contract — Mapbox's maven repository, protobuf exclusions, and
25
+ core-library desugaring. Add it to the host app's Expo config:
27
26
 
27
+ ```json
28
+ {"expo": {"plugins": ["@mentra/crust"]}}
29
+ ```
28
30
 
29
- ### Configure for iOS
31
+ Building with the navigation feature requires a `MAPBOX_DOWNLOADS_TOKEN` in
32
+ the Android build environment (Mapbox's SDK repository is authenticated).
30
33
 
31
- Run `npx pod-install` after installing the npm package.
34
+ At build time the Android side also reads the MentraJS polyfill bundle from
35
+ its [`@mentra/jspolyfill`](https://www.npmjs.com/package/@mentra/jspolyfill)
36
+ sibling, which is declared as a dependency.
32
37
 
33
- # Contributing
38
+ ## Part of MentraOS
34
39
 
35
- Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
40
+ Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
41
+ under `mobile/modules/crust`. Issues and contributions welcome there.
@@ -1,9 +1,16 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <permission
3
+ android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE"
4
+ android:protectionLevel="signature" />
5
+ <uses-permission android:name="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE" />
6
+
2
7
  <application>
3
8
  <service
4
9
  android:name="com.mentra.crust.services.NotificationListenerServiceImpl"
5
10
  android:label="@string/mentra_crust_notification_listener_label"
11
+ android:enabled="false"
6
12
  android:exported="false"
13
+ android:process=":notif"
7
14
  android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
8
15
  <intent-filter>
9
16
  <action android:name="android.service.notification.NotificationListenerService" />
@@ -12,6 +19,13 @@
12
19
  <meta-data android:name="android.service.notification.disabled_filter_types" android:value="ongoing|silent" />
13
20
  </service>
14
21
 
22
+ <receiver
23
+ android:name="com.mentra.crust.services.NotificationConfigReceiver"
24
+ android:enabled="true"
25
+ android:exported="false"
26
+ android:permission="${applicationId}.permission.CRUST_NOTIFICATION_BRIDGE"
27
+ android:process=":notif" />
28
+
15
29
  <!-- The Mapbox Navigation SDK delivers turn-by-turn steps inline via
16
30
  RouteProgress (no Messenger-IPC service), so the Google-era
17
31
  NavInfoReceiverService is gone. Nothing to register here for nav. -->
@@ -1,7 +1,9 @@
1
1
  package com.mentra.crust
2
2
 
3
+ import android.content.BroadcastReceiver
3
4
  import android.util.Log
4
5
  import com.mentra.crust.services.NotificationListener
6
+ import com.mentra.crust.services.NotificationProcessBridge
5
7
  import expo.modules.kotlin.modules.Module
6
8
  import expo.modules.kotlin.modules.ModuleDefinition
7
9
  import java.net.URL
@@ -19,34 +21,33 @@ class CrustModule : Module() {
19
21
  @Volatile private var eventEmitter: ((String, Map<String, Any>) -> Unit)? = null
20
22
 
21
23
  fun emitPhoneNotification(
24
+ context: android.content.Context,
22
25
  notificationKey: String,
23
26
  packageName: String,
24
27
  appName: String,
25
28
  title: String,
26
29
  text: String,
27
30
  timestamp: Long,
31
+ priority: Int,
28
32
  ) {
29
- val data =
30
- mapOf(
31
- "notificationId" to "$packageName-$notificationKey",
32
- "app" to appName,
33
- "title" to title.ifEmpty { appName },
34
- "content" to text,
35
- "priority" to "normal",
36
- "timestamp" to timestamp,
37
- "packageName" to packageName,
38
- )
39
- emitEvent("phone_notification", data)
33
+ NotificationProcessBridge.emitPosted(
34
+ context,
35
+ notificationKey,
36
+ packageName,
37
+ appName,
38
+ title,
39
+ text,
40
+ timestamp,
41
+ priority,
42
+ )
40
43
  }
41
44
 
42
- fun emitPhoneNotificationDismissed(notificationKey: String, packageName: String) {
43
- val data =
44
- mapOf(
45
- "notificationId" to "$packageName-$notificationKey",
46
- "notificationKey" to notificationKey,
47
- "packageName" to packageName,
48
- )
49
- emitEvent("phone_notification_dismissed", data)
45
+ fun emitPhoneNotificationDismissed(
46
+ context: android.content.Context,
47
+ notificationKey: String,
48
+ packageName: String,
49
+ ) {
50
+ NotificationProcessBridge.emitDismissed(context, notificationKey, packageName)
50
51
  }
51
52
 
52
53
  fun emitCaptionsTesterIncident(data: Map<String, Any>) {
@@ -75,6 +76,23 @@ class CrustModule : Module() {
75
76
  // __dispatch from a per-miniapp QuickJS context (SUBSCRIBE, mic, location,
76
77
  // display, send, etc.) would be silently dropped on Android.
77
78
  @Volatile private var runtimeInstalled: Boolean = false
79
+ private var notificationEventReceiver: BroadcastReceiver? = null
80
+ private var notificationBridgeContext: android.content.Context? = null
81
+
82
+ private fun registerNotificationBridgeIfPossible(): Boolean {
83
+ if (notificationEventReceiver != null) return true
84
+ val context = appContext.reactContext ?: appContext.currentActivity ?: return false
85
+ val applicationContext = context.applicationContext
86
+ notificationEventReceiver =
87
+ NotificationProcessBridge.register(applicationContext) { eventName, data ->
88
+ emitEvent(eventName, data)
89
+ }
90
+ // Keep the exact long-lived context used to register the receiver. Expo may
91
+ // clear reactContext/currentActivity before OnDestroy, but Android still
92
+ // requires this receiver to be unregistered when the module is recreated.
93
+ notificationBridgeContext = applicationContext
94
+ return true
95
+ }
78
96
 
79
97
  private fun installRuntimeIfPossible(reason: String): Boolean {
80
98
  if (runtimeInstalled) return true
@@ -118,9 +136,21 @@ class CrustModule : Module() {
118
136
 
119
137
  OnCreate {
120
138
  eventEmitter = { eventName, data -> sendEvent(eventName, data) }
139
+ registerNotificationBridgeIfPossible()
121
140
  installRuntimeIfPossible("OnCreate")
122
141
  }
123
142
 
143
+ OnDestroy {
144
+ val context = notificationBridgeContext
145
+ val receiver = notificationEventReceiver
146
+ if (context != null && receiver != null) {
147
+ NotificationProcessBridge.unregister(context, receiver)
148
+ }
149
+ notificationEventReceiver = null
150
+ notificationBridgeContext = null
151
+ eventEmitter = null
152
+ }
153
+
124
154
  Function("hello") {
125
155
  "Hello world! 👋"
126
156
  }
@@ -129,6 +159,17 @@ class CrustModule : Module() {
129
159
  sendEvent("onChange", mapOf("value" to value))
130
160
  }
131
161
 
162
+ AsyncFunction("nativeHttpRequest") {
163
+ method: String, url: String, headers: Map<String, String>, body: String? ->
164
+ val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
165
+ mapOf(
166
+ "status" to result.status,
167
+ "statusText" to result.statusText,
168
+ "headers" to result.headers,
169
+ "body" to result.body,
170
+ )
171
+ }
172
+
132
173
  Function("showAVRoutePicker") { _: String? ->
133
174
  // iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
134
175
  }
@@ -141,12 +182,18 @@ class CrustModule : Module() {
141
182
 
142
183
  // MARK: - MentraOS Notification Commands
143
184
 
144
- AsyncFunction("setNotificationConfig") { enabled: Boolean, blocklist: List<String> ->
185
+ AsyncFunction("setNotificationConfig") {
186
+ listenerEnabled: Boolean, blocklist: List<String> ->
187
+ registerNotificationBridgeIfPossible()
145
188
  val context =
146
189
  appContext.reactContext
147
190
  ?: appContext.currentActivity
148
191
  ?: throw IllegalStateException("No context available")
149
- NotificationListener.getInstance(context).setNotificationConfig(enabled, blocklist)
192
+ NotificationListener.setNotificationConfig(
193
+ context,
194
+ listenerEnabled,
195
+ blocklist,
196
+ )
150
197
  }
151
198
 
152
199
  AsyncFunction("getInstalledApps") {
@@ -154,7 +201,7 @@ class CrustModule : Module() {
154
201
  appContext.reactContext
155
202
  ?: appContext.currentActivity
156
203
  ?: throw IllegalStateException("No context available")
157
- NotificationListener.getInstance(context).getInstalledApps()
204
+ NotificationListener.getInstalledApps(context)
158
205
  }
159
206
 
160
207
  AsyncFunction("getInstalledAppsForNotifications") {
@@ -162,7 +209,7 @@ class CrustModule : Module() {
162
209
  appContext.reactContext
163
210
  ?: appContext.currentActivity
164
211
  ?: throw IllegalStateException("No context available")
165
- NotificationListener.getInstance(context).getInstalledApps()
212
+ NotificationListener.getInstalledApps(context)
166
213
  }
167
214
 
168
215
  AsyncFunction("hasNotificationListenerPermission") {
@@ -170,7 +217,15 @@ class CrustModule : Module() {
170
217
  appContext.reactContext
171
218
  ?: appContext.currentActivity
172
219
  ?: throw IllegalStateException("No context available")
173
- NotificationListener.getInstance(context).hasNotificationListenerPermission()
220
+ NotificationListener.hasNotificationListenerPermission(context)
221
+ }
222
+
223
+ AsyncFunction("refreshNotificationListener") {
224
+ val context =
225
+ appContext.reactContext
226
+ ?: appContext.currentActivity
227
+ ?: throw IllegalStateException("No context available")
228
+ NotificationListener.refreshComponentForPermission(context)
174
229
  }
175
230
 
176
231
  AsyncFunction("openNotificationListenerSettings") {
@@ -178,7 +233,7 @@ class CrustModule : Module() {
178
233
  appContext.reactContext
179
234
  ?: appContext.currentActivity
180
235
  ?: throw IllegalStateException("No context available")
181
- NotificationListener.getInstance(context).openNotificationListenerSettings()
236
+ NotificationListener.openNotificationListenerSettings(context)
182
237
  true
183
238
  }
184
239
 
@@ -575,6 +630,55 @@ class CrustModule : Module() {
575
630
  }
576
631
  }
577
632
 
633
+ val relativePath =
634
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
635
+ if (isVideo) "Movies/Mentra" else "Pictures/Mentra"
636
+ } else {
637
+ null
638
+ }
639
+ val resolver = context.contentResolver
640
+ val stableDisplayName =
641
+ mediaDisplayName.takeIf {
642
+ it.startsWith("IMG_") || it.startsWith("VID_")
643
+ }
644
+ val existingUri =
645
+ stableDisplayName?.let {
646
+ findExistingGalleryAsset(
647
+ resolver,
648
+ collection,
649
+ listOf(it),
650
+ file.length(),
651
+ captureTimeMillis,
652
+ relativePath,
653
+ context.packageName,
654
+ requireUniqueMatch = false,
655
+ )
656
+ }
657
+ ?: findExistingGalleryAsset(
658
+ resolver,
659
+ collection,
660
+ listOfNotNull(
661
+ mediaDisplayName.takeIf { stableDisplayName == null },
662
+ file.name.takeIf {
663
+ it.isNotBlank() && it != stableDisplayName
664
+ },
665
+ )
666
+ .distinct(),
667
+ file.length(),
668
+ captureTimeMillis,
669
+ relativePath,
670
+ context.packageName,
671
+ requireUniqueMatch = true,
672
+ )
673
+ if (existingUri != null) {
674
+ android.util.Log.d("CrustModule", "Reusing existing gallery asset")
675
+ return@AsyncFunction mapOf(
676
+ "success" to true,
677
+ "uri" to existingUri.toString(),
678
+ "existing" to true,
679
+ )
680
+ }
681
+
578
682
  val values =
579
683
  android.content.ContentValues().apply {
580
684
  put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
@@ -593,19 +697,12 @@ class CrustModule : Module() {
593
697
  )
594
698
  }
595
699
 
596
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
597
- val relativePath =
598
- if (isVideo) {
599
- "Movies/Mentra"
600
- } else {
601
- "Pictures/Mentra"
602
- }
700
+ if (relativePath != null) {
603
701
  put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
604
702
  put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
605
703
  }
606
704
  }
607
705
 
608
- val resolver = context.contentResolver
609
706
  val uri =
610
707
  resolver.insert(collection, values)
611
708
  ?: throw IllegalStateException("Failed to create MediaStore entry")
@@ -879,4 +976,111 @@ class CrustModule : Module() {
879
976
  mapOf("ok" to true)
880
977
  }
881
978
  }
979
+
980
+ /**
981
+ * Find a completed export from a previous attempt. A crash can happen after MediaStore commits
982
+ * but before JavaScript persists the URI receipt; the stable capture display name, size, and
983
+ * capture time, and Mentra album path let the retry return that receipt instead of inserting a
984
+ * duplicate. Scoping by album is also important before deleting interrupted pending rows: a
985
+ * same-named asset owned by another album must never be treated as ours.
986
+ */
987
+ private fun findExistingGalleryAsset(
988
+ resolver: android.content.ContentResolver,
989
+ collection: android.net.Uri,
990
+ displayNames: List<String>,
991
+ size: Long,
992
+ captureTimeMillis: Long?,
993
+ relativePath: String?,
994
+ ownerPackageName: String,
995
+ requireUniqueMatch: Boolean,
996
+ ): android.net.Uri? {
997
+ val dateColumn = android.provider.MediaStore.Images.ImageColumns.DATE_TAKEN
998
+ if (displayNames.isEmpty()) return null
999
+ val namePlaceholders = displayNames.joinToString(",") { "?" }
1000
+ val selectionParts =
1001
+ mutableListOf(
1002
+ "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} IN ($namePlaceholders)",
1003
+ )
1004
+ val selectionArgs = displayNames.toMutableList()
1005
+ if (captureTimeMillis != null) {
1006
+ selectionParts.add("$dateColumn = ?")
1007
+ selectionArgs.add(captureTimeMillis.toString())
1008
+ }
1009
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
1010
+ relativePath != null
1011
+ ) {
1012
+ val pathWithoutTrailingSlash = relativePath.trimEnd('/')
1013
+ selectionParts.add(
1014
+ "(${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ? OR " +
1015
+ "${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ?)"
1016
+ )
1017
+ // MediaProvider normally canonicalizes RELATIVE_PATH with a trailing slash. Accept the
1018
+ // caller's original representation too so exports created by older Android builds remain
1019
+ // reconcilable, while still requiring an exact Mentra album match.
1020
+ selectionArgs.add(pathWithoutTrailingSlash)
1021
+ selectionArgs.add("$pathWithoutTrailingSlash/")
1022
+ // Never reconcile or delete another application's pending MediaStore row.
1023
+ selectionParts.add("${android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?")
1024
+ selectionArgs.add(ownerPackageName)
1025
+ }
1026
+ val projection =
1027
+ mutableListOf(
1028
+ android.provider.BaseColumns._ID,
1029
+ android.provider.MediaStore.MediaColumns.SIZE,
1030
+ )
1031
+ .apply {
1032
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1033
+ add(android.provider.MediaStore.MediaColumns.IS_PENDING)
1034
+ }
1035
+ }
1036
+
1037
+ return try {
1038
+ resolver
1039
+ .query(
1040
+ collection,
1041
+ projection.toTypedArray(),
1042
+ selectionParts.joinToString(" AND "),
1043
+ selectionArgs.toTypedArray(),
1044
+ "${android.provider.BaseColumns._ID} DESC",
1045
+ )
1046
+ ?.use { cursor ->
1047
+ val candidates = mutableListOf<android.net.Uri>()
1048
+ val idColumn = cursor.getColumnIndexOrThrow(android.provider.BaseColumns._ID)
1049
+ val sizeColumn =
1050
+ cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns.SIZE)
1051
+ val pendingColumn =
1052
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1053
+ cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.IS_PENDING)
1054
+ } else {
1055
+ -1
1056
+ }
1057
+ while (cursor.moveToNext()) {
1058
+ val uri =
1059
+ android.content.ContentUris.withAppendedId(
1060
+ collection,
1061
+ cursor.getLong(idColumn),
1062
+ )
1063
+ if (pendingColumn >= 0 && cursor.getInt(pendingColumn) != 0) {
1064
+ // This app owns the row and it was never published. Remove the interrupted
1065
+ // placeholder before retrying the copy, even if it contains only a prefix.
1066
+ resolver.delete(uri, null, null)
1067
+ continue
1068
+ }
1069
+ if (cursor.getLong(sizeColumn) == size) candidates.add(uri)
1070
+ }
1071
+ if (requireUniqueMatch) {
1072
+ // A legacy `base.jpg`/`base.mp4` match is safe only when the complete
1073
+ // name/date/size/path/owner fingerprint identifies exactly one asset.
1074
+ candidates.singleOrNull()
1075
+ } else {
1076
+ // Capture-derived display names are stable. If an older retry already made
1077
+ // duplicates, reuse the newest completed row instead of creating another.
1078
+ candidates.firstOrNull()
1079
+ }
1080
+ }
1081
+ } catch (error: Exception) {
1082
+ android.util.Log.w("CrustModule", "Unable to reconcile existing gallery asset", error)
1083
+ null
1084
+ }
1085
+ }
882
1086
  }
@@ -45,6 +45,41 @@ object JSCPolyfillBridge {
45
45
  .build()
46
46
  }
47
47
 
48
+ data class HttpResult(
49
+ val status: Int,
50
+ val statusText: String,
51
+ val headers: Map<String, String>,
52
+ val body: String,
53
+ )
54
+
55
+ /** Shared OkHttp execution path for host cloud-client requests. */
56
+ fun executeHttp(method: String, url: String, headers: Map<String, String>, bodyString: String?): HttpResult {
57
+ val 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
+
48
83
  /** Idempotent. Call once on host boot, after the dispatcher is created. */
49
84
  fun install(runtime: JSCRuntime) {
50
85
  installFetch(runtime)
@@ -187,8 +222,14 @@ object JSCPolyfillBridge {
187
222
 
188
223
  val builder = Request.Builder().url(url)
189
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
190
231
  val body = if (bodyString.isNullOrEmpty()) null else bodyString.toRequestBody(
191
- (headers["content-type"] ?: "application/octet-stream").toMediaTypeOrNull()
232
+ (contentType ?: "application/octet-stream").toMediaTypeOrNull()
192
233
  )
193
234
  builder.method(method.uppercase(), body)
194
235
 
@@ -436,6 +436,8 @@ object NavigationManager {
436
436
  .alternatives(false)
437
437
  .steps(true)
438
438
  .bannerInstructions(true)
439
+ // The onboard router rejects voice units when voice instructions are disabled.
440
+ .voiceUnits(null)
439
441
  .voiceInstructions(false)
440
442
  .exclude(buildExclude(options))
441
443
  .build()