diff options
Diffstat (limited to 'tests/utils/safetycenter/java/com/android/safetycenter')
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() } + } +} |