@mentra/crust 3.2.0-dev.168 → 3.2.0-dev.177
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/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +36 -28
- package/android/src/main/java/com/mentra/crust/services/NotificationProcessBridge.kt +12 -2
- package/android/src/test/java/com/mentra/crust/services/NotificationListenerConfigTest.kt +179 -0
- package/package.json +2 -2
package/android/build.gradle
CHANGED
|
@@ -144,3 +144,9 @@ dependencies {
|
|
|
144
144
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1'
|
|
145
145
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
|
146
146
|
}
|
|
147
|
+
|
|
148
|
+
// Notification lifecycle tests run against Android APIs without a device.
|
|
149
|
+
dependencies {
|
|
150
|
+
testImplementation 'junit:junit:4.13.2'
|
|
151
|
+
testImplementation 'org.robolectric:robolectric:4.11.1'
|
|
152
|
+
}
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
<intent-filter>
|
|
16
16
|
<action android:name="android.service.notification.NotificationListenerService" />
|
|
17
17
|
</intent-filter>
|
|
18
|
+
<!-- Use on-demand binding (Android 14+) so startup and permission-grant
|
|
19
|
+
recovery can explicitly request a bind after applying listener config. -->
|
|
20
|
+
<meta-data android:name="android.service.notification.default_autobind_listenerservice" android:value="false" />
|
|
18
21
|
<meta-data android:name="android.service.notification.default_filter_types" android:value="conversations|alerting" />
|
|
19
22
|
<meta-data android:name="android.service.notification.disabled_filter_types" android:value="ongoing|silent" />
|
|
20
23
|
</service>
|
|
@@ -40,6 +40,7 @@ class NotificationListener private constructor(private val context: Context) {
|
|
|
40
40
|
* The component stays enabled so Android can show it in notification-access
|
|
41
41
|
* Settings, but its service process only starts after access is granted.
|
|
42
42
|
*/
|
|
43
|
+
@Synchronized
|
|
43
44
|
fun setNotificationConfig(
|
|
44
45
|
context: Context,
|
|
45
46
|
listenerEnabled: Boolean,
|
|
@@ -49,26 +50,12 @@ class NotificationListener private constructor(private val context: Context) {
|
|
|
49
50
|
val blocklistSet = blocklist.toSet()
|
|
50
51
|
persistConfig(applicationContext, listenerEnabled, blocklistSet)
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
53
|
+
reconcileNotificationConfig(
|
|
54
|
+
applicationContext,
|
|
55
|
+
listenerEnabled,
|
|
56
|
+
blocklistSet,
|
|
57
|
+
hasNotificationListenerPermission(applicationContext),
|
|
58
|
+
)
|
|
72
59
|
}
|
|
73
60
|
|
|
74
61
|
/**
|
|
@@ -78,6 +65,7 @@ class NotificationListener private constructor(private val context: Context) {
|
|
|
78
65
|
* newly granted listener starts immediately. Without permission the service
|
|
79
66
|
* is not rebound and the isolated process is never started.
|
|
80
67
|
*/
|
|
68
|
+
@Synchronized
|
|
81
69
|
fun refreshComponentForPermission(context: Context): Boolean {
|
|
82
70
|
val applicationContext = context.applicationContext
|
|
83
71
|
val permissionGranted = hasNotificationListenerPermission(applicationContext)
|
|
@@ -86,19 +74,38 @@ class NotificationListener private constructor(private val context: Context) {
|
|
|
86
74
|
val blocklist =
|
|
87
75
|
preferences.getStringSet(PREF_NOTIFICATIONS_BLOCKLIST, emptySet())?.toSet() ?: emptySet()
|
|
88
76
|
|
|
77
|
+
reconcileNotificationConfig(
|
|
78
|
+
applicationContext,
|
|
79
|
+
listenerEnabled,
|
|
80
|
+
blocklist,
|
|
81
|
+
permissionGranted,
|
|
82
|
+
forceRebind = true,
|
|
83
|
+
)
|
|
84
|
+
return permissionGranted
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Both app entrypoints use one rebind decision; the receiver owns the request. */
|
|
88
|
+
private fun reconcileNotificationConfig(
|
|
89
|
+
context: Context,
|
|
90
|
+
listenerEnabled: Boolean,
|
|
91
|
+
blocklist: Set<String>,
|
|
92
|
+
permissionGranted: Boolean,
|
|
93
|
+
forceRebind: Boolean = false,
|
|
94
|
+
) {
|
|
89
95
|
val shouldRun = listenerEnabled && permissionGranted
|
|
90
|
-
updateComponentState(
|
|
96
|
+
val componentChanged = updateComponentState(context, enabled = listenerEnabled)
|
|
97
|
+
val shouldRebind = shouldRun && (forceRebind || componentChanged)
|
|
91
98
|
if (permissionGranted) {
|
|
99
|
+
// The explicit broadcast starts :notif if needed. Its receiver applies
|
|
100
|
+
// the config before requesting the bind, avoiding duplicate requests
|
|
101
|
+
// and a listener that starts with a stale per-process preferences cache.
|
|
92
102
|
NotificationProcessBridge.sendConfig(
|
|
93
|
-
|
|
103
|
+
context,
|
|
94
104
|
listenerEnabled,
|
|
95
105
|
blocklist,
|
|
96
|
-
|
|
97
|
-
// The :notif receiver persists the config before requesting rebind.
|
|
98
|
-
requestRebind = shouldRun,
|
|
106
|
+
requestRebind = shouldRebind,
|
|
99
107
|
)
|
|
100
108
|
}
|
|
101
|
-
return permissionGranted
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
fun openNotificationListenerSettings(context: Context) {
|
|
@@ -157,10 +164,11 @@ class NotificationListener private constructor(private val context: Context) {
|
|
|
157
164
|
}
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
internal fun requestListenerRebind(context: Context) {
|
|
167
|
+
internal fun requestListenerRebind(context: Context): Boolean {
|
|
161
168
|
val component = ComponentName(context, NotificationListenerServiceImpl::class.java)
|
|
162
|
-
runCatching { NotificationListenerService.requestRebind(component) }
|
|
169
|
+
return runCatching { NotificationListenerService.requestRebind(component) }
|
|
163
170
|
.onFailure { Log.w(TAG, "Could not request notification-listener rebind", it) }
|
|
171
|
+
.isSuccess
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
internal fun applyConfigToExisting(
|
|
@@ -205,6 +205,12 @@ internal data class NotificationConfigUpdate(
|
|
|
205
205
|
|
|
206
206
|
/** Persists and applies config inside the isolated notification process. */
|
|
207
207
|
class NotificationConfigReceiver : BroadcastReceiver() {
|
|
208
|
+
companion object {
|
|
209
|
+
// Owned by :notif, so process death resets recovery independently of the
|
|
210
|
+
// main app. BroadcastReceiver instances themselves are short-lived.
|
|
211
|
+
private var rebindRequestedThisProcess = false
|
|
212
|
+
}
|
|
213
|
+
|
|
208
214
|
override fun onReceive(context: Context, intent: Intent) {
|
|
209
215
|
if (!NotificationProcessBridge.isConfigAction(context, intent)) return
|
|
210
216
|
val config = NotificationProcessBridge.readConfig(intent)
|
|
@@ -215,8 +221,12 @@ class NotificationConfigReceiver : BroadcastReceiver() {
|
|
|
215
221
|
NotificationListener.persistConfig(context, config.listenerEnabled, config.blocklist)
|
|
216
222
|
NotificationListener.applyConfigToExisting(config.listenerEnabled, config.blocklist)
|
|
217
223
|
|
|
218
|
-
if (config.listenerEnabled
|
|
219
|
-
|
|
224
|
+
if (!config.listenerEnabled || !NotificationListener.hasNotificationListenerPermission(context)) {
|
|
225
|
+
rebindRequestedThisProcess = false
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (config.requestRebind || !rebindRequestedThisProcess) {
|
|
229
|
+
rebindRequestedThisProcess = NotificationListener.requestListenerRebind(context)
|
|
220
230
|
}
|
|
221
231
|
}
|
|
222
232
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
package com.mentra.crust.services
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.content.ComponentName
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.provider.Settings
|
|
7
|
+
import android.service.notification.NotificationListenerService
|
|
8
|
+
import org.junit.Assert.assertEquals
|
|
9
|
+
import org.junit.Assert.assertFalse
|
|
10
|
+
import org.junit.Assert.assertTrue
|
|
11
|
+
import org.junit.Before
|
|
12
|
+
import org.junit.Test
|
|
13
|
+
import org.junit.runner.RunWith
|
|
14
|
+
import org.robolectric.RobolectricTestRunner
|
|
15
|
+
import org.robolectric.RuntimeEnvironment
|
|
16
|
+
import org.robolectric.Shadows.shadowOf
|
|
17
|
+
import org.robolectric.annotation.Config
|
|
18
|
+
import org.robolectric.annotation.Implementation
|
|
19
|
+
import org.robolectric.annotation.Implements
|
|
20
|
+
|
|
21
|
+
@RunWith(RobolectricTestRunner::class)
|
|
22
|
+
@Config(sdk = [28], manifest = Config.NONE, shadows = [RebindRecorder::class])
|
|
23
|
+
class NotificationListenerConfigTest {
|
|
24
|
+
private lateinit var context: Application
|
|
25
|
+
|
|
26
|
+
@Before
|
|
27
|
+
fun reset() {
|
|
28
|
+
context = RuntimeEnvironment.getApplication()
|
|
29
|
+
grantPermission(false)
|
|
30
|
+
NotificationListener.setNotificationConfig(context, false, emptyList())
|
|
31
|
+
NotificationConfigReceiver().onReceive(
|
|
32
|
+
context,
|
|
33
|
+
Intent(context.packageName + ".crust.NOTIFICATION_CONFIG"),
|
|
34
|
+
)
|
|
35
|
+
RebindRecorder.requests.clear()
|
|
36
|
+
RebindRecorder.failRequest = false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun startupRebindsOnlyThroughReceiverAndConfigUpdatesDoNotRebind() {
|
|
41
|
+
grantPermission(true)
|
|
42
|
+
context.packageManager.setComponentEnabledSetting(
|
|
43
|
+
component(),
|
|
44
|
+
android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
|
45
|
+
android.content.pm.PackageManager.DONT_KILL_APP,
|
|
46
|
+
)
|
|
47
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
48
|
+
assertTrue(RebindRecorder.requests.isEmpty())
|
|
49
|
+
deliverLatestConfig(expectedRebind = false)
|
|
50
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
51
|
+
|
|
52
|
+
NotificationListener.setNotificationConfig(context, true, listOf("blocked.app"))
|
|
53
|
+
deliverLatestConfig(expectedRebind = false)
|
|
54
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
55
|
+
assertEquals(setOf("blocked.app"), preferences().getStringSet("notifications_blocklist", emptySet()))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun freshNotificationProcessRecoversOnConfigOnlyBroadcast() {
|
|
60
|
+
grantPermission(true)
|
|
61
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
62
|
+
// The app has already requested startup recovery, but the receiver starts
|
|
63
|
+
// fresh in :notif. This is also the state after only :notif was killed.
|
|
64
|
+
NotificationListener.setNotificationConfig(context, true, listOf("blocked.app"))
|
|
65
|
+
deliverLatestConfig(expectedRebind = false)
|
|
66
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
67
|
+
|
|
68
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
69
|
+
deliverLatestConfig(expectedRebind = false)
|
|
70
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun failedRequestCanRetryOnNextConfigWithoutRestartingProcess() {
|
|
75
|
+
grantPermission(true)
|
|
76
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
77
|
+
RebindRecorder.failRequest = true
|
|
78
|
+
deliverLatestConfig(expectedRebind = true)
|
|
79
|
+
assertTrue(RebindRecorder.requests.isEmpty())
|
|
80
|
+
|
|
81
|
+
RebindRecorder.failRequest = false
|
|
82
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
83
|
+
deliverLatestConfig(expectedRebind = false)
|
|
84
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun permissionGrantCountsAsStartupRecovery() {
|
|
89
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
90
|
+
assertTrue(shadowOf(context).broadcastIntents.isEmpty())
|
|
91
|
+
grantPermission(true)
|
|
92
|
+
assertTrue(NotificationListener.refreshComponentForPermission(context))
|
|
93
|
+
assertTrue(RebindRecorder.requests.isEmpty())
|
|
94
|
+
deliverLatestConfig(expectedRebind = true)
|
|
95
|
+
|
|
96
|
+
NotificationListener.setNotificationConfig(context, true, listOf("blocked.app"))
|
|
97
|
+
deliverLatestConfig(expectedRebind = false)
|
|
98
|
+
assertEquals(1, RebindRecorder.requests.size)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun deniedPermissionNeverStartsNotificationProcess() {
|
|
103
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
104
|
+
assertFalse(NotificationListener.refreshComponentForPermission(context))
|
|
105
|
+
assertTrue(shadowOf(context).broadcastIntents.isEmpty())
|
|
106
|
+
assertTrue(RebindRecorder.requests.isEmpty())
|
|
107
|
+
assertEquals(
|
|
108
|
+
android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
|
109
|
+
context.packageManager.getComponentEnabledSetting(component()),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun disablingAndReenablingAllowsAnotherBind() {
|
|
115
|
+
grantPermission(true)
|
|
116
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
117
|
+
deliverLatestConfig(expectedRebind = true)
|
|
118
|
+
NotificationListener.setNotificationConfig(context, false, emptyList())
|
|
119
|
+
deliverLatestConfig(expectedRebind = false)
|
|
120
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
121
|
+
deliverLatestConfig(expectedRebind = true)
|
|
122
|
+
assertEquals(2, RebindRecorder.requests.size)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun confirmedRegrantCanRecoverAgainInSameProcess() {
|
|
127
|
+
grantPermission(true)
|
|
128
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
129
|
+
deliverLatestConfig(expectedRebind = true)
|
|
130
|
+
// Access can be revoked and granted in Settings without a config update.
|
|
131
|
+
NotificationListener.refreshComponentForPermission(context)
|
|
132
|
+
deliverLatestConfig(expectedRebind = true)
|
|
133
|
+
NotificationListener.setNotificationConfig(context, true, emptyList())
|
|
134
|
+
deliverLatestConfig(expectedRebind = false)
|
|
135
|
+
assertEquals(2, RebindRecorder.requests.size)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fun grantPermission(granted: Boolean) {
|
|
139
|
+
Settings.Secure.putString(
|
|
140
|
+
context.contentResolver,
|
|
141
|
+
"enabled_notification_listeners",
|
|
142
|
+
if (granted) component().flattenToString() else "",
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private fun component() = ComponentName(context, NotificationListenerServiceImpl::class.java)
|
|
147
|
+
|
|
148
|
+
private fun preferences() = context.getSharedPreferences("mentra_crust_notification_prefs", 0)
|
|
149
|
+
|
|
150
|
+
private fun deliverLatestConfig(expectedRebind: Boolean) {
|
|
151
|
+
val intent = shadowOf(context).broadcastIntents.last()
|
|
152
|
+
val config = NotificationProcessBridge.readConfig(intent)
|
|
153
|
+
assertEquals(expectedRebind, config.requestRebind)
|
|
154
|
+
// Model :notif's independent preference cache rather than relying on the
|
|
155
|
+
// value just written by the app process.
|
|
156
|
+
preferences().edit().clear().commit()
|
|
157
|
+
NotificationConfigReceiver().onReceive(context, intent)
|
|
158
|
+
assertEquals(config.listenerEnabled, preferences().getBoolean("notification_listener_enabled", false))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@Implements(NotificationListenerService::class)
|
|
163
|
+
class RebindRecorder {
|
|
164
|
+
companion object {
|
|
165
|
+
val requests = mutableListOf<ComponentName>()
|
|
166
|
+
var failRequest = false
|
|
167
|
+
|
|
168
|
+
@JvmStatic
|
|
169
|
+
@Implementation
|
|
170
|
+
fun requestRebind(component: ComponentName) {
|
|
171
|
+
if (failRequest) throw IllegalStateException("Binder unavailable")
|
|
172
|
+
// Receiver must have applied config before binding the service.
|
|
173
|
+
val context = RuntimeEnvironment.getApplication() as Application
|
|
174
|
+
val prefs = context.getSharedPreferences("mentra_crust_notification_prefs", 0)
|
|
175
|
+
assertTrue(prefs.getBoolean("notification_listener_enabled", false))
|
|
176
|
+
requests.add(component)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/crust",
|
|
3
|
-
"version": "3.2.0-dev.
|
|
3
|
+
"version": "3.2.0-dev.177",
|
|
4
4
|
"description": "Mentra Native Module",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"license": "Apache-2.0",
|
|
36
36
|
"homepage": "https://github.com/Mentra-Community/MentraOS/tree/dev/mobile/modules/crust#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@mentra/jspolyfill": "3.2.0-dev.
|
|
38
|
+
"@mentra/jspolyfill": "3.2.0-dev.177"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@expo/config-plugins": ">=8.0.0",
|