@mentra/crust 3.2.0-dev.174 → 3.2.0-dev.180

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.
@@ -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
- 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
- }
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(applicationContext, enabled = listenerEnabled)
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
- applicationContext,
103
+ context,
94
104
  listenerEnabled,
95
105
  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,
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 && config.requestRebind) {
219
- NotificationListener.requestListenerRebind(context)
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.174",
3
+ "version": "3.2.0-dev.180",
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.174"
38
+ "@mentra/jspolyfill": "3.2.0-dev.180"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@expo/config-plugins": ">=8.0.0",