blob: db714fa97f07068f96a924f23d7d5b42310da497 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.safetycenter.testing
import android.app.NotificationChannel
import android.content.ComponentName
import android.content.Context
import android.os.ConditionVariable
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.android.compatibility.common.util.SystemUtil
import com.android.safetycenter.testing.Coroutines.TIMEOUT_LONG
import com.android.safetycenter.testing.Coroutines.TIMEOUT_SHORT
import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeout
import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeoutOrNull
import com.android.safetycenter.testing.Coroutines.waitForWithTimeout
import com.google.common.truth.Truth.assertThat
import java.time.Duration
import java.util.concurrent.TimeoutException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
/** Used in tests to check whether expected notifications are present in the status bar. */
class TestNotificationListener : NotificationListenerService() {
private sealed class NotificationEvent(val statusBarNotification: StatusBarNotification)
private class NotificationPosted(statusBarNotification: StatusBarNotification) :
NotificationEvent(statusBarNotification) {
override fun toString(): String = "Posted $statusBarNotification"
}
private class NotificationRemoved(statusBarNotification: StatusBarNotification) :
NotificationEvent(statusBarNotification) {
override fun toString(): String = "Removed $statusBarNotification"
}
override fun onNotificationPosted(statusBarNotification: StatusBarNotification) {
super.onNotificationPosted(statusBarNotification)
if (statusBarNotification.isSafetyCenterNotification()) {
runBlockingWithTimeout {
safetyCenterNotificationEvents.send(NotificationPosted(statusBarNotification))
}
}
}
override fun onNotificationRemoved(statusBarNotification: StatusBarNotification) {
super.onNotificationRemoved(statusBarNotification)
if (statusBarNotification.isSafetyCenterNotification()) {
runBlockingWithTimeout {
safetyCenterNotificationEvents.send(NotificationRemoved(statusBarNotification))
}
}
}
override fun onListenerConnected() {
Log.d(TAG, "onListenerConnected")
super.onListenerConnected()
disconnected.close()
instance = this
connected.open()
}
override fun onListenerDisconnected() {
Log.d(TAG, "onListenerDisconnected")
super.onListenerDisconnected()
connected.close()
instance = null
disconnected.open()
}
companion object {
private const val TAG = "SafetyCenterTestNotif"
private val connected = ConditionVariable(false)
private val disconnected = ConditionVariable(true)
private var instance: TestNotificationListener? = null
@Volatile
private var safetyCenterNotificationEvents =
Channel<NotificationEvent>(capacity = Channel.UNLIMITED)
/**
* Blocks until there are zero Safety Center notifications and there remain zero for a short
* duration. Throws an [AssertionError] if a this condition is not met within [timeout], or
* if it is met and then violated.
*/
fun waitForZeroNotifications(timeout: Duration = TIMEOUT_LONG) {
waitForNotificationCount(0, timeout)
}
/**
* Blocks until there is exactly one Safety Center notification and ensures that remains
* true for a short duration. Returns that notification, or throws an [AssertionError] if a
* this condition is not met within [timeout], or if it is met and then violated.
*/
fun waitForSingleNotification(
timeout: Duration = TIMEOUT_LONG
): StatusBarNotificationWithChannel {
return waitForNotificationCount(1, timeout).first()
}
/**
* Blocks until there are exactly [count] Safety Center notifications and ensures that
* remains true for a short duration. Returns those notifications, or throws an
* [AssertionError] if a this condition is not met within [timeout], or if it is met and
* then violated.
*/
private fun waitForNotificationCount(
count: Int,
timeout: Duration = TIMEOUT_LONG
): List<StatusBarNotificationWithChannel> {
return waitForNotificationsToSatisfy(
timeout = timeout,
description = "$count notifications"
) {
it.size == count
}
}
/**
* Blocks until there is a single Safety Center notification, which matches the given
* [characteristics] and ensures that remains true for a short duration. Returns that
* notification, or throws an [AssertionError] if a this condition is not met within
* [timeout], or if it is met and then violated.
*/
fun waitForSingleNotificationMatching(
characteristics: NotificationCharacteristics,
timeout: Duration = TIMEOUT_LONG
): StatusBarNotificationWithChannel {
return waitForNotificationsMatching(characteristics, timeout = timeout).first()
}
/**
* Blocks until the Safety Center notifications match the given [characteristics] and
* ensures that remains true for a short duration. Returns those notifications, or throws an
* [AssertionError] if a this condition is not met within [timeout], or if it is met and
* then violated.
*/
fun waitForNotificationsMatching(
vararg characteristics: NotificationCharacteristics,
timeout: Duration = TIMEOUT_LONG
): List<StatusBarNotificationWithChannel> {
val charsList = characteristics.toList()
return waitForNotificationsToSatisfy(
timeout = timeout,
description = "notification(s) matching characteristics $charsList"
) {
NotificationCharacteristics.areMatching(it, charsList)
}
}
/**
* Blocks for [TIMEOUT_SHORT], or throw an [AssertionError] if any notification is posted or
* removed before then.
*/
fun waitForZeroNotificationEvents() {
val event =
runBlockingWithTimeoutOrNull(TIMEOUT_SHORT) {
safetyCenterNotificationEvents.receive()
}
assertThat(event).isNull()
}
private fun waitForNotificationsToSatisfy(
timeout: Duration = TIMEOUT_LONG,
forAtLeast: Duration = TIMEOUT_SHORT,
description: String,
predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
): List<StatusBarNotificationWithChannel> {
// First we wait at most timeout for the active notifications to satisfy the given
// predicate or otherwise we throw:
val satisfyingNotifications =
try {
runBlockingWithTimeout(timeout) {
waitForNotificationsToSatisfyAsync(predicate)
}
} catch (e: TimeoutCancellationException) {
throw AssertionError(
"Expected: $description, but notifications were " +
"${getSafetyCenterNotifications()} after waiting for $timeout",
e
)
}
// Assuming the predicate was satisfied, now we ensure it is not violated for the
// forAtLeast duration as well:
val nonSatisfyingNotifications =
runBlockingWithTimeoutOrNull(forAtLeast) {
waitForNotificationsToSatisfyAsync { !predicate(it) }
}
if (nonSatisfyingNotifications != null) {
// In this case the negated-predicate was satisfied before forAtLeast had elapsed
throw AssertionError(
"Expected: $description to settle, but notifications changed to " +
"$nonSatisfyingNotifications within $forAtLeast"
)
}
return satisfyingNotifications
}
private suspend fun waitForNotificationsToSatisfyAsync(
predicate: (List<StatusBarNotificationWithChannel>) -> Boolean
): List<StatusBarNotificationWithChannel> {
var currentNotifications = getSafetyCenterNotifications()
while (!predicate(currentNotifications)) {
val event = safetyCenterNotificationEvents.receive()
Log.d(TAG, "Received notification event: $event")
currentNotifications = getSafetyCenterNotifications()
}
return currentNotifications
}
private fun getSafetyCenterNotifications(): List<StatusBarNotificationWithChannel> {
return with(getInstanceOrThrow()) {
val notificationsSnapshot =
checkNotNull(getActiveNotifications()) {
"getActiveNotifications() returned null"
}
val rankingSnapshot =
checkNotNull(getCurrentRanking()) { "getCurrentRanking() returned null" }
fun getChannel(key: String): NotificationChannel? {
// This API uses a result parameter:
val rankingOut = Ranking()
val success = rankingSnapshot.getRanking(key, rankingOut)
return if (success) {
rankingOut.channel
} else {
null
}
}
notificationsSnapshot
.filter { it.isSafetyCenterNotification() }
.mapNotNull { statusBarNotification ->
val channel = getChannel(statusBarNotification.key)
if (channel != null) {
StatusBarNotificationWithChannel(statusBarNotification, channel)
} else {
null
}
}
}
}
private fun getInstanceOrThrow(): TestNotificationListener {
// We want to check the current values of the connected and disconnected
// ConditionVariables, but importantly block(0) actually does not timeout immediately!
val isConnected = connected.block(1)
val isDisconnected = disconnected.block(1)
check(isConnected == !isDisconnected) {
"Notification listener condition variables are inconsistent"
}
check(isConnected && !isDisconnected) {
"Notification listener was unexpectedly disconnected"
}
return checkNotNull(instance) { "Notification listener was unexpectedly null" }
}
/**
* Cancels a specific notification and then waits for it to be removed by the notification
* manager and marked as dismissed in Safety Center, or throws if it has not been removed
* within [timeout].
*/
fun cancelAndWait(key: String, timeout: Duration = TIMEOUT_LONG) {
getInstanceOrThrow().cancelNotification(key)
waitForNotificationsToSatisfy(
timeout = timeout,
description = "no notification with the key $key"
) { notifications ->
notifications.none { it.statusBarNotification.key == key }
}
waitForIssueCacheToContainAnyDismissedNotification()
}
private fun waitForIssueCacheToContainAnyDismissedNotification() {
// Here we wait for an issue to be recorded as dismissed according to the dumpsys
// output. The cancelAndWait helper above first "waits" for the notification to
// be dismissed, but this additional wait is needed to ensure the notification's delete
// PendingIntent is handled. Without this wait there is a race condition between
// SafetyCenterNotificationReceiver#onReceive and subsequent calls that set source data
// and that race makes tests flaky because the dismissal status of the previous
// notification is not well defined.
fun dumpIssueDismissalsRepositoryState(): String =
SystemUtil.runShellCommand("dumpsys safety_center data")
try {
waitForWithTimeout {
dumpIssueDismissalsRepositoryState()
.contains(Regex("""mNotificationDismissedAt=\d+"""))
}
} catch (e: TimeoutCancellationException) {
throw IllegalStateException(
"Notification dismissal was not recorded in the issue cache: " +
dumpIssueDismissalsRepositoryState(),
e
)
}
}
/** Runs a shell command to allow or disallow the listener. Use before and after test. */
private fun toggleListenerAccess(context: Context, allowed: Boolean) {
val componentName = ComponentName(context, TestNotificationListener::class.java)
val verb = if (allowed) "allow" else "disallow"
SystemUtil.runShellCommand(
"cmd notification ${verb}_listener ${componentName.flattenToString()}"
)
if (allowed) {
requestRebind(componentName)
if (!connected.block(TIMEOUT_LONG.toMillis())) {
throw TimeoutException("Notification listener did not connect in $TIMEOUT_LONG")
}
} else {
if (!disconnected.block(TIMEOUT_LONG.toMillis())) {
throw TimeoutException(
"Notification listener did not disconnect in $TIMEOUT_LONG"
)
}
}
}
/** Prepare the [TestNotificationListener] for a notification test */
fun setup(context: Context) {
toggleListenerAccess(context, true)
}
/** Clean up the [TestNotificationListener] after executing a notification test. */
fun reset(context: Context) {
waitForNotificationsToSatisfy(
forAtLeast = Duration.ZERO,
description = "all Safety Center notifications removed in tear down"
) {
it.isEmpty()
}
toggleListenerAccess(context, false)
safetyCenterNotificationEvents.cancel()
safetyCenterNotificationEvents = Channel(capacity = Channel.UNLIMITED)
}
private fun StatusBarNotification.isSafetyCenterNotification(): Boolean =
packageName == "android" &&
notification.channelId.startsWith("safety_center") &&
// Don't consider the grouped system notifications to be a SC notification, in some
// scenarios a "ranker_group" notification can remain even when there are no more
// notifications associated with the channel. See b/293593539 for more details.
tag != "ranker_group"
}
}