@glassly/crust 0.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 +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
- package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
- package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +153 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +186 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +39 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +69 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +8 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +3 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +5 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +784 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +804 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +674 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +751 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +21 -0
- package/plugin/build/index.js +25 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +133 -0
- package/src/Crust.types.ts +162 -0
- package/src/CrustModule.ts +194 -0
- package/src/CrustModule.web.ts +72 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +9 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
package com.glassly.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.glassly.crust.CrustModule
|
|
17
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
18
|
+
|
|
19
|
+
class NotificationListener private constructor(private val context: Context) {
|
|
20
|
+
companion object {
|
|
21
|
+
private const val TAG = "CrustNotificationListener"
|
|
22
|
+
private const val PREFS_NAME = "glassly_crust_notification_prefs"
|
|
23
|
+
private const val PREF_LISTENER_ENABLED = "notification_listener_enabled"
|
|
24
|
+
private const val PREF_NOTIFICATIONS_BLOCKLIST = "notifications_blocklist"
|
|
25
|
+
|
|
26
|
+
@Volatile private var instance: NotificationListener? = null
|
|
27
|
+
|
|
28
|
+
fun getInstance(context: Context): NotificationListener {
|
|
29
|
+
return instance
|
|
30
|
+
?: synchronized(this) {
|
|
31
|
+
instance
|
|
32
|
+
?: NotificationListener(context.applicationContext).also {
|
|
33
|
+
instance = it
|
|
34
|
+
}
|
|
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
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private val listeners = mutableListOf<OnNotificationReceivedListener>()
|
|
237
|
+
private val appNameCache = ConcurrentHashMap<String, String>()
|
|
238
|
+
|
|
239
|
+
// Deduplication tracking with dedicated background thread. Using HandlerThread
|
|
240
|
+
// keeps the service independent from the app lifecycle on newer Android versions.
|
|
241
|
+
private val notificationBuffer = mutableMapOf<String, Runnable>()
|
|
242
|
+
private val notificationThread = HandlerThread("NotificationHandler").apply { start() }
|
|
243
|
+
private val notificationHandler = Handler(notificationThread.looper)
|
|
244
|
+
private val duplicateThresholdMs = 200L
|
|
245
|
+
|
|
246
|
+
private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
247
|
+
|
|
248
|
+
@Volatile private var listenerEnabled = preferences.getBoolean(PREF_LISTENER_ENABLED, false)
|
|
249
|
+
@Volatile
|
|
250
|
+
private var notificationsBlocklist =
|
|
251
|
+
preferences.getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())?.toSet() ?: emptySet()
|
|
252
|
+
|
|
253
|
+
private fun applyConfig(
|
|
254
|
+
listenerEnabled: Boolean,
|
|
255
|
+
blocklist: Set<String>,
|
|
256
|
+
) {
|
|
257
|
+
this.listenerEnabled = listenerEnabled
|
|
258
|
+
notificationsBlocklist = blocklist
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Add a listener for notifications. */
|
|
262
|
+
fun addListener(listener: OnNotificationReceivedListener) {
|
|
263
|
+
if (!listeners.contains(listener)) {
|
|
264
|
+
listeners.add(listener)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Remove a listener. */
|
|
269
|
+
fun removeListener(listener: OnNotificationReceivedListener) {
|
|
270
|
+
listeners.remove(listener)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Allowlist for messaging apps that should not be blocked even if they match system patterns. */
|
|
274
|
+
private val messagingAppAllowlist =
|
|
275
|
+
setOf(
|
|
276
|
+
"com.google.android.apps.messaging",
|
|
277
|
+
"com.samsung.android.messaging",
|
|
278
|
+
"com.android.mms",
|
|
279
|
+
"com.google.android.gm",
|
|
280
|
+
"com.samsung.android.email.provider",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
/** Check if this is a system package that should be blocked. */
|
|
284
|
+
private fun isSystemPackageToBlock(packageName: String): Boolean {
|
|
285
|
+
if (messagingAppAllowlist.contains(packageName)) {
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
val pkg = packageName.lowercase()
|
|
290
|
+
return pkg.contains("google") || pkg.contains("samsung") || pkg.contains(".sec.")
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Called internally by the service when a notification is posted. */
|
|
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
|
+
|
|
305
|
+
val packageName = sbn.packageName
|
|
306
|
+
Log.d(TAG, "Received notification from $packageName (key: ${sbn.key})")
|
|
307
|
+
|
|
308
|
+
if (isSystemPackageToBlock(packageName)) {
|
|
309
|
+
Log.d(TAG, "Blocking system package: $packageName")
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (notificationsBlocklist.contains(packageName)) {
|
|
314
|
+
Log.d(TAG, "Notification in blocklist, returning")
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
val notification = sbn.notification
|
|
319
|
+
val extras = notification.extras
|
|
320
|
+
val title = extras.getCharSequence("android.title")?.toString() ?: ""
|
|
321
|
+
val text = extras.getCharSequence("android.text")?.toString() ?: ""
|
|
322
|
+
|
|
323
|
+
if (title.isEmpty() && text.isEmpty()) {
|
|
324
|
+
Log.d(TAG, "Ignoring empty notification")
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (text.matches(Regex("^\\d+ new messages$"))) {
|
|
329
|
+
Log.d(TAG, "Ignoring summary notification: $text")
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
val notificationKey = "$packageName|$title|$text"
|
|
334
|
+
|
|
335
|
+
synchronized(notificationBuffer) {
|
|
336
|
+
notificationBuffer[notificationKey]?.let {
|
|
337
|
+
notificationHandler.removeCallbacks(it)
|
|
338
|
+
notificationBuffer.remove(notificationKey)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
val task =
|
|
342
|
+
Runnable {
|
|
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
|
+
}
|
|
364
|
+
Log.d(TAG, "Processing buffered notification from $appName")
|
|
365
|
+
CrustModule.emitPhoneNotification(
|
|
366
|
+
context = context,
|
|
367
|
+
notificationKey = sbn.key,
|
|
368
|
+
packageName = packageName,
|
|
369
|
+
appName = appName,
|
|
370
|
+
title = title,
|
|
371
|
+
text = text,
|
|
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,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
val notificationData =
|
|
380
|
+
NotificationData(
|
|
381
|
+
packageName = packageName,
|
|
382
|
+
title = title,
|
|
383
|
+
text = text,
|
|
384
|
+
timestamp = sbn.postTime,
|
|
385
|
+
id = sbn.id,
|
|
386
|
+
tag = sbn.tag,
|
|
387
|
+
)
|
|
388
|
+
listeners.forEach { listener ->
|
|
389
|
+
listener.onNotificationReceived(notificationData)
|
|
390
|
+
}
|
|
391
|
+
} catch (e: Exception) {
|
|
392
|
+
Log.e(TAG, "Error processing notification: ${e.message}", e)
|
|
393
|
+
} finally {
|
|
394
|
+
synchronized(notificationBuffer) {
|
|
395
|
+
notificationBuffer.remove(notificationKey)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
notificationBuffer[notificationKey] = task
|
|
401
|
+
Log.d(TAG, "Buffering notification (${duplicateThresholdMs}ms delay)")
|
|
402
|
+
notificationHandler.postDelayed(task, duplicateThresholdMs)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Called internally by the service when a notification is removed. */
|
|
407
|
+
internal fun onNotificationRemoved(sbn: StatusBarNotification) {
|
|
408
|
+
if (!listenerEnabled) {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
val packageName = sbn.packageName
|
|
413
|
+
val notificationKey = sbn.key
|
|
414
|
+
|
|
415
|
+
Log.d(TAG, "Notification removed - package: $packageName, key: $notificationKey")
|
|
416
|
+
|
|
417
|
+
CrustModule.emitPhoneNotificationDismissed(
|
|
418
|
+
context = context,
|
|
419
|
+
notificationKey = notificationKey,
|
|
420
|
+
packageName = packageName,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
listeners.forEach { listener -> listener.onNotificationRemoved(packageName, sbn.id) }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Interface for notification callbacks. */
|
|
427
|
+
interface OnNotificationReceivedListener {
|
|
428
|
+
fun onNotificationReceived(notification: NotificationData)
|
|
429
|
+
fun onNotificationRemoved(packageName: String, notificationId: Int) {}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Data class for notification info. */
|
|
433
|
+
data class NotificationData(
|
|
434
|
+
val packageName: String,
|
|
435
|
+
val title: String,
|
|
436
|
+
val text: String,
|
|
437
|
+
val timestamp: Long,
|
|
438
|
+
val id: Int,
|
|
439
|
+
val tag: String?,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
/** Clean up resources when the service is destroyed. */
|
|
443
|
+
fun cleanup() {
|
|
444
|
+
Log.d(TAG, "Cleaning up notification handler thread")
|
|
445
|
+
synchronized(notificationBuffer) {
|
|
446
|
+
notificationBuffer.values.forEach { notificationHandler.removeCallbacks(it) }
|
|
447
|
+
notificationBuffer.clear()
|
|
448
|
+
}
|
|
449
|
+
notificationThread.quitSafely()
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The actual NotificationListenerService implementation. */
|
|
454
|
+
class NotificationListenerServiceImpl : NotificationListenerService() {
|
|
455
|
+
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
|
456
|
+
super.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
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
|
476
|
+
super.onNotificationRemoved(sbn)
|
|
477
|
+
NotificationListener.getInstance(applicationContext).onNotificationRemoved(sbn)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
override fun onListenerConnected() {
|
|
481
|
+
super.onListenerConnected()
|
|
482
|
+
Log.d("CrustNotificationListener", "NotificationListenerService connected and ready")
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
override fun onListenerDisconnected() {
|
|
486
|
+
super.onListenerDisconnected()
|
|
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
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
override fun onDestroy() {
|
|
498
|
+
super.onDestroy()
|
|
499
|
+
Log.d("CrustNotificationListener", "NotificationListenerService being destroyed")
|
|
500
|
+
NotificationListener.destroyInstance()
|
|
501
|
+
}
|
|
502
|
+
}
|