@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.
Files changed (66) hide show
  1. package/README.md +41 -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 +1042 -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 +287 -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 +182 -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 +34 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +63 -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 +781 -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 +191 -0
  63. package/src/CrustModule.web.ts +66 -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,319 @@
1
+ package com.mentra.crust.services
2
+
3
+ import android.content.ComponentName
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.pm.ApplicationInfo
7
+ import android.content.pm.PackageManager
8
+ import android.os.Handler
9
+ import android.os.HandlerThread
10
+ import android.provider.Settings
11
+ import android.service.notification.NotificationListenerService
12
+ import android.service.notification.StatusBarNotification
13
+ import android.text.TextUtils
14
+ import android.util.Base64
15
+ import android.util.Log
16
+ import com.mentra.crust.CrustModule
17
+
18
+ class NotificationListener private constructor(private val context: Context) {
19
+ companion object {
20
+ private const val TAG = "CrustNotificationListener"
21
+ private const val PREFS_NAME = "mentra_crust_notification_prefs"
22
+ private const val PREF_NOTIFICATIONS_ENABLED = "notifications_enabled"
23
+ private const val PREF_NOTIFICATIONS_BLOCKLIST = "notifications_blocklist"
24
+
25
+ @Volatile private var instance: NotificationListener? = null
26
+
27
+ fun getInstance(context: Context): NotificationListener {
28
+ return instance
29
+ ?: synchronized(this) {
30
+ instance
31
+ ?: NotificationListener(context.applicationContext).also {
32
+ instance = it
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ private val listeners = mutableListOf<OnNotificationReceivedListener>()
39
+
40
+ // Deduplication tracking with dedicated background thread. Using HandlerThread
41
+ // keeps the service independent from the app lifecycle on newer Android versions.
42
+ private val notificationBuffer = mutableMapOf<String, Runnable>()
43
+ private val notificationThread = HandlerThread("NotificationHandler").apply { start() }
44
+ private val notificationHandler = Handler(notificationThread.looper)
45
+ private val duplicateThresholdMs = 200L
46
+
47
+ private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
48
+
49
+ @Volatile private var notificationsEnabled = preferences.getBoolean(PREF_NOTIFICATIONS_ENABLED, false)
50
+ @Volatile
51
+ private var notificationsBlocklist =
52
+ preferences.getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())?.toSet() ?: emptySet()
53
+
54
+ /** Keep MentraOS notification settings in Crust instead of Bluetooth SDK state. */
55
+ fun setNotificationConfig(enabled: Boolean, blocklist: List<String>) {
56
+ val blocklistSet = blocklist.toSet()
57
+ notificationsEnabled = enabled
58
+ notificationsBlocklist = blocklistSet
59
+ preferences
60
+ .edit()
61
+ .putBoolean(PREF_NOTIFICATIONS_ENABLED, enabled)
62
+ .putStringSet(PREF_NOTIFICATIONS_BLOCKLIST, blocklistSet)
63
+ .apply()
64
+ }
65
+
66
+ /** Check if notification listener permission is granted. */
67
+ fun hasNotificationListenerPermission(): Boolean {
68
+ val packageName = context.packageName
69
+ val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
70
+
71
+ if (!TextUtils.isEmpty(flat)) {
72
+ val names = flat.split(":")
73
+ for (name in names) {
74
+ val componentName = ComponentName.unflattenFromString(name)
75
+ if (componentName != null && TextUtils.equals(packageName, componentName.packageName)) {
76
+ return true
77
+ }
78
+ }
79
+ }
80
+ return false
81
+ }
82
+
83
+ /** Open notification listener settings. */
84
+ fun openNotificationListenerSettings() {
85
+ val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
86
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
87
+ context.startActivity(intent)
88
+ }
89
+
90
+ /** Add a listener for notifications. */
91
+ fun addListener(listener: OnNotificationReceivedListener) {
92
+ if (!listeners.contains(listener)) {
93
+ listeners.add(listener)
94
+ }
95
+ }
96
+
97
+ /** Remove a listener. */
98
+ fun removeListener(listener: OnNotificationReceivedListener) {
99
+ listeners.remove(listener)
100
+ }
101
+
102
+ /** Allowlist for messaging apps that should not be blocked even if they match system patterns. */
103
+ private val messagingAppAllowlist =
104
+ setOf(
105
+ "com.google.android.apps.messaging",
106
+ "com.samsung.android.messaging",
107
+ "com.android.mms",
108
+ "com.google.android.gm",
109
+ "com.samsung.android.email.provider",
110
+ )
111
+
112
+ /** Check if this is a system package that should be blocked. */
113
+ private fun isSystemPackageToBlock(packageName: String): Boolean {
114
+ if (messagingAppAllowlist.contains(packageName)) {
115
+ return false
116
+ }
117
+
118
+ val pkg = packageName.lowercase()
119
+ return pkg.contains("google") || pkg.contains("samsung") || pkg.contains(".sec.")
120
+ }
121
+
122
+ /** Called internally by the service when a notification is posted. */
123
+ internal fun onNotificationPosted(sbn: StatusBarNotification) {
124
+ val packageName = sbn.packageName
125
+ Log.d(TAG, "Received notification from $packageName (key: ${sbn.key})")
126
+
127
+ if (isSystemPackageToBlock(packageName)) {
128
+ Log.d(TAG, "Blocking system package: $packageName")
129
+ return
130
+ }
131
+
132
+ if (notificationsBlocklist.contains(packageName)) {
133
+ Log.d(TAG, "Notification in blocklist, returning")
134
+ return
135
+ }
136
+
137
+ if (!notificationsEnabled) {
138
+ Log.d(TAG, "Notifications disabled globally")
139
+ return
140
+ }
141
+
142
+ val notification = sbn.notification
143
+ val extras = notification.extras
144
+ val title = extras.getCharSequence("android.title")?.toString() ?: ""
145
+ val text = extras.getCharSequence("android.text")?.toString() ?: ""
146
+
147
+ if (title.isEmpty() && text.isEmpty()) {
148
+ Log.d(TAG, "Ignoring empty notification")
149
+ return
150
+ }
151
+
152
+ if (text.matches(Regex("^\\d+ new messages$"))) {
153
+ Log.d(TAG, "Ignoring summary notification: $text")
154
+ return
155
+ }
156
+
157
+ val packageManager = context.packageManager
158
+ val appName =
159
+ try {
160
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
161
+ packageManager.getApplicationLabel(appInfo).toString()
162
+ } catch (_: Exception) {
163
+ packageName
164
+ }
165
+
166
+ val notificationKey = "$packageName|$title|$text"
167
+
168
+ synchronized(notificationBuffer) {
169
+ notificationBuffer[notificationKey]?.let {
170
+ notificationHandler.removeCallbacks(it)
171
+ notificationBuffer.remove(notificationKey)
172
+ }
173
+
174
+ val task =
175
+ Runnable {
176
+ try {
177
+ Log.d(TAG, "Processing buffered notification from $appName")
178
+ CrustModule.emitPhoneNotification(
179
+ notificationKey = sbn.key,
180
+ packageName = packageName,
181
+ appName = appName,
182
+ title = title,
183
+ text = text,
184
+ timestamp = sbn.postTime,
185
+ )
186
+
187
+ val notificationData =
188
+ NotificationData(
189
+ packageName = packageName,
190
+ title = title,
191
+ text = text,
192
+ timestamp = sbn.postTime,
193
+ id = sbn.id,
194
+ tag = sbn.tag,
195
+ )
196
+ listeners.forEach { listener ->
197
+ listener.onNotificationReceived(notificationData)
198
+ }
199
+ } catch (e: Exception) {
200
+ Log.e(TAG, "Error processing notification: ${e.message}", e)
201
+ } finally {
202
+ synchronized(notificationBuffer) {
203
+ notificationBuffer.remove(notificationKey)
204
+ }
205
+ }
206
+ }
207
+
208
+ notificationBuffer[notificationKey] = task
209
+ Log.d(TAG, "Buffering notification (${duplicateThresholdMs}ms delay)")
210
+ notificationHandler.postDelayed(task, duplicateThresholdMs)
211
+ }
212
+ }
213
+
214
+ /** Called internally by the service when a notification is removed. */
215
+ internal fun onNotificationRemoved(sbn: StatusBarNotification) {
216
+ val packageName = sbn.packageName
217
+ val notificationKey = sbn.key
218
+
219
+ Log.d(TAG, "Notification removed - package: $packageName, key: $notificationKey")
220
+
221
+ CrustModule.emitPhoneNotificationDismissed(
222
+ notificationKey = notificationKey,
223
+ packageName = packageName,
224
+ )
225
+
226
+ listeners.forEach { listener -> listener.onNotificationRemoved(packageName, sbn.id) }
227
+ }
228
+
229
+ /** Interface for notification callbacks. */
230
+ interface OnNotificationReceivedListener {
231
+ fun onNotificationReceived(notification: NotificationData)
232
+ fun onNotificationRemoved(packageName: String, notificationId: Int) {}
233
+ }
234
+
235
+ /** Data class for notification info. */
236
+ data class NotificationData(
237
+ val packageName: String,
238
+ val title: String,
239
+ val text: String,
240
+ val timestamp: Long,
241
+ val id: Int,
242
+ val tag: String?,
243
+ )
244
+
245
+ /** Get all installed apps with details. */
246
+ fun getInstalledApps(): List<Map<String, Any?>> {
247
+ val packageManager = context.packageManager
248
+ val packages = packageManager.getInstalledApplications(PackageManager.GET_META_DATA)
249
+ val blocklist = notificationsBlocklist
250
+
251
+ return packages
252
+ .filter { it.flags and ApplicationInfo.FLAG_SYSTEM == 0 }
253
+ .map { appInfo ->
254
+ val icon =
255
+ try {
256
+ val drawable = packageManager.getApplicationIcon(appInfo.packageName)
257
+ val bitmap = (drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
258
+ if (bitmap != null) {
259
+ val outputStream = java.io.ByteArrayOutputStream()
260
+ bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, outputStream)
261
+ val byteArray = outputStream.toByteArray()
262
+ "data:image/png;base64," + Base64.encodeToString(byteArray, Base64.NO_WRAP)
263
+ } else {
264
+ null
265
+ }
266
+ } catch (_: Exception) {
267
+ null
268
+ }
269
+
270
+ mapOf(
271
+ "packageName" to appInfo.packageName,
272
+ "appName" to packageManager.getApplicationLabel(appInfo).toString(),
273
+ "isBlocked" to blocklist.contains(appInfo.packageName),
274
+ "icon" to icon,
275
+ )
276
+ }
277
+ .sortedBy { it["appName"] as String }
278
+ }
279
+
280
+ /** Clean up resources when the service is destroyed. */
281
+ fun cleanup() {
282
+ Log.d(TAG, "Cleaning up notification handler thread")
283
+ synchronized(notificationBuffer) {
284
+ notificationBuffer.values.forEach { notificationHandler.removeCallbacks(it) }
285
+ notificationBuffer.clear()
286
+ }
287
+ notificationThread.quitSafely()
288
+ }
289
+ }
290
+
291
+ /** The actual NotificationListenerService implementation. */
292
+ class NotificationListenerServiceImpl : NotificationListenerService() {
293
+ override fun onNotificationPosted(sbn: StatusBarNotification) {
294
+ super.onNotificationPosted(sbn)
295
+ NotificationListener.getInstance(applicationContext).onNotificationPosted(sbn)
296
+ }
297
+
298
+ override fun onNotificationRemoved(sbn: StatusBarNotification) {
299
+ super.onNotificationRemoved(sbn)
300
+ NotificationListener.getInstance(applicationContext).onNotificationRemoved(sbn)
301
+ }
302
+
303
+ override fun onListenerConnected() {
304
+ super.onListenerConnected()
305
+ Log.d("CrustNotificationListener", "NotificationListenerService connected and ready")
306
+ }
307
+
308
+ override fun onListenerDisconnected() {
309
+ super.onListenerDisconnected()
310
+ Log.d("CrustNotificationListener", "NotificationListenerService disconnected, requesting rebind")
311
+ requestRebind(ComponentName(this, NotificationListenerServiceImpl::class.java))
312
+ }
313
+
314
+ override fun onDestroy() {
315
+ super.onDestroy()
316
+ Log.d("CrustNotificationListener", "NotificationListenerService being destroyed")
317
+ NotificationListener.getInstance(applicationContext).cleanup()
318
+ }
319
+ }