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