1 /*
<lambda>null2  * Copyright (C) 2023 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 com.android.safetycenter.testing
18 
19 import android.app.Notification
20 import android.app.NotificationChannel
21 import android.content.ComponentName
22 import android.content.Context
23 import android.os.ConditionVariable
24 import android.service.notification.NotificationListenerService
25 import android.service.notification.StatusBarNotification
26 import android.util.Log
27 import com.android.compatibility.common.util.SystemUtil
28 import com.android.safetycenter.testing.Coroutines.TIMEOUT_LONG
29 import com.android.safetycenter.testing.Coroutines.TIMEOUT_SHORT
30 import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeout
31 import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeoutOrNull
32 import com.android.safetycenter.testing.Coroutines.waitForWithTimeout
33 import com.google.common.truth.Truth.assertThat
34 import java.time.Duration
35 import java.util.concurrent.TimeoutException
36 import kotlinx.coroutines.TimeoutCancellationException
37 import kotlinx.coroutines.channels.Channel
38 
39 /** Used in tests to check whether expected notifications are present in the status bar. */
40 class TestNotificationListener : NotificationListenerService() {
41 
42     private sealed class NotificationEvent(val statusBarNotification: StatusBarNotification)
43 
44     private class NotificationPosted(statusBarNotification: StatusBarNotification) :
45         NotificationEvent(statusBarNotification) {
46         override fun toString(): String = "Posted $statusBarNotification"
47     }
48 
49     private class NotificationRemoved(statusBarNotification: StatusBarNotification) :
50         NotificationEvent(statusBarNotification) {
51         override fun toString(): String = "Removed $statusBarNotification"
52     }
53 
54     override fun onNotificationPosted(statusBarNotification: StatusBarNotification) {
55         super.onNotificationPosted(statusBarNotification)
56         if (statusBarNotification.isSafetyCenterNotification()) {
57             runBlockingWithTimeout {
58                 safetyCenterNotificationEvents.send(NotificationPosted(statusBarNotification))
59             }
60         }
61     }
62 
63     override fun onNotificationRemoved(statusBarNotification: StatusBarNotification) {
64         super.onNotificationRemoved(statusBarNotification)
65         if (statusBarNotification.isSafetyCenterNotification()) {
66             runBlockingWithTimeout {
67                 safetyCenterNotificationEvents.send(NotificationRemoved(statusBarNotification))
68             }
69         }
70     }
71 
72     override fun onListenerConnected() {
73         Log.d(TAG, "onListenerConnected")
74         super.onListenerConnected()
75         disconnected.close()
76         instance = this
77         connected.open()
78     }
79 
80     override fun onListenerDisconnected() {
81         Log.d(TAG, "onListenerDisconnected")
82         super.onListenerDisconnected()
83         connected.close()
84         instance = null
85         disconnected.open()
86     }
87 
88     companion object {
89         private const val TAG = "SafetyCenterTestNotif"
90 
91         private val connected = ConditionVariable(false)
92         private val disconnected = ConditionVariable(true)
93         private var instance: TestNotificationListener? = null
94 
95         @Volatile
96         private var safetyCenterNotificationEvents =
97             Channel<NotificationEvent>(capacity = Channel.UNLIMITED)
98 
99         /**
100          * Blocks until there are zero Safety Center notifications and there remain zero for a short
101          * duration. Throws an [AssertionError] if a this condition is not met within [timeout], or
102          * if it is met and then violated.
103          */
104         fun waitForZeroNotifications(timeout: Duration = TIMEOUT_LONG) {
105             waitForNotificationsToSatisfy(timeout = timeout, description = "No notifications") {
106                 it.isEmpty()
107             }
108         }
109 
110         /**
111          * Blocks until there is a single Safety Center notification, which matches the given
112          * [characteristics] and ensures that remains true for a short duration. Returns that
113          * notification, or throws an [AssertionError] if a this condition is not met within
114          * [timeout], or if it is met and then violated.
115          */
116         fun waitForSingleNotificationMatching(
117             characteristics: NotificationCharacteristics,
118             timeout: Duration = TIMEOUT_LONG
119         ): StatusBarNotificationWithChannel {
120             return waitForNotificationsMatching(characteristics, timeout = timeout).first()
121         }
122 
123         /**
124          * Blocks until the Safety Center notifications match the given [characteristics] and
125          * ensures that remains true for a short duration. Returns those notifications, or throws an
126          * [AssertionError] if a this condition is not met within [timeout], or if it is met and
127          * then violated.
128          */
129         fun waitForNotificationsMatching(
130             vararg characteristics: NotificationCharacteristics,
131             timeout: Duration = TIMEOUT_LONG
132         ): List<StatusBarNotificationWithChannel> {
133             val charsList = characteristics.toList()
134             return waitForNotificationsToSatisfy(
135                 timeout = timeout,
136                 description = "notification(s) matching characteristics $charsList"
137             ) {
138                 NotificationCharacteristics.areMatching(it, charsList)
139             }
140         }
141 
142         /**
143          * Waits for a success notification with the given [successMessage] after resolving an
144          * issue.
145          *
146          * Additional assertions can be made on the [StatusBarNotification] using [onNotification].
147          */
148         fun waitForSuccessNotification(
149             successMessage: String,
150             onNotification: (StatusBarNotification) -> Unit = {}
151         ) {
152             // Only wait for the notification event and don't wait for all notifications to "settle"
153             // as this notification is auto-cancelled after 10s; which can cause flakyness.
154             val statusBarNotification =
155                 (runBlockingWithTimeout {
156                         waitForNotificationEventAsync {
157                             (it is NotificationPosted &&
158                                 it.statusBarNotification.notification
159                                     ?.extras
160                                     ?.getString(Notification.EXTRA_TITLE) == successMessage)
161                         }
162                     }
163                         as NotificationPosted)
164                     .statusBarNotification
165             onNotification(statusBarNotification)
166             // Cancel the notification directly to speed up the tests as it's only auto-cancelled
167             // after 10 seconds, and the teardown waits for all notifications to be cancelled to
168             // avoid having unrelated notifications leaking between test cases.
169             cancelAndWait(statusBarNotification.key, waitForIssueCache = false)
170         }
171 
172         /**
173          * Blocks for [TIMEOUT_SHORT], or throw an [AssertionError] if any notification is posted or
174          * removed before then.
175          */
176         fun waitForZeroNotificationEvents() {
177             val event =
178                 runBlockingWithTimeoutOrNull(TIMEOUT_SHORT) {
179                     safetyCenterNotificationEvents.receive()
180                 }
181             assertThat(event).isNull()
182         }
183 
184         private fun waitForNotificationsToSatisfy(
185             timeout: Duration = TIMEOUT_LONG,
186             forAtLeast: Duration = TIMEOUT_SHORT,
187             description: String,
188             predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
189         ): List<StatusBarNotificationWithChannel> {
190             // First we wait at most timeout for the active notifications to satisfy the given
191             // predicate or otherwise we throw:
192             val satisfyingNotifications =
193                 try {
194                     runBlockingWithTimeout(timeout) {
195                         waitForNotificationsToSatisfyAsync(predicate)
196                     }
197                 } catch (e: TimeoutCancellationException) {
198                     throw AssertionError(
199                         "Expected: $description, but notifications were " +
200                             "${getSafetyCenterNotifications()} after waiting for $timeout",
201                         e
202                     )
203                 }
204 
205             // Assuming the predicate was satisfied, now we ensure it is not violated for the
206             // forAtLeast duration as well:
207             val nonSatisfyingNotifications =
208                 runBlockingWithTimeoutOrNull(forAtLeast) {
209                     waitForNotificationsToSatisfyAsync { !predicate(it) }
210                 }
211             if (nonSatisfyingNotifications != null) {
212                 // In this case the negated-predicate was satisfied before forAtLeast had elapsed
213                 throw AssertionError(
214                     "Expected: $description to settle, but notifications changed to " +
215                         "$nonSatisfyingNotifications within $forAtLeast"
216                 )
217             }
218 
219             return satisfyingNotifications
220         }
221 
222         private suspend fun waitForNotificationsToSatisfyAsync(
223             predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
224         ): List<StatusBarNotificationWithChannel> {
225             var currentNotifications = getSafetyCenterNotifications()
226             while (!predicate(currentNotifications)) {
227                 val event = safetyCenterNotificationEvents.receive()
228                 Log.d(TAG, "Received notification event: $event")
229                 currentNotifications = getSafetyCenterNotifications()
230             }
231             return currentNotifications
232         }
233 
234         private suspend fun waitForNotificationEventAsync(
235             predicate: (NotificationEvent) -> Boolean
236         ): NotificationEvent {
237             var event: NotificationEvent
238             do {
239                 event = safetyCenterNotificationEvents.receive()
240                 Log.d(TAG, "Received notification event: $event")
241             } while (!predicate(event))
242             return event
243         }
244 
245         private fun getSafetyCenterNotifications(): List<StatusBarNotificationWithChannel> {
246             return with(getInstanceOrThrow()) {
247                 val notificationsSnapshot =
248                     checkNotNull(getActiveNotifications()) {
249                         "getActiveNotifications() returned null"
250                     }
251                 val rankingSnapshot =
252                     checkNotNull(getCurrentRanking()) { "getCurrentRanking() returned null" }
253 
254                 fun getChannel(key: String): NotificationChannel? {
255                     // This API uses a result parameter:
256                     val rankingOut = Ranking()
257                     val success = rankingSnapshot.getRanking(key, rankingOut)
258                     return if (success) {
259                         rankingOut.channel
260                     } else {
261                         null
262                     }
263                 }
264 
265                 notificationsSnapshot
266                     .filter { it.isSafetyCenterNotification() }
267                     .mapNotNull { statusBarNotification ->
268                         val channel = getChannel(statusBarNotification.key)
269                         if (channel != null) {
270                             StatusBarNotificationWithChannel(statusBarNotification, channel)
271                         } else {
272                             null
273                         }
274                     }
275             }
276         }
277 
278         private fun getInstanceOrThrow(): TestNotificationListener {
279             // We want to check the current values of the connected and disconnected
280             // ConditionVariables, but importantly block(0) actually does not timeout immediately!
281             val isConnected = connected.block(1)
282             val isDisconnected = disconnected.block(1)
283             check(isConnected == !isDisconnected) {
284                 "Notification listener condition variables are inconsistent"
285             }
286             check(isConnected && !isDisconnected) {
287                 "Notification listener was unexpectedly disconnected"
288             }
289             return checkNotNull(instance) { "Notification listener was unexpectedly null" }
290         }
291 
292         /**
293          * Cancels a specific notification and then waits for it to be removed by the notification
294          * manager and marked as dismissed in Safety Center, or throws if it has not been removed
295          * within [TIMEOUT_LONG].
296          */
297         fun cancelAndWait(key: String, waitForIssueCache: Boolean = true) {
298             getInstanceOrThrow().cancelNotification(key)
299             waitForNotificationsToSatisfy(
300                 timeout = TIMEOUT_LONG,
301                 description = "no notification with the key $key"
302             ) { notifications ->
303                 notifications.none { it.statusBarNotification.key == key }
304             }
305 
306             if (waitForIssueCache) {
307                 waitForIssueCacheToContainAnyDismissedNotification()
308             }
309         }
310 
311         private fun waitForIssueCacheToContainAnyDismissedNotification() {
312             // Here we wait for an issue to be recorded as dismissed according to the dumpsys
313             // output. The cancelAndWait helper above first "waits" for the notification to
314             // be dismissed, but this additional wait is needed to ensure the notification's delete
315             // PendingIntent is handled. Without this wait there is a race condition between
316             // SafetyCenterNotificationReceiver#onReceive and subsequent calls that set source data
317             // and that race makes tests flaky because the dismissal status of the previous
318             // notification is not well defined.
319             fun dumpIssueDismissalsRepositoryState(): String =
320                 SystemUtil.runShellCommand("dumpsys safety_center data")
321             try {
322                 waitForWithTimeout {
323                     dumpIssueDismissalsRepositoryState()
324                         .contains(Regex("""mNotificationDismissedAt=\d+"""))
325                 }
326             } catch (e: TimeoutCancellationException) {
327                 throw IllegalStateException(
328                     "Notification dismissal was not recorded in the issue cache: " +
329                         dumpIssueDismissalsRepositoryState(),
330                     e
331                 )
332             }
333         }
334 
335         /** Runs a shell command to allow or disallow the listener. Use before and after test. */
336         private fun toggleListenerAccess(context: Context, allowed: Boolean) {
337             val componentName = ComponentName(context, TestNotificationListener::class.java)
338             val verb = if (allowed) "allow" else "disallow"
339             SystemUtil.runShellCommand(
340                 "cmd notification ${verb}_listener ${componentName.flattenToString()}"
341             )
342             if (allowed) {
343                 requestRebind(componentName)
344                 if (!connected.block(TIMEOUT_LONG.toMillis())) {
345                     throw TimeoutException("Notification listener did not connect in $TIMEOUT_LONG")
346                 }
347             } else {
348                 if (!disconnected.block(TIMEOUT_LONG.toMillis())) {
349                     throw TimeoutException(
350                         "Notification listener did not disconnect in $TIMEOUT_LONG"
351                     )
352                 }
353             }
354         }
355 
356         /** Prepare the [TestNotificationListener] for a notification test */
357         fun setup(context: Context) {
358             toggleListenerAccess(context, true)
359         }
360 
361         /** Clean up the [TestNotificationListener] after executing a notification test. */
362         fun reset(context: Context) {
363             waitForNotificationsToSatisfy(
364                 forAtLeast = Duration.ZERO,
365                 description = "all Safety Center notifications removed in tear down"
366             ) {
367                 it.isEmpty()
368             }
369             toggleListenerAccess(context, false)
370             safetyCenterNotificationEvents.cancel()
371             safetyCenterNotificationEvents = Channel(capacity = Channel.UNLIMITED)
372         }
373 
374         private fun StatusBarNotification.isSafetyCenterNotification(): Boolean =
375             packageName == "android" &&
376                 notification.channelId.startsWith("safety_center") &&
377                 // Don't consider the grouped system notifications to be a SC notification, in some
378                 // scenarios a "ranker_group" notification can remain even when there are no more
379                 // notifications associated with the channel. See b/293593539 for more details.
380                 tag != "ranker_group"
381     }
382 }
383