@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 +28 -22
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +237 -33
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +42 -1
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +2 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +273 -90
- package/android/src/main/java/com/mentra/crust/services/NotificationProcessBridge.kt +222 -0
- package/build/Crust.types.d.ts +6 -1
- package/build/Crust.types.d.ts.map +1 -1
- package/build/Crust.types.js.map +1 -1
- package/build/CrustModule.d.ts +9 -1
- package/build/CrustModule.d.ts.map +1 -1
- package/build/CrustModule.js.map +1 -1
- package/build/CrustModule.web.d.ts +10 -1
- package/build/CrustModule.web.d.ts.map +1 -1
- package/build/CrustModule.web.js +13 -1
- package/build/CrustModule.web.js.map +1 -1
- package/ios/CrustModule.swift +288 -47
- package/package.json +7 -3
- package/plugin/build/index.d.ts +2 -0
- package/plugin/build/index.js +2 -0
- package/plugin/build/withAndroid.js +56 -1
- package/src/Crust.types.ts +6 -1
- package/src/CrustModule.ts +10 -4
- package/src/CrustModule.web.ts +13 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
311
|
-
|
|
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.
|
|
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
|
+
}
|