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