@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.
@@ -14,12 +14,13 @@ import android.text.TextUtils
14
14
  import android.util.Base64
15
15
  import android.util.Log
16
16
  import com.mentra.crust.CrustModule
17
+ import java.util.concurrent.ConcurrentHashMap
17
18
 
18
19
  class NotificationListener private constructor(private val context: Context) {
19
20
  companion object {
20
21
  private const val TAG = "CrustNotificationListener"
21
22
  private const val PREFS_NAME = "mentra_crust_notification_prefs"
22
- private const val PREF_NOTIFICATIONS_ENABLED = "notifications_enabled"
23
+ private const val PREF_LISTENER_ENABLED = "notification_listener_enabled"
23
24
  private const val PREF_NOTIFICATIONS_BLOCKLIST = "notifications_blocklist"
24
25
 
25
26
  @Volatile private var instance: NotificationListener? = null
@@ -33,9 +34,207 @@ class NotificationListener private constructor(private val context: Context) {
33
34
  }
34
35
  }
35
36
  }
37
+
38
+ /**
39
+ * Persist desired notification state without constructing the listener.
40
+ * The component stays enabled so Android can show it in notification-access
41
+ * Settings, but its service process only starts after access is granted.
42
+ */
43
+ fun setNotificationConfig(
44
+ context: Context,
45
+ listenerEnabled: Boolean,
46
+ blocklist: List<String>,
47
+ ) {
48
+ val applicationContext = context.applicationContext
49
+ val blocklistSet = blocklist.toSet()
50
+ persistConfig(applicationContext, listenerEnabled, blocklistSet)
51
+
52
+ val permissionGranted = hasNotificationListenerPermission(applicationContext)
53
+ val shouldRun = listenerEnabled && permissionGranted
54
+ val componentChanged = updateComponentState(applicationContext, enabled = listenerEnabled)
55
+
56
+ // Keep the isolated process's own SharedPreferences cache and live
57
+ // singleton in sync. A rebind is only needed when enabling the component;
58
+ // ordinary blocklist updates must not restart a healthy listener.
59
+ if (permissionGranted) {
60
+ NotificationProcessBridge.sendConfig(
61
+ applicationContext,
62
+ listenerEnabled,
63
+ blocklistSet,
64
+ requestRebind = shouldRun && componentChanged,
65
+ )
66
+ }
67
+
68
+ if (!shouldRun) {
69
+ Log.d(TAG, "Notification listener prerequisites not met; service will not start")
70
+ return
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Reconcile the component after an OS notification-access check.
76
+ *
77
+ * The permission flow calls this when the app returns from Settings, so a
78
+ * newly granted listener starts immediately. Without permission the service
79
+ * is not rebound and the isolated process is never started.
80
+ */
81
+ fun refreshComponentForPermission(context: Context): Boolean {
82
+ val applicationContext = context.applicationContext
83
+ val permissionGranted = hasNotificationListenerPermission(applicationContext)
84
+ val preferences = applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
85
+ val listenerEnabled = preferences.getBoolean(PREF_LISTENER_ENABLED, false)
86
+ val blocklist =
87
+ preferences.getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())?.toSet() ?: emptySet()
88
+
89
+ val shouldRun = listenerEnabled && permissionGranted
90
+ updateComponentState(applicationContext, enabled = listenerEnabled)
91
+ if (permissionGranted) {
92
+ NotificationProcessBridge.sendConfig(
93
+ applicationContext,
94
+ listenerEnabled,
95
+ blocklist,
96
+ // This method is reserved for the confirmed permission-grant path.
97
+ // The :notif receiver persists the config before requesting rebind.
98
+ requestRebind = shouldRun,
99
+ )
100
+ }
101
+ return permissionGranted
102
+ }
103
+
104
+ fun openNotificationListenerSettings(context: Context) {
105
+ val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
106
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
107
+ context.applicationContext.startActivity(intent)
108
+ }
109
+
110
+ fun hasNotificationListenerPermission(context: Context): Boolean {
111
+ val packageName = context.packageName
112
+ val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
113
+ if (TextUtils.isEmpty(flat)) return false
114
+
115
+ return flat.split(":").any { name ->
116
+ ComponentName.unflattenFromString(name)?.packageName == packageName
117
+ }
118
+ }
119
+
120
+ private fun updateComponentState(
121
+ context: Context,
122
+ enabled: Boolean,
123
+ ): Boolean {
124
+ val component = ComponentName(context, NotificationListenerServiceImpl::class.java)
125
+ val newState =
126
+ if (enabled) {
127
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED
128
+ } else {
129
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED
130
+ }
131
+ val packageManager = context.packageManager
132
+ if (packageManager.getComponentEnabledSetting(component) != newState) {
133
+ packageManager.setComponentEnabledSetting(
134
+ component,
135
+ newState,
136
+ PackageManager.DONT_KILL_APP,
137
+ )
138
+ return true
139
+ }
140
+ return false
141
+ }
142
+
143
+ internal fun persistConfig(
144
+ context: Context,
145
+ listenerEnabled: Boolean,
146
+ blocklist: Set<String>,
147
+ ) {
148
+ val committed =
149
+ context
150
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
151
+ .edit()
152
+ .putBoolean(PREF_LISTENER_ENABLED, listenerEnabled)
153
+ .putStringSet(PREF_NOTIFICATIONS_BLOCKLIST, blocklist)
154
+ .commit()
155
+ if (!committed) {
156
+ Log.w(TAG, "Could not persist notification-listener config")
157
+ }
158
+ }
159
+
160
+ internal fun requestListenerRebind(context: Context) {
161
+ val component = ComponentName(context, NotificationListenerServiceImpl::class.java)
162
+ runCatching { NotificationListenerService.requestRebind(component) }
163
+ .onFailure { Log.w(TAG, "Could not request notification-listener rebind", it) }
164
+ }
165
+
166
+ internal fun applyConfigToExisting(
167
+ listenerEnabled: Boolean,
168
+ blocklist: Set<String>,
169
+ ) {
170
+ synchronized(this) {
171
+ instance?.applyConfig(listenerEnabled, blocklist)
172
+ }
173
+ }
174
+
175
+ fun isListenerEnabled(context: Context): Boolean {
176
+ val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
177
+ return preferences.getBoolean(PREF_LISTENER_ENABLED, false) &&
178
+ hasNotificationListenerPermission(context)
179
+ }
180
+
181
+ /**
182
+ * Read installed-app metadata without constructing the service singleton.
183
+ *
184
+ * These APIs run in the React Native process. The notification singleton
185
+ * belongs exclusively to :notif; constructing it here would create an
186
+ * orphaned HandlerThread that no service lifecycle ever destroys.
187
+ */
188
+ fun getInstalledApps(context: Context): List<Map<String, Any?>> {
189
+ val applicationContext = context.applicationContext
190
+ val packageManager = applicationContext.packageManager
191
+ val packages = packageManager.getInstalledApplications(PackageManager.GET_META_DATA)
192
+ val blocklist =
193
+ applicationContext
194
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
195
+ .getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())
196
+ ?.toSet() ?: emptySet()
197
+
198
+ return packages
199
+ .filter { it.flags and ApplicationInfo.FLAG_SYSTEM == 0 }
200
+ .map { appInfo ->
201
+ val icon =
202
+ try {
203
+ val drawable = packageManager.getApplicationIcon(appInfo.packageName)
204
+ val bitmap = (drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
205
+ if (bitmap != null) {
206
+ val outputStream = java.io.ByteArrayOutputStream()
207
+ bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, outputStream)
208
+ val byteArray = outputStream.toByteArray()
209
+ "data:image/png;base64," + Base64.encodeToString(byteArray, Base64.NO_WRAP)
210
+ } else {
211
+ null
212
+ }
213
+ } catch (_: Exception) {
214
+ null
215
+ }
216
+
217
+ mapOf(
218
+ "packageName" to appInfo.packageName,
219
+ "appName" to packageManager.getApplicationLabel(appInfo).toString(),
220
+ "isBlocked" to blocklist.contains(appInfo.packageName),
221
+ "icon" to icon,
222
+ )
223
+ }
224
+ .sortedBy { it["appName"] as String }
225
+ }
226
+
227
+ /** Dispose the process singleton so a service rebind gets a live HandlerThread. */
228
+ fun destroyInstance() {
229
+ synchronized(this) {
230
+ instance?.cleanup()
231
+ instance = null
232
+ }
233
+ }
36
234
  }
37
235
 
38
236
  private val listeners = mutableListOf<OnNotificationReceivedListener>()
237
+ private val appNameCache = ConcurrentHashMap<String, String>()
39
238
 
40
239
  // Deduplication tracking with dedicated background thread. Using HandlerThread
41
240
  // keeps the service independent from the app lifecycle on newer Android versions.
@@ -46,45 +245,17 @@ class NotificationListener private constructor(private val context: Context) {
46
245
 
47
246
  private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
48
247
 
49
- @Volatile private var notificationsEnabled = preferences.getBoolean(PREF_NOTIFICATIONS_ENABLED, false)
248
+ @Volatile private var listenerEnabled = preferences.getBoolean(PREF_LISTENER_ENABLED, false)
50
249
  @Volatile
51
250
  private var notificationsBlocklist =
52
251
  preferences.getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())?.toSet() ?: emptySet()
53
252
 
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)
253
+ private fun applyConfig(
254
+ listenerEnabled: Boolean,
255
+ blocklist: Set<String>,
256
+ ) {
257
+ this.listenerEnabled = listenerEnabled
258
+ notificationsBlocklist = blocklist
88
259
  }
89
260
 
90
261
  /** Add a listener for notifications. */
@@ -120,7 +291,17 @@ class NotificationListener private constructor(private val context: Context) {
120
291
  }
121
292
 
122
293
  /** Called internally by the service when a notification is posted. */
123
- internal fun onNotificationPosted(sbn: StatusBarNotification) {
294
+ /**
295
+ * @param importance NotificationManager.IMPORTANCE_* from the ranking, or null
296
+ * when unavailable. Preferred over the deprecated Notification.priority,
297
+ * which reads 0 for channel-based notifications.
298
+ */
299
+ internal fun onNotificationPosted(sbn: StatusBarNotification, importance: Int? = null) {
300
+ if (!listenerEnabled) {
301
+ Log.d(TAG, "Notification listener disabled")
302
+ return
303
+ }
304
+
124
305
  val packageName = sbn.packageName
125
306
  Log.d(TAG, "Received notification from $packageName (key: ${sbn.key})")
126
307
 
@@ -134,11 +315,6 @@ class NotificationListener private constructor(private val context: Context) {
134
315
  return
135
316
  }
136
317
 
137
- if (!notificationsEnabled) {
138
- Log.d(TAG, "Notifications disabled globally")
139
- return
140
- }
141
-
142
318
  val notification = sbn.notification
143
319
  val extras = notification.extras
144
320
  val title = extras.getCharSequence("android.title")?.toString() ?: ""
@@ -154,15 +330,6 @@ class NotificationListener private constructor(private val context: Context) {
154
330
  return
155
331
  }
156
332
 
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
333
  val notificationKey = "$packageName|$title|$text"
167
334
 
168
335
  synchronized(notificationBuffer) {
@@ -174,14 +341,39 @@ class NotificationListener private constructor(private val context: Context) {
174
341
  val task =
175
342
  Runnable {
176
343
  try {
344
+ // Config can change during the debounce window. Recheck here so a
345
+ // kill-switch or blocklist update cannot leak an already-buffered
346
+ // notification after it takes effect.
347
+ if (!listenerEnabled || notificationsBlocklist.contains(packageName)) {
348
+ return@Runnable
349
+ }
350
+
351
+ // PackageManager label resolution can take tens of milliseconds on
352
+ // low-end devices. It belongs on this handler thread, not the
353
+ // NotificationListenerService main callback, and only once per app.
354
+ val appName =
355
+ appNameCache.getOrPut(packageName) {
356
+ try {
357
+ val packageManager = context.packageManager
358
+ val appInfo = packageManager.getApplicationInfo(packageName, 0)
359
+ packageManager.getApplicationLabel(appInfo).toString()
360
+ } catch (_: Exception) {
361
+ packageName
362
+ }
363
+ }
177
364
  Log.d(TAG, "Processing buffered notification from $appName")
178
365
  CrustModule.emitPhoneNotification(
366
+ context = context,
179
367
  notificationKey = sbn.key,
180
368
  packageName = packageName,
181
369
  appName = appName,
182
370
  title = title,
183
371
  text = text,
184
372
  timestamp = sbn.postTime,
373
+ // Map channel importance onto the priority scale consumers
374
+ // already understand (LOW/MIN are negative). Fall back to the
375
+ // deprecated field only when no ranking was available.
376
+ priority = importance?.let { it - 3 } ?: notification.priority,
185
377
  )
186
378
 
187
379
  val notificationData =
@@ -213,12 +405,17 @@ class NotificationListener private constructor(private val context: Context) {
213
405
 
214
406
  /** Called internally by the service when a notification is removed. */
215
407
  internal fun onNotificationRemoved(sbn: StatusBarNotification) {
408
+ if (!listenerEnabled) {
409
+ return
410
+ }
411
+
216
412
  val packageName = sbn.packageName
217
413
  val notificationKey = sbn.key
218
414
 
219
415
  Log.d(TAG, "Notification removed - package: $packageName, key: $notificationKey")
220
416
 
221
417
  CrustModule.emitPhoneNotificationDismissed(
418
+ context = context,
222
419
  notificationKey = notificationKey,
223
420
  packageName = packageName,
224
421
  )
@@ -242,41 +439,6 @@ class NotificationListener private constructor(private val context: Context) {
242
439
  val tag: String?,
243
440
  )
244
441
 
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
442
  /** Clean up resources when the service is destroyed. */
281
443
  fun cleanup() {
282
444
  Log.d(TAG, "Cleaning up notification handler thread")
@@ -292,7 +454,22 @@ class NotificationListener private constructor(private val context: Context) {
292
454
  class NotificationListenerServiceImpl : NotificationListenerService() {
293
455
  override fun onNotificationPosted(sbn: StatusBarNotification) {
294
456
  super.onNotificationPosted(sbn)
295
- NotificationListener.getInstance(applicationContext).onNotificationPosted(sbn)
457
+ // Channel importance, not Notification.priority: the latter was deprecated
458
+ // at API 26 and reads 0 for apps that express intent through a channel
459
+ // instead, which would make every consumer treat them as ordinary. Only the
460
+ // service can see the ranking, so it is resolved here and passed down.
461
+ NotificationListener.getInstance(applicationContext).onNotificationPosted(sbn, importanceOf(sbn))
462
+ }
463
+
464
+ /** IMPORTANCE_* for this notification, or null when the ranking is unavailable. */
465
+ private fun importanceOf(sbn: StatusBarNotification): Int? {
466
+ return try {
467
+ val ranking = NotificationListenerService.Ranking()
468
+ if (currentRanking?.getRanking(sbn.key, ranking) == true) ranking.importance else null
469
+ } catch (e: Exception) {
470
+ Log.w("CrustNotificationListener", "could not read ranking importance", e)
471
+ null
472
+ }
296
473
  }
297
474
 
298
475
  override fun onNotificationRemoved(sbn: StatusBarNotification) {
@@ -307,13 +484,19 @@ class NotificationListenerServiceImpl : NotificationListenerService() {
307
484
 
308
485
  override fun onListenerDisconnected() {
309
486
  super.onListenerDisconnected()
310
- Log.d("CrustNotificationListener", "NotificationListenerService disconnected, requesting rebind")
311
- requestRebind(ComponentName(this, NotificationListenerServiceImpl::class.java))
487
+ Log.d("CrustNotificationListener", "NotificationListenerService disconnected")
488
+ if (NotificationListener.isListenerEnabled(applicationContext)) {
489
+ runCatching {
490
+ requestRebind(ComponentName(this, NotificationListenerServiceImpl::class.java))
491
+ }.onFailure {
492
+ Log.w("CrustNotificationListener", "Could not request notification-listener rebind", it)
493
+ }
494
+ }
312
495
  }
313
496
 
314
497
  override fun onDestroy() {
315
498
  super.onDestroy()
316
499
  Log.d("CrustNotificationListener", "NotificationListenerService being destroyed")
317
- NotificationListener.getInstance(applicationContext).cleanup()
500
+ NotificationListener.destroyInstance()
318
501
  }
319
502
  }
@@ -0,0 +1,222 @@
1
+ package com.mentra.crust.services
2
+
3
+ import android.app.ActivityManager
4
+ import android.app.Application
5
+ import android.content.BroadcastReceiver
6
+ import android.content.ComponentName
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.IntentFilter
10
+ import android.os.Build
11
+ import android.os.Process
12
+ import android.util.Log
13
+ import java.io.File
14
+
15
+ /**
16
+ * Identifies the lightweight process used by NotificationListenerService.
17
+ *
18
+ * The host app's generated MainApplication calls this before initializing
19
+ * React Native. Keep this object free of Expo/React dependencies so loading it
20
+ * cannot recreate the cold-start work that the process split avoids.
21
+ */
22
+ object NotificationProcess {
23
+ const val SUFFIX = ":notif"
24
+
25
+ @JvmStatic
26
+ fun isCurrent(context: Context): Boolean = currentName(context).endsWith(SUFFIX)
27
+
28
+ private fun currentName(context: Context): String {
29
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
30
+ return Application.getProcessName()
31
+ }
32
+
33
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
34
+ activityManager
35
+ ?.runningAppProcesses
36
+ ?.firstOrNull { it.pid == Process.myPid() }
37
+ ?.processName
38
+ ?.let { return it }
39
+
40
+ return runCatching {
41
+ File("/proc/self/cmdline").inputStream().bufferedReader().use { it.readText().trimEnd('\u0000') }
42
+ }.getOrDefault(context.packageName)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Signature-protected, process-safe handoff from the notification process to
48
+ * the React Native process. The receiver is dynamic, so sending while the app
49
+ * runtime is dead does not start the expensive main process.
50
+ */
51
+ object NotificationProcessBridge {
52
+ private const val TAG = "CrustNotificationBridge"
53
+ private const val POSTED_SUFFIX = ".crust.PHONE_NOTIFICATION"
54
+ private const val DISMISSED_SUFFIX = ".crust.PHONE_NOTIFICATION_DISMISSED"
55
+ private const val CONFIG_SUFFIX = ".crust.NOTIFICATION_CONFIG"
56
+ private const val PERMISSION_SUFFIX = ".permission.CRUST_NOTIFICATION_BRIDGE"
57
+
58
+ private const val EXTRA_NOTIFICATION_ID = "notificationId"
59
+ private const val EXTRA_NOTIFICATION_KEY = "notificationKey"
60
+ private const val EXTRA_PACKAGE_NAME = "packageName"
61
+ private const val EXTRA_APP = "app"
62
+ private const val EXTRA_TITLE = "title"
63
+ private const val EXTRA_CONTENT = "content"
64
+ private const val EXTRA_PRIORITY = "priority"
65
+ private const val EXTRA_TIMESTAMP = "timestamp"
66
+ private const val EXTRA_LISTENER_ENABLED = "listenerEnabled"
67
+ private const val EXTRA_BLOCKLIST = "blocklist"
68
+ private const val EXTRA_REQUEST_REBIND = "requestRebind"
69
+
70
+ fun register(
71
+ context: Context,
72
+ onEvent: (String, Map<String, Any>) -> Unit,
73
+ ): BroadcastReceiver {
74
+ val applicationContext = context.applicationContext
75
+ val receiver =
76
+ object : BroadcastReceiver() {
77
+ override fun onReceive(receiveContext: Context, intent: Intent) {
78
+ when (intent.action) {
79
+ postedAction(receiveContext) -> {
80
+ onEvent(
81
+ "phone_notification",
82
+ mapOf(
83
+ EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
84
+ EXTRA_APP to intent.getStringExtra(EXTRA_APP).orEmpty(),
85
+ EXTRA_TITLE to intent.getStringExtra(EXTRA_TITLE).orEmpty(),
86
+ EXTRA_CONTENT to intent.getStringExtra(EXTRA_CONTENT).orEmpty(),
87
+ EXTRA_PRIORITY to intent.getIntExtra(EXTRA_PRIORITY, 0),
88
+ EXTRA_TIMESTAMP to intent.getLongExtra(EXTRA_TIMESTAMP, 0L),
89
+ EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
90
+ ),
91
+ )
92
+ }
93
+ dismissedAction(receiveContext) -> {
94
+ onEvent(
95
+ "phone_notification_dismissed",
96
+ mapOf(
97
+ EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
98
+ EXTRA_NOTIFICATION_KEY to intent.getStringExtra(EXTRA_NOTIFICATION_KEY).orEmpty(),
99
+ EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
100
+ ),
101
+ )
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ val filter =
108
+ IntentFilter().apply {
109
+ addAction(postedAction(applicationContext))
110
+ addAction(dismissedAction(applicationContext))
111
+ }
112
+ val permission = bridgePermission(applicationContext)
113
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
114
+ applicationContext.registerReceiver(
115
+ receiver,
116
+ filter,
117
+ permission,
118
+ null,
119
+ Context.RECEIVER_NOT_EXPORTED,
120
+ )
121
+ } else {
122
+ @Suppress("UnspecifiedRegisterReceiverFlag")
123
+ applicationContext.registerReceiver(receiver, filter, permission, null)
124
+ }
125
+ return receiver
126
+ }
127
+
128
+ fun unregister(context: Context, receiver: BroadcastReceiver) {
129
+ runCatching { context.applicationContext.unregisterReceiver(receiver) }
130
+ .onFailure { Log.w(TAG, "Could not unregister notification bridge", it) }
131
+ }
132
+
133
+ fun emitPosted(
134
+ context: Context,
135
+ notificationKey: String,
136
+ packageName: String,
137
+ appName: String,
138
+ title: String,
139
+ text: String,
140
+ timestamp: Long,
141
+ priority: Int,
142
+ ) {
143
+ val intent =
144
+ Intent(postedAction(context))
145
+ .setPackage(context.packageName)
146
+ .putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
147
+ .putExtra(EXTRA_APP, appName)
148
+ .putExtra(EXTRA_TITLE, title.ifEmpty { appName })
149
+ .putExtra(EXTRA_CONTENT, text)
150
+ .putExtra(EXTRA_PRIORITY, priority)
151
+ .putExtra(EXTRA_TIMESTAMP, timestamp)
152
+ .putExtra(EXTRA_PACKAGE_NAME, packageName)
153
+ context.sendBroadcast(intent, bridgePermission(context))
154
+ }
155
+
156
+ fun emitDismissed(context: Context, notificationKey: String, packageName: String) {
157
+ val intent =
158
+ Intent(dismissedAction(context))
159
+ .setPackage(context.packageName)
160
+ .putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
161
+ .putExtra(EXTRA_NOTIFICATION_KEY, notificationKey)
162
+ .putExtra(EXTRA_PACKAGE_NAME, packageName)
163
+ context.sendBroadcast(intent, bridgePermission(context))
164
+ }
165
+
166
+ fun sendConfig(
167
+ context: Context,
168
+ listenerEnabled: Boolean,
169
+ blocklist: Set<String>,
170
+ requestRebind: Boolean,
171
+ ) {
172
+ val intent =
173
+ Intent(configAction(context))
174
+ .setComponent(ComponentName(context, NotificationConfigReceiver::class.java))
175
+ .putExtra(EXTRA_LISTENER_ENABLED, listenerEnabled)
176
+ .putStringArrayListExtra(EXTRA_BLOCKLIST, ArrayList(blocklist))
177
+ .putExtra(EXTRA_REQUEST_REBIND, requestRebind)
178
+ context.sendBroadcast(intent, bridgePermission(context))
179
+ }
180
+
181
+ internal fun readConfig(intent: Intent): NotificationConfigUpdate {
182
+ return NotificationConfigUpdate(
183
+ listenerEnabled = intent.getBooleanExtra(EXTRA_LISTENER_ENABLED, false),
184
+ blocklist = intent.getStringArrayListExtra(EXTRA_BLOCKLIST)?.toSet() ?: emptySet(),
185
+ requestRebind = intent.getBooleanExtra(EXTRA_REQUEST_REBIND, false),
186
+ )
187
+ }
188
+
189
+ internal fun isConfigAction(context: Context, intent: Intent): Boolean = intent.action == configAction(context)
190
+
191
+ private fun postedAction(context: Context) = context.packageName + POSTED_SUFFIX
192
+
193
+ private fun dismissedAction(context: Context) = context.packageName + DISMISSED_SUFFIX
194
+
195
+ private fun configAction(context: Context) = context.packageName + CONFIG_SUFFIX
196
+
197
+ private fun bridgePermission(context: Context) = context.packageName + PERMISSION_SUFFIX
198
+ }
199
+
200
+ internal data class NotificationConfigUpdate(
201
+ val listenerEnabled: Boolean,
202
+ val blocklist: Set<String>,
203
+ val requestRebind: Boolean,
204
+ )
205
+
206
+ /** Persists and applies config inside the isolated notification process. */
207
+ class NotificationConfigReceiver : BroadcastReceiver() {
208
+ override fun onReceive(context: Context, intent: Intent) {
209
+ if (!NotificationProcessBridge.isConfigAction(context, intent)) return
210
+ val config = NotificationProcessBridge.readConfig(intent)
211
+
212
+ // SharedPreferences caches are per-process. Persist the payload here before
213
+ // touching the live instance so a listener recreated by requestRebind reads
214
+ // the same config rather than this process's stale cache.
215
+ NotificationListener.persistConfig(context, config.listenerEnabled, config.blocklist)
216
+ NotificationListener.applyConfigToExisting(config.listenerEnabled, config.blocklist)
217
+
218
+ if (config.listenerEnabled && config.requestRebind) {
219
+ NotificationListener.requestListenerRebind(context)
220
+ }
221
+ }
222
+ }