blob: 1d81e4405fb391d93f4427295580c33a5d49fe62 [file] [log] [blame]
/*
* Copyright (C) 2020 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 android.os.cts
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_TOP_SLEEPING
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_AUTO_REVOKE_PERMISSIONS
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_DENIED
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.content.res.Resources
import android.net.Uri
import android.os.Build
import android.os.UserHandle
import android.platform.test.annotations.AppModeFull
import android.provider.DeviceConfig
import android.safetycenter.SafetyCenterIssue
import android.safetycenter.SafetyCenterManager
import android.support.test.uiautomator.By
import android.support.test.uiautomator.BySelector
import android.support.test.uiautomator.UiObject2
import android.support.test.uiautomator.UiObjectNotFoundException
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Switch
import androidx.test.InstrumentationRegistry
import androidx.test.filters.SdkSuppress
import androidx.test.runner.AndroidJUnit4
import com.android.compatibility.common.util.DeviceConfigStateChangerRule
import com.android.compatibility.common.util.DisableAnimationRule
import com.android.compatibility.common.util.FreezeRotationRule
import com.android.compatibility.common.util.MatcherUtils.hasTextThat
import com.android.compatibility.common.util.SystemUtil
import com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity
import com.android.compatibility.common.util.SystemUtil.eventually
import com.android.compatibility.common.util.SystemUtil.getEventually
import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity
import com.android.compatibility.common.util.UI_ROOT
import com.android.compatibility.common.util.click
import com.android.compatibility.common.util.depthFirstSearch
import com.android.compatibility.common.util.uiDump
import com.android.modules.utils.build.SdkLevel
import com.android.safetycenter.internaldata.SafetyCenterIds
import com.android.safetycenter.internaldata.SafetyCenterIssueId
import com.android.safetycenter.internaldata.SafetyCenterIssueKey
import java.lang.reflect.Modifier
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import java.util.regex.Pattern
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.CoreMatchers.containsStringIgnoringCase
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matcher
import org.hamcrest.Matchers.greaterThan
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThat
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeFalse
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private const val READ_CALENDAR = "android.permission.READ_CALENDAR"
private const val BLUETOOTH_CONNECT = "android.permission.BLUETOOTH_CONNECT"
/**
* Test for auto revoke
*/
@RunWith(AndroidJUnit4::class)
class AutoRevokeTest {
private val context: Context = InstrumentationRegistry.getTargetContext()
private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
private val mPermissionControllerResources: Resources = context.createPackageContext(
context.packageManager.permissionControllerPackageName, 0).resources
private lateinit var supportedApkPath: String
private lateinit var supportedAppPackageName: String
private lateinit var preMinVersionApkPath: String
private lateinit var preMinVersionAppPackageName: String
@Rule
@JvmField
val storeExactTimeRule = DeviceConfigStateChangerRule(context,
DeviceConfig.NAMESPACE_PERMISSIONS, STORE_EXACT_TIME_KEY, "true")
companion object {
const val LOG_TAG = "AutoRevokeTest"
private const val STORE_EXACT_TIME_KEY = "permission_changes_store_exact_time"
private const val UNUSED_APPS_SOURCE_ID = "AndroidPermissionAutoRevoke"
private const val UNUSED_APPS_ISSUE_ID = "unused_apps_issue"
@JvmStatic
@BeforeClass
fun beforeAllTests() {
runBootCompleteReceiver(InstrumentationRegistry.getTargetContext(), LOG_TAG)
}
}
@get:Rule
val disableAnimationRule = DisableAnimationRule()
@get:Rule
val freezeRotationRule = FreezeRotationRule()
@Before
fun setup() {
// Collapse notifications
assertThat(
runShellCommandOrThrow("cmd statusbar collapse"),
equalTo(""))
// Wake up the device
runShellCommandOrThrow("input keyevent KEYCODE_WAKEUP")
if ("false".equals(runShellCommandOrThrow("cmd lock_settings get-disabled"))) {
// Unlock screen only when it's lock settings enabled to prevent showing "wallpaper
// picker" which may cover another UI elements on freeform window configuration.
runShellCommandOrThrow("input keyevent 82")
}
if (isAutomotiveDevice()) {
supportedApkPath = APK_PATH_S_APP
supportedAppPackageName = APK_PACKAGE_NAME_S_APP
preMinVersionApkPath = APK_PATH_R_APP
preMinVersionAppPackageName = APK_PACKAGE_NAME_R_APP
} else {
supportedApkPath = APK_PATH_R_APP
supportedAppPackageName = APK_PACKAGE_NAME_R_APP
preMinVersionApkPath = APK_PATH_Q_APP
preMinVersionAppPackageName = APK_PACKAGE_NAME_Q_APP
}
}
@After
fun cleanUp() {
goHome()
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
@Ignore("b/201545116")
fun testUnusedApp_getsPermissionRevoked() {
assumeFalse(
"Watch doesn't provide a unified way to check notifications. it depends on UX",
hasFeatureWatch())
withUnusedThresholdMs(3L) {
withDummyApp {
// Setup
startAppAndAcceptPermission()
killDummyApp()
Thread.sleep(5) // wait longer than the unused threshold
// Run
runAppHibernationJob(context, LOG_TAG)
// Verify
assertPermission(PERMISSION_DENIED)
if (hasFeatureTV()) {
// Skip checking unused apps screen because it may be unavailable on TV
return
}
openUnusedAppsNotification()
waitFindObject(By.text(supportedAppPackageName))
waitFindObject(By.text("Calendar permission removed"))
goBack()
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testUnusedApp_uninstallApp() {
assumeFalse(
"Unused apps screen may be unavailable on TV",
hasFeatureTV())
withUnusedThresholdMs(3L) {
withDummyAppNoUninstallAssertion {
// Setup
startAppAndAcceptPermission()
killDummyApp()
Thread.sleep(5) // wait longer than the unused threshold
// Run
runAppHibernationJob(context, LOG_TAG)
// Verify
openUnusedAppsNotification()
waitFindObject(By.text(supportedAppPackageName))
assertTrue(isPackageInstalled(supportedAppPackageName))
clickUninstallIcon()
clickUninstallOk()
eventually {
assertFalse(isPackageInstalled(supportedAppPackageName))
}
goBack()
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.S, codeName = "S")
@Test
fun testUnusedApp_doesntGetSplitPermissionRevoked() {
assumeFalse(
"Auto doesn't support hibernation for pre-S apps",
isAutomotiveDevice())
withUnusedThresholdMs(3L) {
withDummyApp(APK_PATH_R_APP, APK_PACKAGE_NAME_R_APP) {
// Setup
startApp(APK_PACKAGE_NAME_R_APP)
assertPermission(PERMISSION_GRANTED, APK_PACKAGE_NAME_R_APP, BLUETOOTH_CONNECT)
killDummyApp(APK_PACKAGE_NAME_R_APP)
Thread.sleep(500)
// Run
runAppHibernationJob(context, LOG_TAG)
// Verify
assertPermission(PERMISSION_GRANTED, APK_PACKAGE_NAME_R_APP, BLUETOOTH_CONNECT)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testUsedApp_doesntGetPermissionRevoked() {
withUnusedThresholdMs(100_000L) {
withDummyApp {
// Setup
startApp()
clickPermissionAllow()
assertPermission(PERMISSION_GRANTED)
killDummyApp()
Thread.sleep(5)
// Run
runAppHibernationJob(context, LOG_TAG)
Thread.sleep(1000)
// Verify
assertPermission(PERMISSION_GRANTED)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testAppWithPermissionsChangedRecently_doesNotGetPermissionRevoked() {
val unusedThreshold = 15_000L
withUnusedThresholdMs(unusedThreshold) {
withDummyApp {
// Setup
// Ensure app is considered unused and then change permission
Thread.sleep(unusedThreshold)
goToPermissions()
click("Calendar")
click("Allow")
goBack()
goBack()
goBack()
// Run
runAppHibernationJob(context, LOG_TAG)
// Verify that permission is not revoked because the permission was changed
// within the unused threshold even though the app itself is unused
assertPermission(PERMISSION_GRANTED)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testPermissionEventCleanupService_scrubsEvents() {
val unusedThreshold = 15_000L
withUnusedThresholdMs(unusedThreshold) {
withDummyApp {
// Setup
// Ensure app is considered unused
Thread.sleep(unusedThreshold)
goToPermissions()
click("Calendar")
click("Allow")
goBack()
goBack()
goBack()
// Run with threshold where events would be cleaned up
withUnusedThresholdMs(0) {
runPermissionEventCleanupJob(context)
Thread.sleep(3000L)
}
runAppHibernationJob(context, LOG_TAG)
// Verify that permission is revoked because there are no recent permission changes
assertPermission(PERMISSION_DENIED)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testPreMinAutoRevokeVersionUnusedApp_doesntGetPermissionRevoked() {
withUnusedThresholdMs(3L) {
withDummyApp(preMinVersionApkPath, preMinVersionAppPackageName) {
withDummyApp {
startApp(preMinVersionAppPackageName)
clickPermissionAllow()
assertPermission(PERMISSION_GRANTED, preMinVersionAppPackageName)
killDummyApp(preMinVersionAppPackageName)
startApp()
clickPermissionAllow()
assertPermission(PERMISSION_GRANTED)
killDummyApp()
Thread.sleep(20)
// Run
runAppHibernationJob(context, LOG_TAG)
Thread.sleep(500)
// Verify
assertPermission(PERMISSION_DENIED)
assertPermission(PERMISSION_GRANTED, preMinVersionAppPackageName)
}
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testAutoRevoke_userAllowlisting() {
assumeFalse(context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE))
withUnusedThresholdMs(4L) {
withDummyApp {
// Setup
startApp()
clickPermissionAllow()
assertAllowlistState(false)
// Verify
waitFindObject(byTextIgnoreCase("Request allowlist")).click()
waitFindObject(byTextIgnoreCase("Permissions")).click()
val autoRevokeEnabledToggle = getAllowlistToggle()
assertTrue(autoRevokeEnabledToggle.isChecked())
// Grant allowlist
autoRevokeEnabledToggle.click()
eventually {
assertFalse(getAllowlistToggle().isChecked())
}
// Run
goBack()
goBack()
goBack()
runAppHibernationJob(context, LOG_TAG)
Thread.sleep(500L)
// Verify
startApp()
assertAllowlistState(true)
assertPermission(PERMISSION_GRANTED)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testInstallGrants_notRevokedImmediately() {
withUnusedThresholdMs(TimeUnit.DAYS.toMillis(30)) {
withDummyApp {
// Setup
goToPermissions()
click("Calendar")
click("Allow")
goBack()
goBack()
goBack()
// Run
runAppHibernationJob(context, LOG_TAG)
Thread.sleep(500)
// Verify
assertPermission(PERMISSION_GRANTED)
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@Test
fun testAutoRevoke_allowlistingApis() {
withDummyApp {
val pm = context.packageManager
runWithShellPermissionIdentity {
assertFalse(pm.isAutoRevokeWhitelisted(supportedAppPackageName))
}
runWithShellPermissionIdentity {
assertTrue(pm.setAutoRevokeWhitelisted(supportedAppPackageName, true))
}
eventually {
runWithShellPermissionIdentity {
assertTrue(pm.isAutoRevokeWhitelisted(supportedAppPackageName))
}
}
runWithShellPermissionIdentity {
assertTrue(pm.setAutoRevokeWhitelisted(supportedAppPackageName, false))
}
eventually {
runWithShellPermissionIdentity {
assertFalse(pm.isAutoRevokeWhitelisted(supportedAppPackageName))
}
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU, codeName = "Tiramisu")
@Test
fun testAutoRevoke_showsUpInSafetyCenter() {
withSafetyCenterEnabled {
withUnusedThresholdMs(3L) {
withDummyApp {
startAppAndAcceptPermission()
killDummyApp()
// Run
runAppHibernationJob(context, LOG_TAG)
// Verify
val safetyCenterManager =
context.getSystemService(SafetyCenterManager::class.java)!!
eventually {
val issues = ArrayList<SafetyCenterIssue>()
runWithShellPermissionIdentity {
val safetyCenterData = safetyCenterManager!!.safetyCenterData
issues.addAll(safetyCenterData.issues)
}
val issueId = SafetyCenterIds.encodeToString(
SafetyCenterIssueId.newBuilder()
.setSafetyCenterIssueKey(SafetyCenterIssueKey.newBuilder()
.setSafetySourceId(UNUSED_APPS_SOURCE_ID)
.setSafetySourceIssueId(UNUSED_APPS_ISSUE_ID)
.setUserId(UserHandle.myUserId())
.build())
.setIssueTypeId(UNUSED_APPS_ISSUE_ID)
.build())
assertTrue(issues.any { it.id == issueId })
}
}
}
}
}
@AppModeFull(reason = "Uses separate apps for testing")
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU, codeName = "Tiramisu")
@Test
fun testAutoRevoke_goToUnusedAppsPage_removesSafetyCenterIssue() {
withSafetyCenterEnabled {
withUnusedThresholdMs(3L) {
withDummyApp {
startAppAndAcceptPermission()
killDummyApp()
// Run
runAppHibernationJob(context, LOG_TAG)
// Go to unused apps page
openUnusedAppsNotification()
waitFindObject(By.text(supportedAppPackageName))
// Verify
val safetyCenterManager =
context.getSystemService(SafetyCenterManager::class.java)!!
eventually {
val issues = ArrayList<SafetyCenterIssue>()
runWithShellPermissionIdentity {
val safetyCenterData = safetyCenterManager!!.safetyCenterData
issues.addAll(safetyCenterData.issues)
}
val issueId = SafetyCenterIds.encodeToString(
SafetyCenterIssueId.newBuilder()
.setSafetyCenterIssueKey(SafetyCenterIssueKey.newBuilder()
.setSafetySourceId(UNUSED_APPS_SOURCE_ID)
.setSafetySourceIssueId(UNUSED_APPS_ISSUE_ID)
.setUserId(UserHandle.myUserId())
.build())
.setIssueTypeId(UNUSED_APPS_ISSUE_ID)
.build())
assertFalse(issues.any { it.id == issueId })
}
}
}
}
}
private fun isAutomotiveDevice(): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
}
private fun installApp() {
installApk(supportedApkPath)
}
private fun isPackageInstalled(packageName: String): Boolean {
val pm = context.packageManager
return callWithShellPermissionIdentity {
try {
pm.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
}
private fun uninstallApp() {
uninstallApp(supportedAppPackageName)
}
private fun startApp() {
startApp(supportedAppPackageName)
}
private fun startAppAndAcceptPermission() {
startApp()
clickPermissionAllow()
assertPermission(PERMISSION_GRANTED)
}
private fun goBack() {
runShellCommandOrThrow("input keyevent KEYCODE_BACK")
}
private fun killDummyApp(pkg: String = supportedAppPackageName) {
if (!SdkLevel.isAtLeastS()) {
// Work around a race condition on R that killing the app process too fast after
// activity launch would result in a stale process record in LRU process list that
// sticks until next reboot.
Thread.sleep(5000)
}
assertThat(
runShellCommandOrThrow("am force-stop " + pkg),
equalTo(""))
awaitAppState(pkg, greaterThan(IMPORTANCE_TOP_SLEEPING))
}
private fun clickPermissionAllow() {
if (isAutomotiveDevice()) {
waitFindObject(By.text(Pattern.compile(
Pattern.quote(mPermissionControllerResources.getString(
mPermissionControllerResources.getIdentifier(
"grant_dialog_button_allow", "string",
"com.android.permissioncontroller"))),
Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE))).click()
} else {
waitFindObject(By.res("com.android.permissioncontroller:id/permission_allow_button"))
.click()
}
waitForIdle()
}
private fun clickUninstallIcon() {
val rowSelector = By.text(supportedAppPackageName)
val rowItem = waitFindObject(rowSelector).parent.parent
val uninstallSelector = if (isAutomotiveDevice()) {
By.res("com.android.permissioncontroller:id/car_ui_secondary_action")
} else {
By.desc("Uninstall or disable")
}
rowItem.findObject(uninstallSelector).click()
}
private fun clickUninstallOk() {
waitFindObject(By.text("OK")).click()
}
private inline fun withDummyApp(
apk: String = supportedApkPath,
packageName: String = supportedAppPackageName,
action: () -> Unit
) {
withApp(apk, packageName, action)
}
private inline fun withDummyAppNoUninstallAssertion(
apk: String = supportedApkPath,
packageName: String = supportedAppPackageName,
action: () -> Unit
) {
withAppNoUninstallAssertion(apk, packageName, action)
}
private fun assertPermission(
state: Int,
packageName: String = supportedAppPackageName,
permission: String = READ_CALENDAR
) {
assertPermission(packageName, permission, state)
}
private fun goToPermissions(packageName: String = supportedAppPackageName) {
context.startActivity(Intent(ACTION_AUTO_REVOKE_PERMISSIONS)
.setData(Uri.fromParts("package", packageName, null))
.addFlags(FLAG_ACTIVITY_NEW_TASK))
waitForIdle()
click("Permissions")
}
private fun click(label: String) {
try {
waitFindObject(byTextIgnoreCase(label)).click()
} catch (e: UiObjectNotFoundException) {
// waitFindObject sometimes fails to find UI that is present in the view hierarchy
// Increasing sleep to 2000 in waitForIdle() might be passed but no guarantee that the
// UI is fully displayed So Adding one more check without using the UiAutomator helps
// reduce false positives
waitFindNode(hasTextThat(containsStringIgnoringCase(label))).click()
}
waitForIdle()
}
private fun assertAllowlistState(state: Boolean) {
assertThat(
waitFindObject(By.textStartsWith("Auto-revoke allowlisted: ")).text,
containsString(state.toString()))
}
private fun getAllowlistToggle(): UiObject2 {
waitForIdle()
val parent = waitFindObject(
By.clickable(true)
.hasDescendant(By.textStartsWith("Remove permissions"))
.hasDescendant(By.clazz(Switch::class.java.name))
)
return parent.findObject(By.clazz(Switch::class.java.name))
}
private fun waitForIdle() {
instrumentation.uiAutomation.waitForIdle(1000, 10000)
Thread.sleep(500)
instrumentation.uiAutomation.waitForIdle(1000, 10000)
}
private inline fun <T> eventually(crossinline action: () -> T): T {
val res = AtomicReference<T>()
SystemUtil.eventually {
res.set(action())
}
return res.get()
}
private fun waitFindObject(selector: BySelector): UiObject2 {
return waitFindObject(instrumentation.uiAutomation, selector)
}
}
private fun permissionStateToString(state: Int): String {
return constToString<PackageManager>("PERMISSION_", state)
}
/**
* For some reason waitFindObject sometimes fails to find UI that is present in the view hierarchy
*/
fun waitFindNode(
matcher: Matcher<AccessibilityNodeInfo>,
failMsg: String? = null,
timeoutMs: Long = 10_000
): AccessibilityNodeInfo {
return getEventually({
val ui = UI_ROOT
ui.depthFirstSearch { node ->
matcher.matches(node)
}.assertNotNull {
buildString {
if (failMsg != null) {
appendLine(failMsg)
}
appendLine("No view found matching $matcher:\n\n${uiDump(ui)}")
}
}
}, timeoutMs)
}
fun byTextIgnoreCase(txt: String): BySelector {
return By.text(Pattern.compile(txt, Pattern.CASE_INSENSITIVE))
}
fun waitForIdle() {
InstrumentationRegistry.getInstrumentation().uiAutomation.waitForIdle(1000, 10000)
}
fun uninstallApp(packageName: String) {
assertThat(runShellCommandOrThrow("pm uninstall $packageName"), containsString("Success"))
}
fun uninstallAppWithoutAssertion(packageName: String) {
runShellCommandOrThrow("pm uninstall $packageName")
}
fun installApk(apk: String) {
assertThat(runShellCommandOrThrow("pm install -r $apk"), containsString("Success"))
}
fun assertPermission(packageName: String, permissionName: String, state: Int) {
assertThat(permissionName, containsString("permission."))
eventually {
runWithShellPermissionIdentity {
assertEquals(
permissionStateToString(state),
permissionStateToString(
InstrumentationRegistry.getTargetContext()
.packageManager
.checkPermission(permissionName, packageName)))
}
}
}
inline fun <reified T> constToString(prefix: String, value: Int): String {
return T::class.java.declaredFields.filter {
Modifier.isStatic(it.modifiers) && it.name.startsWith(prefix)
}.map {
it.isAccessible = true
it.name to it.get(null)
}.find { (k, v) ->
v == value
}.assertNotNull {
"None of ${T::class.java.simpleName}.$prefix* == $value"
}.first
}
inline fun <T> T?.assertNotNull(errorMsg: () -> String): T {
return if (this == null) throw AssertionError(errorMsg()) else this
}