• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.safetycenter.functional.testing
18 
19 import android.app.NotificationChannel
20 import android.content.ComponentName
21 import android.os.ConditionVariable
22 import android.service.notification.NotificationListenerService
23 import android.service.notification.StatusBarNotification
24 import android.util.Log
25 import com.android.compatibility.common.util.SystemUtil
26 import com.android.safetycenter.testing.Coroutines.TIMEOUT_LONG
27 import com.android.safetycenter.testing.Coroutines.TIMEOUT_SHORT
28 import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeout
29 import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeoutOrNull
30 import com.android.safetycenter.testing.Coroutines.waitForWithTimeout
31 import com.google.common.truth.Truth.assertThat
32 import java.time.Duration
33 import java.util.concurrent.TimeoutException
34 import kotlinx.coroutines.TimeoutCancellationException
35 import kotlinx.coroutines.channels.Channel
36 
37 /** Used in tests to check whether expected notifications are present in the status bar. */
38 class TestNotificationListener : NotificationListenerService() {
39 
40     private sealed class NotificationEvent(val statusBarNotification: StatusBarNotification)
41 
42     private class NotificationPosted(statusBarNotification: StatusBarNotification) :
43         NotificationEvent(statusBarNotification) {
44         override fun toString(): String = "Posted $statusBarNotification"
45     }
46 
47     private class NotificationRemoved(statusBarNotification: StatusBarNotification) :
48         NotificationEvent(statusBarNotification) {
49         override fun toString(): String = "Removed $statusBarNotification"
50     }
51 
52     override fun onNotificationPosted(statusBarNotification: StatusBarNotification) {
53         super.onNotificationPosted(statusBarNotification)
54         if (statusBarNotification.isSafetyCenterNotification()) {
55             runBlockingWithTimeout {
56                 safetyCenterNotificationEvents.send(NotificationPosted(statusBarNotification))
57             }
58         }
59     }
60 
61     override fun onNotificationRemoved(statusBarNotification: StatusBarNotification) {
62         super.onNotificationRemoved(statusBarNotification)
63         if (statusBarNotification.isSafetyCenterNotification()) {
64             runBlockingWithTimeout {
65                 safetyCenterNotificationEvents.send(NotificationRemoved(statusBarNotification))
66             }
67         }
68     }
69 
70     override fun onListenerConnected() {
71         Log.d(TAG, "onListenerConnected")
72         super.onListenerConnected()
73         disconnected.close()
74         instance = this
75         connected.open()
76     }
77 
78     override fun onListenerDisconnected() {
79         Log.d(TAG, "onListenerDisconnected")
80         super.onListenerDisconnected()
81         connected.close()
82         instance = null
83         disconnected.open()
84     }
85 
86     companion object {
87         private const val TAG = "TestNotificationListene"
88 
89         private val id: String =
90             "android.safetycenter.functional/" + TestNotificationListener::class.java.name
91         private val componentName =
92             ComponentName(
93                 "android.safetycenter.functional",
94                 TestNotificationListener::class.java.name
95             )
96 
97         private val connected = ConditionVariable(false)
98         private val disconnected = ConditionVariable(true)
99         private var instance: TestNotificationListener? = null
100 
101         @Volatile
102         private var safetyCenterNotificationEvents =
103             Channel<NotificationEvent>(capacity = Channel.UNLIMITED)
104 
105         /**
106          * Blocks until there are zero Safety Center notifications and there remain zero for a short
107          * duration. Throws an [AssertionError] if a this condition is not met within [timeout], or
108          * if it is met and then violated.
109          */
110         fun waitForZeroNotifications(timeout: Duration = TIMEOUT_LONG) {
111             waitForNotificationCount(0, timeout)
112         }
113 
114         /**
115          * Blocks until there is exactly one Safety Center notification and ensures that remains
116          * true for a short duration. Returns that notification, or throws an [AssertionError] if a
117          * this condition is not met within [timeout], or if it is met and then violated.
118          */
119         fun waitForSingleNotification(
120             timeout: Duration = TIMEOUT_LONG
121         ): StatusBarNotificationWithChannel {
122             return waitForNotificationCount(1, timeout).first()
123         }
124 
125         /**
126          * Blocks until there are exactly [count] Safety Center notifications and ensures that
127          * remains true for a short duration. Returns those notifications, or throws an
128          * [AssertionError] if a this condition is not met within [timeout], or if it is met and
129          * then violated.
130          */
131         private fun waitForNotificationCount(
132             count: Int,
133             timeout: Duration = TIMEOUT_LONG
134         ): List<StatusBarNotificationWithChannel> {
135             return waitForNotificationsToSatisfy(timeout, description = "$count notifications") {
136                 it.size == count
137             }
138         }
139 
140         /**
141          * Blocks until there is a single Safety Center notification, which matches the given
142          * [characteristics] and ensures that remains true for a short duration. Returns that
143          * notification, or throws an [AssertionError] if a this condition is not met within
144          * [timeout], or if it is met and then violated.
145          */
146         fun waitForSingleNotificationMatching(
147             characteristics: NotificationCharacteristics,
148             timeout: Duration = TIMEOUT_LONG
149         ): StatusBarNotificationWithChannel {
150             return waitForNotificationsMatching(characteristics, timeout = timeout).first()
151         }
152 
153         /**
154          * Blocks until the Safety Center notifications match the given [characteristics] and
155          * ensures that remains true for a short duration. Returns those notifications, or throws an
156          * [AssertionError] if a this condition is not met within [timeout], or if it is met and
157          * then violated.
158          */
159         fun waitForNotificationsMatching(
160             vararg characteristics: NotificationCharacteristics,
161             timeout: Duration = TIMEOUT_LONG
162         ): List<StatusBarNotificationWithChannel> {
163             val charsList = characteristics.toList()
164             return waitForNotificationsToSatisfy(
165                 timeout,
166                 description = "notification(s) matching characteristics $charsList"
167             ) { NotificationCharacteristics.areMatching(it, charsList) }
168         }
169 
170         /**
171          * Blocks until [forAtLeast] has elapsed, or throw an [AssertionError] if any notification
172          * is posted or removed before then.
173          */
174         fun waitForZeroNotificationEvents(forAtLeast: Duration = TIMEOUT_SHORT) {
175             val event =
176                 runBlockingWithTimeoutOrNull(forAtLeast) {
177                     safetyCenterNotificationEvents.receive()
178                 }
179             assertThat(event).isNull()
180         }
181 
182         private fun waitForNotificationsToSatisfy(
183             timeout: Duration = TIMEOUT_LONG,
184             forAtLeast: Duration = TIMEOUT_SHORT,
185             description: String,
186             predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
187         ): List<StatusBarNotificationWithChannel> {
188             fun formatError(notifs: List<StatusBarNotificationWithChannel>): String {
189                 return "Expected: $description, but the actual notifications were: $notifs"
190             }
191 
192             // First we wait at most timeout for the active notifications to satisfy the given
193             // predicate or otherwise we throw:
194             val satisfyingNotifications =
195                 try {
196                     runBlockingWithTimeout(timeout) {
197                         waitForNotificationsToSatisfyAsync(predicate)
198                     }
199                 } catch (e: TimeoutCancellationException) {
200                     throw AssertionError(formatError(getSafetyCenterNotifications()), e)
201                 }
202 
203             // Assuming the predicate was satisfied, now we ensure it is not violated for the
204             // forAtLeast duration as well:
205             val nonSatisfyingNotifications =
206                 runBlockingWithTimeoutOrNull(forAtLeast) {
207                     waitForNotificationsToSatisfyAsync { !predicate(it) }
208                 }
209             if (nonSatisfyingNotifications != null) {
210                 // In this case the negated-predicate was satisfied before forAtLeast had elapsed
211                 throw AssertionError(formatError(nonSatisfyingNotifications))
212             }
213 
214             return satisfyingNotifications
215         }
216 
217         private suspend fun waitForNotificationsToSatisfyAsync(
218             predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
219         ): List<StatusBarNotificationWithChannel> {
220             var currentNotifications = getSafetyCenterNotifications()
221             while (!predicate(currentNotifications)) {
222                 val event = safetyCenterNotificationEvents.receive()
223                 Log.d(TAG, "Received notification event: $event")
224                 currentNotifications = getSafetyCenterNotifications()
225             }
226             return currentNotifications
227         }
228 
229         private fun getSafetyCenterNotifications(): List<StatusBarNotificationWithChannel> {
230             return with(getInstanceOrThrow()) {
231                 val notificationsSnapshot =
232                     checkNotNull(getActiveNotifications()) {
233                         "getActiveNotifications() returned null"
234                     }
235                 val rankingSnapshot =
236                     checkNotNull(getCurrentRanking()) { "getCurrentRanking() returned null" }
237 
238                 fun getChannel(key: String): NotificationChannel? {
239                     // This API uses a result parameter:
240                     val rankingOut = Ranking()
241                     val success = rankingSnapshot.getRanking(key, rankingOut)
242                     return if (success) {
243                         rankingOut.channel
244                     } else {
245                         null
246                     }
247                 }
248 
249                 notificationsSnapshot
250                     .filter { it.isSafetyCenterNotification() }
251                     .mapNotNull { statusBarNotification ->
252                         val channel = getChannel(statusBarNotification.key)
253                         if (channel != null) {
254                             StatusBarNotificationWithChannel(statusBarNotification, channel)
255                         } else {
256                             null
257                         }
258                     }
259             }
260         }
261 
262         private fun getInstanceOrThrow(): TestNotificationListener {
263             // We want to check the current values of the connected and disconnected
264             // ConditionVariables, but importantly block(0) actually does not timeout immediately!
265             val isConnected = connected.block(1)
266             val isDisconnected = disconnected.block(1)
267             check(isConnected == !isDisconnected) {
268                 "Notification listener condition variables are inconsistent"
269             }
270             check(isConnected && !isDisconnected) {
271                 "Notification listener was unexpectedly disconnected"
272             }
273             return checkNotNull(instance) { "Notification listener was unexpectedly null" }
274         }
275 
276         /**
277          * Cancels a specific notification and then waits for it to be removed by the notification
278          * manager and marked as dismissed in Safety Center, or throws if it has not been removed
279          * within [timeout].
280          */
281         fun cancelAndWait(key: String, timeout: Duration = TIMEOUT_LONG) {
282             getInstanceOrThrow().cancelNotification(key)
283             waitForNotificationsToSatisfy(
284                 timeout,
285                 description = "no notification with the key $key"
286             ) { notifications -> notifications.none { it.statusBarNotification.key == key } }
287 
288             waitForIssueCacheToContainAnyDismissedNotification()
289         }
290 
291         private fun waitForIssueCacheToContainAnyDismissedNotification() {
292             // Here we wait for an issue to be recorded as dismissed according to the dumpsys
293             // output. The cancelAndWait helper above first "waits" for the notification to
294             // be dismissed, but this additional wait is needed to ensure the notification's delete
295             // PendingIntent is handled. Without this wait there is a race condition between
296             // SafetyCenterNotificationReceiver#onReceive and subsequent calls that set source data
297             // and that race makes tests flaky because the dismissal status of the previous
298             // notification is not well defined.
299             fun dumpIssueDismissalsRepositoryState(): String =
300                 SystemUtil.runShellCommand("dumpsys safety_center data")
301             try {
302                 waitForWithTimeout {
303                     dumpIssueDismissalsRepositoryState()
304                         .contains(Regex("""mNotificationDismissedAt=\d+"""))
305                 }
306             } catch (e: TimeoutCancellationException) {
307                 throw IllegalStateException(
308                     "Notification dismissal was not recorded in the issue cache: " +
309                         dumpIssueDismissalsRepositoryState(),
310                     e
311                 )
312             }
313         }
314 
315         /** Runs a shell command to allow or disallow the listener. Use before and after test. */
316         private fun toggleListenerAccess(allowed: Boolean) {
317             // TODO(b/260335646): Try to do this using the AndroidTest.xml instead of in code
318             val verb = if (allowed) "allow" else "disallow"
319             SystemUtil.runShellCommand("cmd notification ${verb}_listener $id")
320             if (allowed) {
321                 requestRebind(componentName)
322                 if (!connected.block(TIMEOUT_LONG.toMillis())) {
323                     throw TimeoutException("Notification listener did not connect in $TIMEOUT_LONG")
324                 }
325             } else {
326                 if (!disconnected.block(TIMEOUT_LONG.toMillis())) {
327                     throw TimeoutException(
328                         "Notification listener did not disconnect in $TIMEOUT_LONG"
329                     )
330                 }
331             }
332         }
333 
334         /** Prepare the [TestNotificationListener] for a notification test */
335         fun setup() {
336             toggleListenerAccess(true)
337         }
338 
339         /** Clean up the [TestNotificationListener] after executing a notification test. */
340         fun reset() {
341             waitForNotificationsToSatisfy(
342                 forAtLeast = Duration.ZERO,
343                 description = "all Safety Center notifications removed in tear down"
344             ) { it.isEmpty() }
345             toggleListenerAccess(false)
346             safetyCenterNotificationEvents.cancel()
347             safetyCenterNotificationEvents = Channel(capacity = Channel.UNLIMITED)
348         }
349 
350         private fun StatusBarNotification.isSafetyCenterNotification(): Boolean =
351             packageName == "android" && notification.channelId.startsWith("safety_center")
352     }
353 }
354