summaryrefslogtreecommitdiff
path: root/tests/utils/safetycenter/java/com/android/safetycenter
diff options
context:
space:
mode:
Diffstat (limited to 'tests/utils/safetycenter/java/com/android/safetycenter')
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/Coroutines.kt1
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/EnableSensorRule.kt72
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/NotificationCharacteristics.kt71
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterActivityLauncher.kt18
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterEnabledChangedReceiver.kt16
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterFlags.kt66
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestConfigs.kt63
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestData.kt65
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestHelper.kt25
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestRule.kt57
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceIntentHandler.kt14
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceReceiver.kt38
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceTestData.kt152
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/StatusBarNotificationWithChannel.kt26
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenter.kt29
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenterRule.kt50
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/TestActivity.kt34
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/TestNotificationListener.kt393
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/UiTestHelper.kt85
-rw-r--r--tests/utils/safetycenter/java/com/android/safetycenter/testing/WaitForBroadcasts.kt74
20 files changed, 1101 insertions, 248 deletions
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/Coroutines.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/Coroutines.kt
index 29d1c1f09..a7009b19e 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/Coroutines.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/Coroutines.kt
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withTimeoutOrNull
/** A class that facilitates interacting with coroutines. */
object Coroutines {
+
/**
* The timeout of a test case, typically varies depending on whether the test is running
* locally, on pre-submit or post-submit.
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/EnableSensorRule.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/EnableSensorRule.kt
new file mode 100644
index 000000000..1ed0ecbc3
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/EnableSensorRule.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.Manifest.permission.MANAGE_SENSOR_PRIVACY
+import android.Manifest.permission.OBSERVE_SENSOR_PRIVACY
+import android.content.Context
+import android.hardware.SensorPrivacyManager
+import android.hardware.SensorPrivacyManager.TOGGLE_TYPE_SOFTWARE
+import com.android.safetycenter.testing.ShellPermissions.callWithShellPermissionIdentity
+import org.junit.Assume.assumeTrue
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A JUnit [TestRule] to ensure a given [sensor] is enabled.
+ *
+ * This rule disables sensor privacy before a test and restores the prior state afterwards.
+ */
+class EnableSensorRule(context: Context, val sensor: Int) : TestRule {
+
+ private val sensorPrivacyManager: SensorPrivacyManager =
+ context.getSystemService(SensorPrivacyManager::class.java)!!
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ override fun evaluate() {
+ assumeTrue(
+ "Test device does not support toggling sensor $sensor",
+ supportsSensorToggle()
+ )
+ val oldSensorPrivacy = isSensorPrivacyEnabled()
+ setSensorPrivacy(false)
+ try {
+ base.evaluate()
+ } finally {
+ setSensorPrivacy(oldSensorPrivacy)
+ }
+ }
+ }
+ }
+
+ private fun supportsSensorToggle(): Boolean =
+ sensorPrivacyManager.supportsSensorToggle(sensor) &&
+ sensorPrivacyManager.supportsSensorToggle(TOGGLE_TYPE_SOFTWARE, sensor)
+
+ private fun isSensorPrivacyEnabled(): Boolean =
+ callWithShellPermissionIdentity(OBSERVE_SENSOR_PRIVACY) {
+ sensorPrivacyManager.isSensorPrivacyEnabled(TOGGLE_TYPE_SOFTWARE, sensor)
+ }
+
+ private fun setSensorPrivacy(enabled: Boolean) {
+ callWithShellPermissionIdentity(MANAGE_SENSOR_PRIVACY, OBSERVE_SENSOR_PRIVACY) {
+ sensorPrivacyManager.setSensorPrivacy(sensor, enabled)
+ }
+ }
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/NotificationCharacteristics.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/NotificationCharacteristics.kt
new file mode 100644
index 000000000..177c2359c
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/NotificationCharacteristics.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.Notification
+
+/** The characteristic properties of a notification. */
+data class NotificationCharacteristics(
+ val title: String,
+ val text: String,
+ val actions: List<CharSequence> = emptyList(),
+ val importance: Int = IMPORTANCE_ANY,
+ val blockable: Boolean? = null
+) {
+ companion object {
+ const val IMPORTANCE_ANY = -1
+
+ private fun importanceMatches(
+ statusBarNotificationWithChannel: StatusBarNotificationWithChannel,
+ characteristicImportance: Int
+ ): Boolean {
+ return characteristicImportance == IMPORTANCE_ANY ||
+ statusBarNotificationWithChannel.channel.importance == characteristicImportance
+ }
+
+ private fun blockableMatches(
+ statusBarNotificationWithChannel: StatusBarNotificationWithChannel,
+ characteristicBlockable: Boolean?
+ ): Boolean {
+ return characteristicBlockable == null ||
+ statusBarNotificationWithChannel.channel.isBlockable == characteristicBlockable
+ }
+
+ private fun isMatch(
+ statusBarNotificationWithChannel: StatusBarNotificationWithChannel,
+ characteristic: NotificationCharacteristics
+ ): Boolean {
+ val notif = statusBarNotificationWithChannel.statusBarNotification.notification
+ return notif != null &&
+ notif.extras.getString(Notification.EXTRA_TITLE) == characteristic.title &&
+ notif.extras.getString(Notification.EXTRA_TEXT).orEmpty() == characteristic.text &&
+ notif.actions.orEmpty().map { it.title } == characteristic.actions &&
+ importanceMatches(statusBarNotificationWithChannel, characteristic.importance) &&
+ blockableMatches(statusBarNotificationWithChannel, characteristic.blockable)
+ }
+
+ fun areMatching(
+ statusBarNotifications: List<StatusBarNotificationWithChannel>,
+ characteristics: List<NotificationCharacteristics>
+ ): Boolean {
+ if (statusBarNotifications.size != characteristics.size) {
+ return false
+ }
+ return statusBarNotifications.zip(characteristics).all { isMatch(it.first, it.second) }
+ }
+ }
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterActivityLauncher.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterActivityLauncher.kt
index 537eb7ead..40515fa33 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterActivityLauncher.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterActivityLauncher.kt
@@ -28,7 +28,6 @@ import android.os.Build.VERSION_CODES.TIRAMISU
import android.os.Bundle
import androidx.annotation.RequiresApi
import androidx.test.uiautomator.By
-import com.android.compatibility.common.util.RetryableException
import com.android.compatibility.common.util.UiAutomatorUtils2.getUiDevice
import com.android.safetycenter.testing.ShellPermissions.callWithShellPermissionIdentity
import com.android.safetycenter.testing.UiTestHelper.waitDisplayed
@@ -46,13 +45,14 @@ object SafetyCenterActivityLauncher {
*/
fun Context.launchSafetyCenterActivity(
intentExtras: Bundle? = null,
+ intentAction: String = ACTION_SAFETY_CENTER,
withReceiverPermission: Boolean = false,
preventTrampolineToSettings: Boolean = true,
block: () -> Unit
) {
val launchSafetyCenterIntent =
createIntent(
- ACTION_SAFETY_CENTER,
+ intentAction,
intentExtras,
preventTrampolineToSettings = preventTrampolineToSettings
)
@@ -80,19 +80,6 @@ object SafetyCenterActivityLauncher {
executeBlockAndExit(block) { waitDisplayed(By.text(entryPoint)) { it.click() } }
}
- /**
- * Launches a page in Safety Center and exits it once [block] completes, throwing a
- * [RetryableException] for any [RuntimeException] thrown by [block] to allow [RetryRule] to
- * retry the test invocation.
- */
- fun openPageAndExitAllowingRetries(entryPoint: String, block: () -> Unit) {
- try {
- openPageAndExit(entryPoint, block)
- } catch (e: Throwable) {
- throw RetryableException(e, "Exception occurred when checking a Safety Center page")
- }
- }
-
private fun createIntent(
intentAction: String,
intentExtras: Bundle?,
@@ -107,6 +94,7 @@ object SafetyCenterActivityLauncher {
return launchIntent
}
+ /** Executes the given [block] and presses the back button to exit. */
fun executeBlockAndExit(block: () -> Unit, launchActivity: () -> Unit) {
val uiDevice = getUiDevice()
uiDevice.waitForIdle()
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterEnabledChangedReceiver.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterEnabledChangedReceiver.kt
index b948dc52c..f8926caac 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterEnabledChangedReceiver.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterEnabledChangedReceiver.kt
@@ -24,8 +24,8 @@ import android.content.IntentFilter
import android.os.Build.VERSION_CODES.TIRAMISU
import android.safetycenter.SafetyCenterManager.ACTION_SAFETY_CENTER_ENABLED_CHANGED
import androidx.annotation.RequiresApi
-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.ShellPermissions.callWithShellPermissionIdentity
import java.time.Duration
@@ -55,20 +55,18 @@ class SafetyCenterEnabledChangedReceiver(private val context: Context) : Broadca
fun setSafetyCenterEnabledWithReceiverPermissionAndWait(
value: Boolean,
timeout: Duration = TIMEOUT_LONG
- ) =
+ ): Boolean =
callWithShellPermissionIdentity(READ_SAFETY_CENTER_STATUS) {
- setSafetyCenterEnabledWithoutReceiverPermissionAndWait(value, timeout)
+ SafetyCenterFlags.isEnabled = value
+ receiveSafetyCenterEnabledChanged(timeout)
}
fun setSafetyCenterEnabledWithoutReceiverPermissionAndWait(
value: Boolean,
- timeout: Duration = TIMEOUT_LONG
- ): Boolean {
+ ) {
SafetyCenterFlags.isEnabled = value
- if (timeout < TIMEOUT_LONG) {
- SystemUtil.waitForBroadcasts()
- }
- return receiveSafetyCenterEnabledChanged(timeout)
+ WaitForBroadcasts.waitForBroadcasts()
+ receiveSafetyCenterEnabledChanged(TIMEOUT_SHORT)
}
fun unregister() {
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterFlags.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterFlags.kt
index f7b5f486d..912ea44ad 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterFlags.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterFlags.kt
@@ -20,9 +20,7 @@ import android.Manifest.permission.READ_DEVICE_CONFIG
import android.Manifest.permission.WRITE_DEVICE_CONFIG
import android.annotation.TargetApi
import android.app.job.JobInfo
-import android.content.Context
import android.content.pm.PackageManager
-import android.content.res.Resources
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import android.provider.DeviceConfig
import android.provider.DeviceConfig.NAMESPACE_PRIVACY
@@ -35,6 +33,7 @@ import android.safetycenter.SafetyCenterManager.REFRESH_REASON_PERIODIC
import android.safetycenter.SafetyCenterManager.REFRESH_REASON_RESCAN_BUTTON_CLICK
import android.safetycenter.SafetyCenterManager.REFRESH_REASON_SAFETY_CENTER_ENABLED
import android.safetycenter.SafetySourceData
+import com.android.modules.utils.build.SdkLevel
import com.android.safetycenter.testing.Coroutines.TEST_TIMEOUT
import com.android.safetycenter.testing.Coroutines.TIMEOUT_LONG
import com.android.safetycenter.testing.ShellPermissions.callWithShellPermissionIdentity
@@ -46,7 +45,7 @@ object SafetyCenterFlags {
/** Flag that determines whether Safety Center is enabled. */
private val isEnabledFlag =
- Flag("safety_center_is_enabled", defaultValue = false, BooleanParser())
+ Flag("safety_center_is_enabled", defaultValue = SdkLevel.isAtLeastU(), BooleanParser())
/** Flag that determines whether Safety Center can send notifications. */
private val notificationsFlag =
@@ -102,13 +101,6 @@ object SafetyCenterFlags {
DurationParser()
)
- /**
- * Flag that determines whether we should show error entries for sources that timeout when
- * refreshing them.
- */
- private val showErrorEntriesOnTimeoutFlag =
- Flag("safety_center_show_error_entries_on_timeout", defaultValue = true, BooleanParser())
-
/** Flag that determines whether we should replace the IconAction of the lock screen source. */
private val replaceLockScreenIconActionFlag =
Flag("safety_center_replace_lock_screen_icon_action", defaultValue = true, BooleanParser())
@@ -211,6 +203,18 @@ object SafetyCenterFlags {
)
/**
+ * Flag containing a map (a comma separated list of colon separated pairs) where the key is a
+ * Safety Source ID and the value is a vertical-bar-delimited list of Action IDs that should
+ * have their PendingIntent replaced with the source's default PendingIntent.
+ */
+ private val actionsToOverrideWithDefaultIntentFlag =
+ Flag(
+ "safety_center_actions_to_override_with_default_intent",
+ defaultValue = emptyMap(),
+ MapParser(StringParser(), SetParser(StringParser(), delimiter = "|"))
+ )
+
+ /**
* Flag that represents a comma delimited list of IDs of sources that should only be refreshed
* when Safety Center is on screen. We will refresh these sources only on page open and when the
* scan button is clicked.
@@ -293,13 +297,6 @@ object SafetyCenterFlags {
MapParser(StringParser(), SetParser(StringParser(), delimiter = "|"))
)
- /**
- * Flag that determines whether background refreshes require charging in
- * [SafetyCenterBackgroundRefreshJobService]. See [JobInfo.setRequiresCharging] for details.
- */
- private val backgroundRefreshRequiresChargingFlag =
- Flag("safety_center_background_requires_charging", defaultValue = false, BooleanParser())
-
/** Every Safety Center flag. */
private val FLAGS: List<Flag<*>> =
listOf(
@@ -309,7 +306,6 @@ object SafetyCenterFlags {
notificationsMinDelayFlag,
immediateNotificationBehaviorIssuesFlag,
notificationResurfaceIntervalFlag,
- showErrorEntriesOnTimeoutFlag,
replaceLockScreenIconActionFlag,
refreshSourceTimeoutsFlag,
resolveActionTimeoutFlag,
@@ -319,6 +315,7 @@ object SafetyCenterFlags {
resurfaceIssueMaxCountsFlag,
resurfaceIssueDelaysFlag,
issueCategoryAllowlistsFlag,
+ actionsToOverrideWithDefaultIntentFlag,
allowedAdditionalPackageCertsFlag,
backgroundRefreshDeniedSourcesFlag,
allowStatsdLoggingFlag,
@@ -326,14 +323,7 @@ object SafetyCenterFlags {
showSubpagesFlag,
overrideRefreshOnPageOpenSourcesFlag,
backgroundRefreshIsEnabledFlag,
- periodicBackgroundRefreshIntervalFlag,
- backgroundRefreshRequiresChargingFlag
- )
-
- /** Returns whether the device supports Safety Center. */
- fun Context.deviceSupportsSafetyCenter() =
- resources.getBoolean(
- Resources.getSystem().getIdentifier("config_enableSafetyCenter", "bool", "android")
+ periodicBackgroundRefreshIntervalFlag
)
/** A property that allows getting and setting the [isEnabledFlag]. */
@@ -354,9 +344,6 @@ object SafetyCenterFlags {
/** A property that allows getting and setting the [notificationResurfaceIntervalFlag]. */
var notificationResurfaceInterval: Duration by notificationResurfaceIntervalFlag
- /** A property that allows getting and setting the [showErrorEntriesOnTimeoutFlag]. */
- var showErrorEntriesOnTimeout: Boolean by showErrorEntriesOnTimeoutFlag
-
/** A property that allows getting and setting the [replaceLockScreenIconActionFlag]. */
var replaceLockScreenIconAction: Boolean by replaceLockScreenIconActionFlag
@@ -384,6 +371,10 @@ object SafetyCenterFlags {
/** A property that allows getting and setting the [issueCategoryAllowlistsFlag]. */
var issueCategoryAllowlists: Map<Int, Set<String>> by issueCategoryAllowlistsFlag
+ /** A property that allows getting and setting the [actionsToOverrideWithDefaultIntentFlag]. */
+ var actionsToOverrideWithDefaultIntent: Map<String, Set<String>> by
+ actionsToOverrideWithDefaultIntentFlag
+
var allowedAdditionalPackageCerts: Map<String, Set<String>> by allowedAdditionalPackageCertsFlag
/** A property that allows getting and setting the [backgroundRefreshDeniedSourcesFlag]. */
@@ -398,15 +389,6 @@ object SafetyCenterFlags {
/** A property that allows getting and setting the [overrideRefreshOnPageOpenSourcesFlag]. */
var overrideRefreshOnPageOpenSources: Set<String> by overrideRefreshOnPageOpenSourcesFlag
- /** A property that allows getting and settings the [backgroundRefreshIsEnabledFlag]. */
- var backgroundRefreshIsEnabled: Boolean by backgroundRefreshIsEnabledFlag
-
- /** A property that allows getting and settings the [periodicBackgroundRefreshIntervalFlag]. */
- var periodicBackgroundRefreshInterval: Duration by periodicBackgroundRefreshIntervalFlag
-
- /** A property that allows getting and settings the [backgroundRefreshRequiresChargingFlag]. */
- var backgroundRefreshRequiresCharging: Boolean by backgroundRefreshRequiresChargingFlag
-
/**
* Returns a snapshot of all the Safety Center flags.
*
@@ -460,7 +442,7 @@ object SafetyCenterFlags {
/** Returns the [isEnabledFlag] value of the Safety Center flags snapshot. */
fun Properties.isSafetyCenterEnabled() =
- getBoolean(isEnabledFlag.name, /* defaultValue */ false)
+ getBoolean(isEnabledFlag.name, isEnabledFlag.defaultValue)
@TargetApi(UPSIDE_DOWN_CAKE)
private fun getAllRefreshTimeoutsMap(refreshTimeout: Duration): Map<Int, Duration> =
@@ -533,11 +515,7 @@ object SafetyCenterFlags {
.joinToString(entriesDelimiter)
}
- private class Flag<T>(
- val name: String,
- private val defaultValue: T,
- private val parser: Parser<T>
- ) {
+ private class Flag<T>(val name: String, val defaultValue: T, private val parser: Parser<T>) {
val defaultStringValue = parser.toString(defaultValue)
operator fun getValue(thisRef: Any?, property: KProperty<*>): T =
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestConfigs.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestConfigs.kt
index de4cf7094..60c3b4d6a 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestConfigs.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestConfigs.kt
@@ -49,7 +49,7 @@ class SafetyCenterTestConfigs(private val context: Context) {
context.packageName,
PackageInfoFlags.of(GET_SIGNING_CERTIFICATES.toLong())
)
- .signingInfo
+ .signingInfo!!
.apkContentsSigners[0]
.toByteArray()
)
@@ -64,7 +64,9 @@ class SafetyCenterTestConfigs(private val context: Context) {
*/
val singleSourceInvalidIntentConfig =
singleSourceConfig(
- dynamicSafetySourceBuilder(SINGLE_SOURCE_ID).setIntentAction("stub").build()
+ dynamicSafetySourceBuilder(SINGLE_SOURCE_ID)
+ .setIntentAction(INTENT_ACTION_NOT_RESOLVING)
+ .build()
)
/**
@@ -348,7 +350,9 @@ class SafetyCenterTestConfigs(private val context: Context) {
.addSafetySourcesGroup(
safetySourcesGroupBuilder(MULTIPLE_SOURCES_GROUP_ID_1)
.addSafetySource(
- dynamicSafetySourceBuilder(SOURCE_ID_1).setIntentAction("stub").build()
+ dynamicSafetySourceBuilder(SOURCE_ID_1)
+ .setIntentAction(INTENT_ACTION_NOT_RESOLVING)
+ .build()
)
.addSafetySource(dynamicSafetySource(SOURCE_ID_2))
.build()
@@ -373,9 +377,7 @@ class SafetyCenterTestConfigs(private val context: Context) {
* Source group provided by [staticSourcesConfig] containing a single source [staticSource1].
*/
val staticSourceGroup1 =
- SafetySourcesGroup.Builder()
- .setId("test_static_sources_group_id_1")
- .setTitleResId(android.R.string.paste)
+ staticSafetySourcesGroupBuilder("test_static_sources_group_id_1")
.addSafetySource(staticSource1)
.build()
@@ -383,8 +385,7 @@ class SafetyCenterTestConfigs(private val context: Context) {
* Source group provided by [staticSourcesConfig] containing a single source [staticSource2].
*/
val staticSourceGroup2 =
- SafetySourcesGroup.Builder()
- .setId("test_static_sources_group_id_2")
+ staticSafetySourcesGroupBuilder("test_static_sources_group_id_2")
.setTitleResId(android.R.string.copy)
.addSafetySource(staticSource2)
.build()
@@ -402,7 +403,44 @@ class SafetyCenterTestConfigs(private val context: Context) {
* The particular source ID is configured in the same way as sources hosted by the Settings app,
* to launch as if it is part of the Settings app UI.
*/
- val singleStaticSettingsSource = singleSourceConfig(staticSafetySource("TestSource"))
+ val singleStaticSettingsSourceConfig =
+ SafetyCenterConfig.Builder()
+ .addSafetySourcesGroup(
+ staticSafetySourcesGroupBuilder("single_static_source_group")
+ .addSafetySource(staticSafetySource("TestSource"))
+ .build()
+ )
+ .build()
+
+ /** A [SafetyCenterConfig] with a single static source and an intent that doesn't resolve */
+ val singleStaticInvalidIntentConfig =
+ SafetyCenterConfig.Builder()
+ .addSafetySourcesGroup(
+ staticSafetySourcesGroupBuilder("single_static_source_group")
+ .addSafetySource(
+ staticSafetySourceBuilder(SINGLE_SOURCE_ID)
+ .setIntentAction(INTENT_ACTION_NOT_RESOLVING)
+ .build()
+ )
+ .build()
+ )
+ .build()
+
+ /**
+ * A [SafetyCenterConfig] with a single static source and an implicit intent that isn't exported
+ */
+ val singleStaticImplicitIntentNotExportedConfig =
+ SafetyCenterConfig.Builder()
+ .addSafetySourcesGroup(
+ staticSafetySourcesGroupBuilder("single_static_source_group")
+ .addSafetySource(
+ staticSafetySourceBuilder(SINGLE_SOURCE_ID)
+ .setIntentAction(ACTION_TEST_ACTIVITY)
+ .build()
+ )
+ .build()
+ )
+ .build()
/** [SafetyCenterConfig] used in tests for Your Work Policy Info source. */
val workPolicyInfoConfig =
@@ -750,7 +788,7 @@ class SafetyCenterTestConfigs(private val context: Context) {
.setId(id)
.setTitleResId(android.R.string.ok)
.setSummaryResId(android.R.string.ok)
- .setIntentAction(ACTION_TEST_ACTIVITY)
+ .setIntentAction(ACTION_TEST_ACTIVITY_EXPORTED)
.setProfile(SafetySource.PROFILE_PRIMARY)
private fun staticAllProfileSafetySourceBuilder(id: String) =
@@ -780,6 +818,9 @@ class SafetyCenterTestConfigs(private val context: Context) {
.setTitleResId(android.R.string.ok)
.setSummaryResId(android.R.string.ok)
+ private fun staticSafetySourcesGroupBuilder(id: String) =
+ SafetySourcesGroup.Builder().setId(id).setTitleResId(android.R.string.paste)
+
fun singleSourceConfig(safetySource: SafetySource) =
SafetyCenterConfig.Builder()
.addSafetySourcesGroup(
@@ -1033,5 +1074,7 @@ class SafetyCenterTestConfigs(private val context: Context) {
* [privacySubpageWithoutDataSourcesConfig], to replicate the privacy sources group.
*/
const val ANDROID_PRIVACY_SOURCES_GROUP_ID = "AndroidPrivacySources"
+
+ private const val INTENT_ACTION_NOT_RESOLVING = "there.is.no.way.this.resolves"
}
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestData.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestData.kt
index 5ff42e23c..289bc32a8 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestData.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestData.kt
@@ -48,7 +48,7 @@ import com.android.safetycenter.internaldata.SafetyCenterIds
import com.android.safetycenter.internaldata.SafetyCenterIssueActionId
import com.android.safetycenter.internaldata.SafetyCenterIssueId
import com.android.safetycenter.internaldata.SafetyCenterIssueKey
-import com.android.safetycenter.resources.SafetyCenterResourcesContext
+import com.android.safetycenter.resources.SafetyCenterResourcesApk
import com.android.safetycenter.testing.SafetyCenterTestConfigs.Companion.SINGLE_SOURCE_GROUP_ID
import com.android.safetycenter.testing.SafetySourceTestData.Companion.CRITICAL_ISSUE_ACTION_ID
import com.android.safetycenter.testing.SafetySourceTestData.Companion.CRITICAL_ISSUE_ID
@@ -66,23 +66,24 @@ import java.util.Locale
@RequiresApi(TIRAMISU)
class SafetyCenterTestData(context: Context) {
- private val safetyCenterResourcesContext = SafetyCenterResourcesContext.forTests(context)
+ private val safetyCenterResourcesApk = SafetyCenterResourcesApk.forTests(context)
private val safetySourceTestData = SafetySourceTestData(context)
/**
* The [SafetyCenterStatus] used when the overall status is unknown and no scan is in progress.
*/
- val safetyCenterStatusUnknown =
- SafetyCenterStatus.Builder(
- safetyCenterResourcesContext.getStringByName(
- "overall_severity_level_ok_review_title"
- ),
- safetyCenterResourcesContext.getStringByName(
- "overall_severity_level_ok_review_summary"
+ val safetyCenterStatusUnknown: SafetyCenterStatus
+ get() =
+ SafetyCenterStatus.Builder(
+ safetyCenterResourcesApk.getStringByName(
+ "overall_severity_level_ok_review_title"
+ ),
+ safetyCenterResourcesApk.getStringByName(
+ "overall_severity_level_ok_review_summary"
+ )
)
- )
- .setSeverityLevel(OVERALL_SEVERITY_LEVEL_UNKNOWN)
- .build()
+ .setSeverityLevel(OVERALL_SEVERITY_LEVEL_UNKNOWN)
+ .build()
/**
* Returns a [SafetyCenterStatus] with one alert and the given [statusResource] and
@@ -103,7 +104,7 @@ class SafetyCenterTestData(context: Context) {
numAlerts: Int,
): SafetyCenterStatus =
SafetyCenterStatus.Builder(
- safetyCenterResourcesContext.getStringByName(statusResource),
+ safetyCenterResourcesApk.getStringByName(statusResource),
getAlertString(numAlerts)
)
.setSeverityLevel(overallSeverityLevel)
@@ -117,11 +118,8 @@ class SafetyCenterTestData(context: Context) {
numTipIssues: Int,
): SafetyCenterStatus =
SafetyCenterStatus.Builder(
- safetyCenterResourcesContext.getStringByName("overall_severity_level_ok_title"),
- safetyCenterResourcesContext.getStringByName(
- "overall_severity_level_tip_summary",
- numTipIssues
- )
+ safetyCenterResourcesApk.getStringByName("overall_severity_level_ok_title"),
+ getIcuPluralsString("overall_severity_level_tip_summary", numTipIssues)
)
.setSeverityLevel(OVERALL_SEVERITY_LEVEL_OK)
.build()
@@ -134,8 +132,8 @@ class SafetyCenterTestData(context: Context) {
numAutomaticIssues: Int,
): SafetyCenterStatus =
SafetyCenterStatus.Builder(
- safetyCenterResourcesContext.getStringByName("overall_severity_level_ok_title"),
- safetyCenterResourcesContext.getStringByName(
+ safetyCenterResourcesApk.getStringByName("overall_severity_level_ok_title"),
+ getIcuPluralsString(
"overall_severity_level_action_taken_summary",
numAutomaticIssues
)
@@ -149,7 +147,7 @@ class SafetyCenterTestData(context: Context) {
*/
fun safetyCenterStatusCritical(numAlerts: Int) =
SafetyCenterStatus.Builder(
- safetyCenterResourcesContext.getStringByName(
+ safetyCenterResourcesApk.getStringByName(
"overall_severity_level_critical_safety_warning_title"
),
getAlertString(numAlerts)
@@ -166,7 +164,8 @@ class SafetyCenterTestData(context: Context) {
sourceId: String,
userId: Int = UserHandle.myUserId(),
title: CharSequence = "OK",
- pendingIntent: PendingIntent? = safetySourceTestData.testActivityRedirectPendingIntent
+ pendingIntent: PendingIntent? =
+ safetySourceTestData.createTestActivityRedirectPendingIntent()
) =
SafetyCenterEntry.Builder(entryId(sourceId, userId), title)
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_UNKNOWN)
@@ -183,7 +182,8 @@ class SafetyCenterTestData(context: Context) {
sourceId: String,
userId: Int = UserHandle.myUserId(),
title: CharSequence = "OK",
- pendingIntent: PendingIntent? = safetySourceTestData.testActivityRedirectPendingIntent
+ pendingIntent: PendingIntent? =
+ safetySourceTestData.createTestActivityRedirectPendingIntent()
) = safetyCenterEntryDefaultBuilder(sourceId, userId, title, pendingIntent).build()
/**
@@ -199,7 +199,9 @@ class SafetyCenterTestData(context: Context) {
SafetyCenterEntry.Builder(entryId(sourceId, userId), title)
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_UNSPECIFIED)
.setSummary("OK")
- .setPendingIntent(safetySourceTestData.testActivityRedirectPendingIntent)
+ .setPendingIntent(
+ safetySourceTestData.createTestActivityRedirectPendingIntent(explicit = false)
+ )
.setSeverityUnspecifiedIconType(SEVERITY_UNSPECIFIED_ICON_TYPE_NO_ICON)
/**
@@ -216,7 +218,8 @@ class SafetyCenterTestData(context: Context) {
*/
fun safetyCenterEntryUnspecified(
sourceId: String,
- pendingIntent: PendingIntent? = safetySourceTestData.testActivityRedirectPendingIntent
+ pendingIntent: PendingIntent? =
+ safetySourceTestData.createTestActivityRedirectPendingIntent()
) =
SafetyCenterEntry.Builder(entryId(sourceId), "Unspecified title")
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_UNSPECIFIED)
@@ -239,7 +242,7 @@ class SafetyCenterTestData(context: Context) {
SafetyCenterEntry.Builder(entryId(sourceId, userId), title)
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_OK)
.setSummary("Ok summary")
- .setPendingIntent(safetySourceTestData.testActivityRedirectPendingIntent)
+ .setPendingIntent(safetySourceTestData.createTestActivityRedirectPendingIntent())
.setSeverityUnspecifiedIconType(SEVERITY_UNSPECIFIED_ICON_TYPE_NO_RECOMMENDATION)
/**
@@ -264,7 +267,7 @@ class SafetyCenterTestData(context: Context) {
SafetyCenterEntry.Builder(entryId(sourceId), "Recommendation title")
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_RECOMMENDATION)
.setSummary(summary)
- .setPendingIntent(safetySourceTestData.testActivityRedirectPendingIntent)
+ .setPendingIntent(safetySourceTestData.createTestActivityRedirectPendingIntent())
.setSeverityUnspecifiedIconType(SEVERITY_UNSPECIFIED_ICON_TYPE_NO_RECOMMENDATION)
.build()
@@ -276,7 +279,7 @@ class SafetyCenterTestData(context: Context) {
SafetyCenterEntry.Builder(entryId(sourceId), "Critical title")
.setSeverityLevel(ENTRY_SEVERITY_LEVEL_CRITICAL_WARNING)
.setSummary("Critical summary")
- .setPendingIntent(safetySourceTestData.testActivityRedirectPendingIntent)
+ .setPendingIntent(safetySourceTestData.createTestActivityRedirectPendingIntent())
.setSeverityUnspecifiedIconType(SEVERITY_UNSPECIFIED_ICON_TYPE_NO_RECOMMENDATION)
.build()
@@ -307,7 +310,7 @@ class SafetyCenterTestData(context: Context) {
userId
),
"Review",
- safetySourceTestData.testActivityRedirectPendingIntent
+ safetySourceTestData.createTestActivityRedirectPendingIntent()
)
.build()
)
@@ -347,7 +350,7 @@ class SafetyCenterTestData(context: Context) {
userId
),
"See issue",
- safetySourceTestData.testActivityRedirectPendingIntent
+ safetySourceTestData.createTestActivityRedirectPendingIntent()
)
.apply {
if (confirmationDialog && SdkLevel.isAtLeastU()) {
@@ -428,7 +431,7 @@ class SafetyCenterTestData(context: Context) {
private fun getIcuPluralsString(name: String, count: Int, vararg formatArgs: Any): String {
val messageFormat =
MessageFormat(
- safetyCenterResourcesContext.getStringByName(name, formatArgs),
+ safetyCenterResourcesApk.getStringByName(name, formatArgs),
Locale.getDefault()
)
val arguments = ArrayMap<String, Any>()
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestHelper.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestHelper.kt
index 82f7326fd..2902cdd6a 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestHelper.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestHelper.kt
@@ -27,6 +27,7 @@ import android.safetycenter.SafetyEvent
import android.safetycenter.SafetySourceData
import android.safetycenter.config.SafetyCenterConfig
import android.safetycenter.config.SafetySource.SAFETY_SOURCE_TYPE_STATIC
+import android.util.Log
import androidx.annotation.RequiresApi
import com.android.safetycenter.testing.SafetyCenterApisWithShellPermissions.addOnSafetyCenterDataChangedListenerWithPermission
import com.android.safetycenter.testing.SafetyCenterApisWithShellPermissions.clearAllSafetySourceDataForTestsWithPermission
@@ -44,7 +45,7 @@ import com.google.common.util.concurrent.MoreExecutors.directExecutor
/** A class that facilitates settings up Safety Center in tests. */
@RequiresApi(TIRAMISU)
-class SafetyCenterTestHelper(private val context: Context) {
+class SafetyCenterTestHelper(val context: Context) {
private val safetyCenterManager = context.getSystemService(SafetyCenterManager::class.java)!!
private val userManager = context.getSystemService(UserManager::class.java)!!
@@ -55,14 +56,17 @@ class SafetyCenterTestHelper(private val context: Context) {
* values. To be called before each test.
*/
fun setup() {
- SafetySourceReceiver.setup()
+ Log.d(TAG, "setup")
Coroutines.enableDebugging()
+ SafetySourceReceiver.setup()
+ TestActivity.enableHighPriorityAlias()
SafetyCenterFlags.setup()
setEnabled(true)
}
/** Resets the state of Safety Center. To be called after each test. */
fun reset() {
+ Log.d(TAG, "reset")
setEnabled(true)
listeners.forEach {
safetyCenterManager.removeOnSafetyCenterDataChangedListenerWithPermission(it)
@@ -72,12 +76,14 @@ class SafetyCenterTestHelper(private val context: Context) {
safetyCenterManager.clearAllSafetySourceDataForTestsWithPermission()
safetyCenterManager.clearSafetyCenterConfigForTestsWithPermission()
resetFlags()
+ TestActivity.disableHighPriorityAlias()
SafetySourceReceiver.reset()
Coroutines.resetDebugging()
}
/** Enables or disables SafetyCenter based on [value]. */
fun setEnabled(value: Boolean) {
+ Log.d(TAG, "setEnabled to $value")
val safetyCenterConfig = safetyCenterManager.getSafetyCenterConfigWithPermission()
if (safetyCenterConfig == null) {
// No broadcasts are dispatched when toggling the flag when SafetyCenter is not
@@ -87,8 +93,8 @@ class SafetyCenterTestHelper(private val context: Context) {
SafetyCenterFlags.isEnabled = value
return
}
- val currentValue = safetyCenterManager.isSafetyCenterEnabledWithPermission()
- if (currentValue == value) {
+ if (value == isEnabled()) {
+ Log.d(TAG, "isEnabled is already $value")
return
}
setEnabledWaitingForSafetyCenterBroadcastIdle(value, safetyCenterConfig)
@@ -96,6 +102,7 @@ class SafetyCenterTestHelper(private val context: Context) {
/** Sets the given [SafetyCenterConfig]. */
fun setConfig(config: SafetyCenterConfig) {
+ Log.d(TAG, "setConfig")
require(isEnabled())
safetyCenterManager.setSafetyCenterConfigForTestsWithPermission(config)
}
@@ -107,6 +114,7 @@ class SafetyCenterTestHelper(private val context: Context) {
* initial SafetyCenter update
*/
fun addListener(skipInitialData: Boolean = true): SafetyCenterTestListener {
+ Log.d(TAG, "addListener")
require(isEnabled())
val listener = SafetyCenterTestListener()
safetyCenterManager.addOnSafetyCenterDataChangedListenerWithPermission(
@@ -126,6 +134,7 @@ class SafetyCenterTestHelper(private val context: Context) {
safetySourceData: SafetySourceData?,
safetyEvent: SafetyEvent = EVENT_SOURCE_STATE_CHANGED
) {
+ Log.d(TAG, "setData for $safetySourceId")
require(isEnabled())
safetyCenterManager.setSafetySourceDataWithPermission(
safetySourceId,
@@ -137,6 +146,8 @@ class SafetyCenterTestHelper(private val context: Context) {
/** Dismisses the [SafetyCenterIssue] for the given [safetyCenterIssueId]. */
@RequiresApi(UPSIDE_DOWN_CAKE)
fun dismissSafetyCenterIssue(safetyCenterIssueId: String) {
+ Log.d(TAG, "dismissSafetyCenterIssue")
+ require(isEnabled())
safetyCenterManager.dismissSafetyCenterIssueWithPermission(safetyCenterIssueId)
}
@@ -155,6 +166,7 @@ class SafetyCenterTestHelper(private val context: Context) {
// Wait for all ACTION_SAFETY_CENTER_ENABLED_CHANGED broadcasts to be dispatched to
// avoid them leaking onto other tests.
if (safetyCenterConfig.containsTestSource()) {
+ Log.d(TAG, "Waiting for test source enabled changed broadcast")
SafetySourceReceiver.receiveSafetyCenterEnabledChanged()
// The explicit ACTION_SAFETY_CENTER_ENABLED_CHANGED broadcast is also sent to the
// dynamically registered receivers.
@@ -166,6 +178,7 @@ class SafetyCenterTestHelper(private val context: Context) {
// 2: test finishes, 3: new test starts, 4: a test config is set, 5: broadcast from 1
// dispatched).
if (userManager.isSystemUser) {
+ Log.d(TAG, "Waiting for system enabled changed broadcast")
// The implicit broadcast is only sent to the system user.
enabledChangedReceiver.receiveSafetyCenterEnabledChanged()
}
@@ -188,4 +201,8 @@ class SafetyCenterTestHelper(private val context: Context) {
.any { it.packageName == context.packageName }
private fun isEnabled() = safetyCenterManager.isSafetyCenterEnabledWithPermission()
+
+ private companion object {
+ const val TAG: String = "SafetyCenterTestHelper"
+ }
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestRule.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestRule.kt
new file mode 100644
index 000000000..dcbc4ebe9
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetyCenterTestRule.kt
@@ -0,0 +1,57 @@
+/*
+ * 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 org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/** A JUnit [TestRule] that performs setup and reset steps before and after Safety Center tests. */
+class SafetyCenterTestRule(
+ private val safetyCenterTestHelper: SafetyCenterTestHelper,
+ private val withNotifications: Boolean = false
+) : TestRule {
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ override fun evaluate() {
+ setup()
+ try {
+ base.evaluate()
+ } finally {
+ reset()
+ }
+ }
+ }
+ }
+
+ private fun setup() {
+ safetyCenterTestHelper.setup()
+ if (withNotifications) {
+ TestNotificationListener.setup(safetyCenterTestHelper.context)
+ }
+ }
+
+ private fun reset() {
+ safetyCenterTestHelper.reset()
+ if (withNotifications) {
+ // It is important to reset the notification listener last because it waits/ensures that
+ // all notifications have been removed before returning.
+ TestNotificationListener.reset(safetyCenterTestHelper.context)
+ }
+ }
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceIntentHandler.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceIntentHandler.kt
index 2bd662ee8..8386228b8 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceIntentHandler.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceIntentHandler.kt
@@ -221,7 +221,7 @@ class SafetySourceIntentHandler {
safetyEventForResponse: (Response) -> SafetyEvent
) {
val response = mutex.withLock { requestsToResponses[request] } ?: return
- val safetyEvent = safetyEventForResponse(response)
+ val safetyEvent = response.overrideSafetyEvent ?: safetyEventForResponse(response)
when (response) {
is Response.Error ->
reportSafetySourceError(request.sourceId, SafetySourceErrorDetails(safetyEvent))
@@ -270,6 +270,13 @@ class SafetySourceIntentHandler {
*/
sealed interface Response {
+ /**
+ * If non-null, the [SafetyEvent] to use when calling any applicable [SafetyCenterManager]
+ * methods.
+ */
+ val overrideSafetyEvent: SafetyEvent?
+ get() = null
+
/** Creates an error [Response]. */
object Error : Response
@@ -282,10 +289,13 @@ class SafetySourceIntentHandler {
* @param overrideBroadcastId an optional override of the broadcast id to use in the
* [SafetyEvent] sent to the [SafetyCenterManager], in case of [Request.Refresh] or
* [Request.Rescan]. This is used to simulate a misuse of the [SafetyCenterManager] APIs
+ * @param overrideSafetyEvent like [overrideBroadcastId] but allows the whole [SafetyEvent]
+ * to be override to send different types of [SafetyEvent].
*/
data class SetData(
val safetySourceData: SafetySourceData,
- val overrideBroadcastId: String? = null
+ val overrideBroadcastId: String? = null,
+ override val overrideSafetyEvent: SafetyEvent? = null
) : Response
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceReceiver.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceReceiver.kt
index 2ba87040a..29072c989 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceReceiver.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceReceiver.kt
@@ -35,8 +35,8 @@ import android.safetycenter.SafetyCenterManager
import android.safetycenter.SafetyCenterManager.ACTION_SAFETY_CENTER_ENABLED_CHANGED
import androidx.annotation.RequiresApi
import androidx.test.core.app.ApplicationProvider
-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.SafetyCenterApisWithShellPermissions.dismissSafetyCenterIssueWithPermission
import com.android.safetycenter.testing.SafetyCenterApisWithShellPermissions.executeSafetyCenterIssueActionWithPermission
@@ -164,46 +164,38 @@ class SafetySourceReceiver : BroadcastReceiver() {
fun SafetyCenterManager.refreshSafetySourcesWithReceiverPermissionAndWait(
refreshReason: Int,
- timeout: Duration = TIMEOUT_LONG,
- safetySourceIds: List<String>? = null
- ) =
+ safetySourceIds: List<String>? = null,
+ timeout: Duration = TIMEOUT_LONG
+ ): String =
callWithShellPermissionIdentity(SEND_SAFETY_CENTER_UPDATE) {
- refreshSafetySourcesWithoutReceiverPermissionAndWait(
- refreshReason,
- timeout,
- safetySourceIds
- )
+ refreshSafetySourcesWithPermission(refreshReason, safetySourceIds)
+ receiveRefreshSafetySources(timeout)
}
fun SafetyCenterManager.refreshSafetySourcesWithoutReceiverPermissionAndWait(
refreshReason: Int,
- timeout: Duration,
safetySourceIds: List<String>? = null
- ): String {
+ ) {
refreshSafetySourcesWithPermission(refreshReason, safetySourceIds)
- if (timeout < TIMEOUT_LONG) {
- SystemUtil.waitForBroadcasts()
- }
- return receiveRefreshSafetySources(timeout)
+ WaitForBroadcasts.waitForBroadcasts()
+ receiveRefreshSafetySources(TIMEOUT_SHORT)
}
fun setSafetyCenterEnabledWithReceiverPermissionAndWait(
value: Boolean,
timeout: Duration = TIMEOUT_LONG
- ) =
+ ): Boolean =
callWithShellPermissionIdentity(SEND_SAFETY_CENTER_UPDATE) {
- setSafetyCenterEnabledWithoutReceiverPermissionAndWait(value, timeout)
+ SafetyCenterFlags.isEnabled = value
+ receiveSafetyCenterEnabledChanged(timeout)
}
fun setSafetyCenterEnabledWithoutReceiverPermissionAndWait(
value: Boolean,
- timeout: Duration = TIMEOUT_LONG
- ): Boolean {
+ ) {
SafetyCenterFlags.isEnabled = value
- if (timeout < TIMEOUT_LONG) {
- SystemUtil.waitForBroadcasts()
- }
- return receiveSafetyCenterEnabledChanged(timeout)
+ WaitForBroadcasts.waitForBroadcasts()
+ receiveSafetyCenterEnabledChanged(TIMEOUT_SHORT)
}
fun SafetyCenterManager.executeSafetyCenterIssueActionWithPermissionAndWait(
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceTestData.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceTestData.kt
index 97e2078e0..2c4f856bb 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceTestData.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SafetySourceTestData.kt
@@ -20,7 +20,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_RECEIVER_FOREGROUND
-import android.content.pm.PackageManager.ResolveInfoFlags
import android.os.Build.VERSION_CODES.TIRAMISU
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import android.safetycenter.SafetyEvent
@@ -39,13 +38,13 @@ import android.safetycenter.SafetySourceStatus.IconAction.ICON_TYPE_INFO
import androidx.annotation.RequiresApi
import com.android.modules.utils.build.SdkLevel
import com.android.safetycenter.testing.SafetyCenterTestConfigs.Companion.ACTION_TEST_ACTIVITY
+import com.android.safetycenter.testing.SafetyCenterTestConfigs.Companion.ACTION_TEST_ACTIVITY_EXPORTED
import com.android.safetycenter.testing.SafetyCenterTestConfigs.Companion.SINGLE_SOURCE_ID
import com.android.safetycenter.testing.SafetySourceIntentHandler.Companion.ACTION_DISMISS_ISSUE
import com.android.safetycenter.testing.SafetySourceIntentHandler.Companion.ACTION_RESOLVE_ACTION
import com.android.safetycenter.testing.SafetySourceIntentHandler.Companion.EXTRA_SOURCE_ID
import com.android.safetycenter.testing.SafetySourceIntentHandler.Companion.EXTRA_SOURCE_ISSUE_ACTION_ID
import com.android.safetycenter.testing.SafetySourceIntentHandler.Companion.EXTRA_SOURCE_ISSUE_ID
-import java.lang.IllegalStateException
import kotlin.math.max
/**
@@ -55,16 +54,21 @@ import kotlin.math.max
@RequiresApi(TIRAMISU)
class SafetySourceTestData(private val context: Context) {
- /** A [PendingIntent] that redirects to the [TestActivity] page. */
- val testActivityRedirectPendingIntent =
- createRedirectPendingIntent(context, Intent(ACTION_TEST_ACTIVITY))
-
/**
- * A [PendingIntent] that redirects to the [TestActivity] page, the [Intent] is constructed with
- * the given [identifier].
+ * A [PendingIntent] that redirects to the [TestActivity] page.
+ *
+ * @param explicit whether the returned [PendingIntent] should use an explicit [Intent] (default
+ * [true])
+ * @param identifier the [Intent] identifier (default [null])
*/
- fun testActivityRedirectPendingIntent(identifier: String? = null) =
- createRedirectPendingIntent(context, Intent(ACTION_TEST_ACTIVITY).setIdentifier(identifier))
+ fun createTestActivityRedirectPendingIntent(
+ explicit: Boolean = true,
+ identifier: String? = null
+ ) =
+ createRedirectPendingIntent(
+ context,
+ createTestActivityIntent(context, explicit).setIdentifier(identifier)
+ )
/** A [SafetySourceData] with a [SEVERITY_LEVEL_UNSPECIFIED] [SafetySourceStatus]. */
val unspecified =
@@ -93,7 +97,7 @@ class SafetySourceTestData(private val context: Context) {
SEVERITY_LEVEL_UNSPECIFIED
)
.setEnabled(false)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.build()
@@ -110,14 +114,14 @@ class SafetySourceTestData(private val context: Context) {
summary: String = "Information issue summary"
) =
SafetySourceIssue.Builder(id, title, summary, SEVERITY_LEVEL_INFORMATION, ISSUE_TYPE_ID)
- .addAction(
- Action.Builder(
- INFORMATION_ISSUE_ACTION_ID,
- "Review",
- testActivityRedirectPendingIntent
- )
- .build()
- )
+ .addAction(action())
+
+ /** Creates an action with some defaults set. */
+ fun action(
+ id: String = INFORMATION_ISSUE_ACTION_ID,
+ label: String = "Review",
+ pendingIntent: PendingIntent = createTestActivityRedirectPendingIntent()
+ ) = Action.Builder(id, label, pendingIntent).build()
/**
* A [SafetySourceIssue] with a [SEVERITY_LEVEL_INFORMATION] and a redirecting [Action]. With
@@ -132,14 +136,7 @@ class SafetySourceTestData(private val context: Context) {
ISSUE_TYPE_ID
)
.setSubtitle("Information issue subtitle")
- .addAction(
- Action.Builder(
- INFORMATION_ISSUE_ACTION_ID,
- "Review",
- testActivityRedirectPendingIntent
- )
- .build()
- )
+ .addAction(action())
.build()
/**
@@ -154,7 +151,7 @@ class SafetySourceTestData(private val context: Context) {
"Unspecified summary",
SEVERITY_LEVEL_UNSPECIFIED
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(informationIssue)
@@ -172,7 +169,7 @@ class SafetySourceTestData(private val context: Context) {
"Unspecified summary",
SEVERITY_LEVEL_UNSPECIFIED
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(informationIssue)
@@ -183,7 +180,7 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.build()
@@ -209,8 +206,10 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
- .setIconAction(IconAction(ICON_TYPE_INFO, testActivityRedirectPendingIntent))
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
+ .setIconAction(
+ IconAction(ICON_TYPE_INFO, createTestActivityRedirectPendingIntent())
+ )
.build()
)
.build()
@@ -223,8 +222,10 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
- .setIconAction(IconAction(ICON_TYPE_GEAR, testActivityRedirectPendingIntent))
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
+ .setIconAction(
+ IconAction(ICON_TYPE_GEAR, createTestActivityRedirectPendingIntent())
+ )
.build()
)
.build()
@@ -237,7 +238,7 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(informationIssue)
@@ -253,7 +254,7 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(
@@ -275,7 +276,7 @@ class SafetySourceTestData(private val context: Context) {
"Ok summary",
SEVERITY_LEVEL_INFORMATION
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(informationIssue)
@@ -289,7 +290,7 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder("Ok title", "Ok summary", SEVERITY_LEVEL_INFORMATION)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(informationIssueWithSubtitle)
@@ -315,7 +316,7 @@ class SafetySourceTestData(private val context: Context) {
Action.Builder(
RECOMMENDATION_ISSUE_ACTION_ID,
"See issue",
- testActivityRedirectPendingIntent
+ createTestActivityRedirectPendingIntent()
)
.apply {
if (confirmationDialog && SdkLevel.isAtLeastU()) {
@@ -383,7 +384,7 @@ class SafetySourceTestData(private val context: Context) {
"Recommendation summary",
SEVERITY_LEVEL_RECOMMENDATION
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
@@ -425,12 +426,25 @@ class SafetySourceTestData(private val context: Context) {
.build()
/** A [PendingIntent] used by the resolving [Action] in [criticalResolvingGeneralIssue]. */
- val criticalIssueActionPendingIntent =
+ val criticalIssueActionPendingIntent = resolvingActionPendingIntent()
+
+ /**
+ * Returns a [PendingIntent] for a resolving [Action] with the given [sourceId], [sourceIssueId]
+ * and [sourceIssueActionId]. Default values are the same as those used by
+ * [criticalIssueActionPendingIntent]. *
+ */
+ fun resolvingActionPendingIntent(
+ sourceId: String = SINGLE_SOURCE_ID,
+ sourceIssueId: String = CRITICAL_ISSUE_ID,
+ sourceIssueActionId: String = CRITICAL_ISSUE_ACTION_ID
+ ) =
broadcastPendingIntent(
Intent(ACTION_RESOLVE_ACTION)
- .putExtra(EXTRA_SOURCE_ID, SINGLE_SOURCE_ID)
- .putExtra(EXTRA_SOURCE_ISSUE_ID, CRITICAL_ISSUE_ID)
- .putExtra(EXTRA_SOURCE_ISSUE_ACTION_ID, CRITICAL_ISSUE_ACTION_ID)
+ .putExtra(EXTRA_SOURCE_ID, sourceId)
+ .putExtra(EXTRA_SOURCE_ISSUE_ID, sourceIssueId)
+ .putExtra(EXTRA_SOURCE_ISSUE_ACTION_ID, sourceIssueActionId)
+ // Identifier is set because intent extras do not disambiguate PendingIntents
+ .setIdentifier(sourceId + sourceIssueId + sourceIssueActionId)
)
/** A resolving Critical [Action] */
@@ -454,7 +468,11 @@ class SafetySourceTestData(private val context: Context) {
/** An action that redirects to [TestActivity] */
val testActivityRedirectAction =
- Action.Builder(CRITICAL_ISSUE_ACTION_ID, "Redirect", testActivityRedirectPendingIntent)
+ Action.Builder(
+ CRITICAL_ISSUE_ACTION_ID,
+ "Redirect",
+ createTestActivityRedirectPendingIntent()
+ )
.build()
/** A resolving Critical [Action] that declares a success message */
@@ -492,7 +510,7 @@ class SafetySourceTestData(private val context: Context) {
Action.Builder(
CRITICAL_ISSUE_ACTION_ID,
"Go solve issue",
- testActivityRedirectPendingIntent
+ createTestActivityRedirectPendingIntent()
)
.build()
)
@@ -571,7 +589,7 @@ class SafetySourceTestData(private val context: Context) {
"Critical summary",
SEVERITY_LEVEL_CRITICAL_WARNING
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
@@ -685,7 +703,7 @@ class SafetySourceTestData(private val context: Context) {
"Critical summary",
SEVERITY_LEVEL_CRITICAL_WARNING
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(criticalResolvingIssueWithSuccessMessage)
@@ -710,7 +728,7 @@ class SafetySourceTestData(private val context: Context) {
"Critical summary 2",
SEVERITY_LEVEL_CRITICAL_WARNING
)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.addIssue(criticalRedirectingIssue)
@@ -729,7 +747,7 @@ class SafetySourceTestData(private val context: Context) {
SafetySourceData.Builder()
.setStatus(
SafetySourceStatus.Builder(entryTitle, entrySummary, severityLevel)
- .setPendingIntent(testActivityRedirectPendingIntent)
+ .setPendingIntent(createTestActivityRedirectPendingIntent())
.build()
)
.apply {
@@ -746,7 +764,7 @@ class SafetySourceTestData(private val context: Context) {
Action.Builder(
"action_id",
"Action",
- testActivityRedirectPendingIntent
+ createTestActivityRedirectPendingIntent()
)
.build()
)
@@ -811,28 +829,28 @@ class SafetySourceTestData(private val context: Context) {
return builder.build()
}
- /** Returns a [PendingIntent] that redirects to [intent]. */
+ /** Returns an [Intent] that redirects to the [TestActivity] page. */
+ fun createTestActivityIntent(context: Context, explicit: Boolean = true): Intent =
+ if (explicit) {
+ Intent(ACTION_TEST_ACTIVITY).setPackage(context.packageName)
+ } else {
+ val intent = Intent(ACTION_TEST_ACTIVITY_EXPORTED)
+ // We have seen some flakiness where implicit intents find multiple receivers
+ // and the ResolveActivity pops up. A test cannot handle this, so crash. Most
+ // likely the cause is other test's APKs being left hanging around by flaky
+ // test infrastructure.
+ intent.flags = intent.flags or Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT
+ intent
+ }
+
+ /** Returns a [PendingIntent] that redirects to the given [Intent]. */
fun createRedirectPendingIntent(context: Context, intent: Intent): PendingIntent {
- val explicitIntent = Intent(intent).setPackage(context.packageName)
- val redirectIntent =
- if (intentResolves(context, explicitIntent)) {
- explicitIntent
- } else if (intentResolves(context, intent)) {
- intent
- } else {
- throw IllegalStateException("Intent doesn't resolve")
- }
return PendingIntent.getActivity(
context,
0 /* requestCode */,
- redirectIntent,
+ intent,
PendingIntent.FLAG_IMMUTABLE
)
}
-
- private fun intentResolves(context: Context, intent: Intent): Boolean =
- context.packageManager
- .queryIntentActivities(intent, ResolveInfoFlags.of(0))
- .isNotEmpty()
}
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/StatusBarNotificationWithChannel.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/StatusBarNotificationWithChannel.kt
new file mode 100644
index 000000000..53ea34362
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/StatusBarNotificationWithChannel.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.service.notification.StatusBarNotification
+
+/** Tuple of [StatusBarNotification] and the [NotificationChannel] it was posted to. */
+data class StatusBarNotificationWithChannel(
+ val statusBarNotification: StatusBarNotification,
+ val channel: NotificationChannel
+)
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenter.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenter.kt
new file mode 100644
index 000000000..bfb5c4bd7
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenter.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.content.Context
+import android.content.res.Resources
+
+/**
+ * Returns whether the device supports Safety Center according to the `config_enableSafetyCenter`
+ * boolean system resource.
+ */
+fun Context.deviceSupportsSafetyCenter(): Boolean {
+ val resId = Resources.getSystem().getIdentifier("config_enableSafetyCenter", "bool", "android")
+ return resources.getBoolean(resId)
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenterRule.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenterRule.kt
new file mode 100644
index 000000000..7227873ff
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/SupportsSafetyCenterRule.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.content.Context
+import org.junit.Assume.assumeFalse
+import org.junit.Assume.assumeTrue
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * JUnit [TestRule] for on-device tests that requires Safety Center to be supported. This rule does
+ * not require Safety Center to be enabled.
+ *
+ * For tests which should only run on devices where Safety Center is not supported, instantiate with
+ * [requireSupportIs] set to `false` to invert the condition.
+ */
+class SupportsSafetyCenterRule(private val context: Context, requireSupportIs: Boolean = true) :
+ TestRule {
+
+ private val shouldSupportSafetyCenter: Boolean = requireSupportIs
+
+ override fun apply(base: Statement, description: Description): Statement {
+ return object : Statement() {
+ override fun evaluate() {
+ val support = context.deviceSupportsSafetyCenter()
+ if (shouldSupportSafetyCenter) {
+ assumeTrue("Test device does not support Safety Center", support)
+ } else {
+ assumeFalse("Test device supports Safety Center", support)
+ }
+ base.evaluate()
+ }
+ }
+ }
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestActivity.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestActivity.kt
index 124f44101..eceffb74f 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestActivity.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestActivity.kt
@@ -16,9 +16,15 @@
package com.android.safetycenter.testing
import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED
+import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
+import android.content.pm.PackageManager.DONT_KILL_APP
import android.os.Bundle
import android.view.View
import android.widget.TextView
+import androidx.test.core.app.ApplicationProvider
/** An activity used in tests to assert the redirects. */
class TestActivity : Activity() {
@@ -32,4 +38,32 @@ class TestActivity : Activity() {
val exitButton: View? = findViewById(R.id.button)
exitButton?.setOnClickListener { finish() }
}
+
+ companion object {
+
+ /**
+ * Enable a higher-priority alias of TestActivity.
+ *
+ * <p>We have seen flakes where implicit intents for TEST_ACTIVITY fail owing to multiple
+ * receivers, perhaps due to an older CTS APK hanging around. This component should be
+ * turned on (and off in tidyup) in tests in the hope of only resolving to the actively
+ * running test in these cases.
+ */
+ fun enableHighPriorityAlias() {
+ setAliasEnabledState(COMPONENT_ENABLED_STATE_ENABLED)
+ }
+ /** @see [enableHighPriorityAlias] */
+ fun disableHighPriorityAlias() {
+ setAliasEnabledState(COMPONENT_ENABLED_STATE_DISABLED)
+ }
+ private fun setAliasEnabledState(state: Int) {
+ val name =
+ ComponentName(getApplicationContext(), TestActivity::class.java.name + "Priority")
+ getApplicationContext()
+ .packageManager
+ .setComponentEnabledSetting(name, state, DONT_KILL_APP)
+ }
+
+ private fun getApplicationContext(): Context = ApplicationProvider.getApplicationContext()
+ }
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestNotificationListener.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestNotificationListener.kt
new file mode 100644
index 000000000..2b2342d7a
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/TestNotificationListener.kt
@@ -0,0 +1,393 @@
+/*
+ * 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)
+ }
+ }
+
+ /**
+ * Waits for a success notification with the given [successMessage] after resolving an
+ * issue.
+ *
+ * Additional assertions can be made on the [StatusBarNotification] using [onNotification].
+ */
+ fun waitForSuccessNotification(
+ successMessage: String,
+ onNotification: (StatusBarNotification) -> Unit = {}
+ ) {
+ val successNotificationWithChannel =
+ waitForSingleNotificationMatching(
+ NotificationCharacteristics(
+ successMessage,
+ "",
+ actions = emptyList(),
+ )
+ )
+ val statusBarNotification = successNotificationWithChannel.statusBarNotification
+ onNotification(statusBarNotification)
+ // Cancel the notification directly to speed up the tests as it's only auto-cancelled
+ // after 10 seconds, and the teardown waits for all notifications to be cancelled to
+ // avoid having unrelated notifications leaking between test cases.
+ cancelAndWait(statusBarNotification.key, waitForIssueCache = false)
+ }
+
+ /**
+ * 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_LONG].
+ */
+ fun cancelAndWait(key: String, waitForIssueCache: Boolean = true) {
+ getInstanceOrThrow().cancelNotification(key)
+ waitForNotificationsToSatisfy(
+ timeout = TIMEOUT_LONG,
+ description = "no notification with the key $key"
+ ) { notifications ->
+ notifications.none { it.statusBarNotification.key == key }
+ }
+
+ if (waitForIssueCache) {
+ 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"
+ }
+}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/UiTestHelper.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/UiTestHelper.kt
index ec676e3d9..0e062692a 100644
--- a/tests/utils/safetycenter/java/com/android/safetycenter/testing/UiTestHelper.kt
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/UiTestHelper.kt
@@ -29,10 +29,11 @@ import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.StaleObjectException
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
+import androidx.test.uiautomator.Until
import com.android.compatibility.common.util.SystemUtil.runShellCommand
import com.android.compatibility.common.util.UiAutomatorUtils2.getUiDevice
import com.android.compatibility.common.util.UiAutomatorUtils2.waitFindObject
-import com.android.compatibility.common.util.UiAutomatorUtils2.waitFindObjectOrNull
+import com.android.compatibility.common.util.UiDumpUtils
import java.time.Duration
import java.util.concurrent.TimeoutException
import java.util.regex.Pattern
@@ -46,19 +47,30 @@ object UiTestHelper {
const val MORE_ISSUES_LABEL = "More alerts"
private const val DISMISS_ISSUE_LABEL = "Dismiss"
- private val WAIT_TIMEOUT = Duration.ofSeconds(10)
- private val NOT_DISPLAYED_TIMEOUT = Duration.ofMillis(500)
+ private const val TAG = "SafetyCenterUiTestHelper"
- private val TAG = UiTestHelper::class.java.simpleName
+ private val WAIT_TIMEOUT = Duration.ofSeconds(20)
/**
- * Waits for the given [selector] to be displayed and performs the given [uiObjectAction] on it.
+ * Waits for the given [selector] to be displayed, and optionally perform a given
+ * [uiObjectAction] on it.
*/
fun waitDisplayed(selector: BySelector, uiObjectAction: (UiObject2) -> Unit = {}) {
- waitFor("$selector to be displayed", WAIT_TIMEOUT) {
- uiObjectAction(waitFindObject(selector, it.toMillis()))
- true
+ val whenToTimeout = currentElapsedRealtime() + WAIT_TIMEOUT
+ var remaining = WAIT_TIMEOUT
+ while (remaining > Duration.ZERO) {
+ getUiDevice().waitForIdle()
+ try {
+ uiObjectAction(waitFindObject(selector, remaining.toMillis()))
+ return
+ } catch (e: StaleObjectException) {
+ Log.w(TAG, "Found stale UI object, retrying", e)
+ remaining = whenToTimeout - currentElapsedRealtime()
+ }
}
+ throw UiDumpUtils.wrapWithUiDump(
+ TimeoutException("Timed out waiting for $selector to be displayed after $WAIT_TIMEOUT")
+ )
}
/** Waits for all the given [textToFind] to be displayed. */
@@ -77,16 +89,21 @@ object UiTestHelper {
/** Waits for the given [selector] not to be displayed. */
fun waitNotDisplayed(selector: BySelector) {
- waitFor("$selector not to be displayed", NOT_DISPLAYED_TIMEOUT) {
- waitFindObjectOrNull(selector, it.toMillis()) == null
+ // TODO(b/294038848): Add scrolling to make sure it is properly gone.
+ val gone = getUiDevice().wait(Until.gone(selector), WAIT_TIMEOUT.toMillis())
+ if (gone) {
+ return
}
+ throw UiDumpUtils.wrapWithUiDump(
+ TimeoutException(
+ "Timed out waiting for $selector not to be displayed after $WAIT_TIMEOUT"
+ )
+ )
}
/** Waits for all the given [textToFind] not to be displayed. */
fun waitAllTextNotDisplayed(vararg textToFind: CharSequence?) {
- for (text in textToFind) {
- if (text != null) waitNotDisplayed(By.text(text.toString()))
- }
+ waitNotDisplayed(By.text(anyOf(*textToFind)))
}
/** Waits for a button with the given [label] not to be displayed. */
@@ -101,11 +118,11 @@ object UiTestHelper {
*/
@RequiresApi(TIRAMISU)
fun waitSourceDataDisplayed(sourceData: SafetySourceData) {
- waitAllTextDisplayed(sourceData.status?.title, sourceData.status?.summary)
-
for (sourceIssue in sourceData.issues) {
waitSourceIssueDisplayed(sourceIssue)
}
+
+ waitAllTextDisplayed(sourceData.status?.title, sourceData.status?.summary)
}
/** Waits for most of the [SafetySourceIssue] information to be displayed. */
@@ -131,7 +148,7 @@ object UiTestHelper {
fun waitCollapsedIssuesDisplayed(vararg sourceIssues: SafetySourceIssue) {
waitSourceIssueDisplayed(sourceIssues.first())
waitAllTextDisplayed(MORE_ISSUES_LABEL)
- sourceIssues.asSequence().drop(1).forEach { waitSourceIssueNotDisplayed(it) }
+ waitAllTextNotDisplayed(*sourceIssues.drop(1).map { it.title }.toTypedArray())
}
/** Waits for all the [SafetySourceIssue] to be displayed with the [MORE_ISSUES_LABEL] card. */
@@ -221,35 +238,17 @@ object UiTestHelper {
}
private fun buttonSelector(label: CharSequence): BySelector {
- return By.clickable(true).text(Pattern.compile("$label|${label.toString().uppercase()}"))
+ return By.clickable(true).text(anyOf(label, label.toString().uppercase()))
}
- private fun waitFor(
- message: String,
- uiAutomatorConditionTimeout: Duration,
- uiAutomatorCondition: (Duration) -> Boolean
- ) {
- val elapsedStartMillis = SystemClock.elapsedRealtime()
- while (true) {
- getUiDevice().waitForIdle()
- val durationSinceStart =
- Duration.ofMillis(SystemClock.elapsedRealtime() - elapsedStartMillis)
- if (durationSinceStart >= WAIT_TIMEOUT) {
- break
- }
- val remainingTime = WAIT_TIMEOUT - durationSinceStart
- val uiAutomatorTimeout = minOf(uiAutomatorConditionTimeout, remainingTime)
- try {
- if (uiAutomatorCondition(uiAutomatorTimeout)) {
- return
- } else {
- Log.d(TAG, "Failed condition for $message, will retry if within timeout")
- }
- } catch (e: StaleObjectException) {
- Log.d(TAG, "StaleObjectException for $message, will retry if within timeout", e)
+ private fun anyOf(vararg anyTextToFind: CharSequence?): Pattern {
+ val regex =
+ anyTextToFind.filterNotNull().joinToString(separator = "|") {
+ Pattern.quote(it.toString())
}
- }
-
- throw TimeoutException("Timed out waiting for $message")
+ return Pattern.compile(regex)
}
+
+ private fun currentElapsedRealtime(): Duration =
+ Duration.ofMillis(SystemClock.elapsedRealtime())
}
diff --git a/tests/utils/safetycenter/java/com/android/safetycenter/testing/WaitForBroadcasts.kt b/tests/utils/safetycenter/java/com/android/safetycenter/testing/WaitForBroadcasts.kt
new file mode 100644
index 000000000..3c19ea180
--- /dev/null
+++ b/tests/utils/safetycenter/java/com/android/safetycenter/testing/WaitForBroadcasts.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.util.Log
+import androidx.annotation.GuardedBy
+import com.android.compatibility.common.util.SystemUtil
+import com.android.safetycenter.testing.Coroutines.TIMEOUT_LONG
+import com.android.safetycenter.testing.Coroutines.runBlockingWithTimeout
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/** A class to help waiting on broadcasts to be processed by the system. */
+object WaitForBroadcasts {
+
+ private const val TAG: String = "WaitForBroadcasts"
+
+ private val mutex = Mutex()
+ @GuardedBy("mutex") private var currentJob: Job? = null
+
+ /**
+ * Waits for broadcasts for at most [TIMEOUT_LONG] and prints a warning if that operation timed
+ * out.
+ *
+ * The [SystemUtil.waitForBroadcasts] operation will keep on running even after the timeout as
+ * it is not interruptible. Further calls to [WaitForBroadcasts.waitForBroadcasts] will re-use
+ * the currently running [SystemUtil.waitForBroadcasts] call if it hasn't completed.
+ */
+ fun waitForBroadcasts() {
+ try {
+ runBlockingWithTimeout {
+ mutex
+ .withLock {
+ val newJob = currentJob.maybeStartNewWaitForBroadcasts()
+ currentJob = newJob
+ newJob
+ }
+ .join()
+ }
+ } catch (e: TimeoutCancellationException) {
+ Log.w(TAG, "Waiting for broadcasts timed out, proceeding anyway", e)
+ }
+ }
+
+ // We're using a GlobalScope here as there doesn't seem to be a straightforward way to timeout
+ // and interrupt the waitForBroadcasts() call. Given it's uninterruptible, we'd rather just have
+ // at most one globally-bound waitForBroadcasts() call running at any given time.
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun Job?.maybeStartNewWaitForBroadcasts(): Job =
+ if (this != null && isActive) {
+ this
+ } else {
+ GlobalScope.launch(Dispatchers.IO) { SystemUtil.waitForBroadcasts() }
+ }
+}