Test for AutoRevoke
Test: atest AutoRevokeTest
Bug: 146513245
Change-Id: Ib97129b44f0475b9716d9a818b3f9b277e0d2e07
diff --git a/tests/tests/os/AutoRevokeDummyApp/Android.bp b/tests/tests/os/AutoRevokeDummyApp/Android.bp
new file mode 100644
index 0000000..6b38e38
--- /dev/null
+++ b/tests/tests/os/AutoRevokeDummyApp/Android.bp
@@ -0,0 +1,30 @@
+//
+// 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.
+//
+
+android_test_helper_app {
+ name: "CtsAutoRevokeDummyApp",
+ defaults: ["cts_defaults"],
+ sdk_version: "test_current",
+ // Tag this module as a cts test artifact
+ test_suites: [
+ "cts",
+ "vts",
+ "vts10",
+ "mts",
+ "general-tests",
+ ],
+ srcs: ["src/**/*.java", "src/**/*.kt"],
+}
diff --git a/tests/tests/os/AutoRevokeDummyApp/AndroidManifest.xml b/tests/tests/os/AutoRevokeDummyApp/AndroidManifest.xml
new file mode 100644
index 0000000..202b95c
--- /dev/null
+++ b/tests/tests/os/AutoRevokeDummyApp/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--s
+ * 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="android.os.cts.autorevokedummyapp">
+
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
+
+ <uses-sdk android:minSdkVersion="30" android:targetSdkVersion="30" />
+
+ <application android:autoRevokePermissions="discouraged">
+ <activity android:name="android.os.cts.autorevokedummyapp.MainActivity"
+ android:exported="true"
+ android:visibleToInstantApps="true" >
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
+
diff --git a/tests/tests/os/AutoRevokeDummyApp/src/android/os/cts/autorevokedummyapp/MainActivity.kt b/tests/tests/os/AutoRevokeDummyApp/src/android/os/cts/autorevokedummyapp/MainActivity.kt
new file mode 100644
index 0000000..a134ea1
--- /dev/null
+++ b/tests/tests/os/AutoRevokeDummyApp/src/android/os/cts/autorevokedummyapp/MainActivity.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.autorevokedummyapp
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.LinearLayout.VERTICAL
+import android.widget.TextView
+
+class MainActivity : Activity() {
+
+ val whitelistStatus by lazy { TextView(this) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(LinearLayout(this).apply {
+ orientation = VERTICAL
+
+ addView(whitelistStatus)
+ addView(Button(this@MainActivity).apply {
+ text = "Request whitelist"
+
+ setOnClickListener {
+ startActivity(
+ // TODO use this instead once implemented
+// Intent(Intent.ACTION_AUTO_REVOKE_PERMISSIONS)
+// .putExtra(Intent.EXTRA_PACKAGE_NAME, packageName))
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(Uri.fromParts("package", packageName, null)))
+ }
+ })
+ })
+
+ requestPermissions(arrayOf("android.permission.READ_CALENDAR"), 0)
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ whitelistStatus.text = "Auto-revoke whitelisted: " + packageManager.isAutoRevokeWhitelisted
+ }
+}
diff --git a/tests/tests/os/CtsOsTestCases.xml b/tests/tests/os/CtsOsTestCases.xml
index 0660e50..3718c59 100644
--- a/tests/tests/os/CtsOsTestCases.xml
+++ b/tests/tests/os/CtsOsTestCases.xml
@@ -32,4 +32,9 @@
<option name="hidden-api-checks" value="false" />
-->
</test>
+
+ <!-- Load additional APKs onto device -->
+ <target_preparer class="com.android.compatibility.common.tradefed.targetprep.FilePusher">
+ <option name="push" value="CtsAutoRevokeDummyApp.apk->/data/local/tmp/cts/os/CtsAutoRevokeDummyApp.apk" />
+ </target_preparer>
</configuration>
diff --git a/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt b/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt
new file mode 100644
index 0000000..b104554
--- /dev/null
+++ b/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt
@@ -0,0 +1,235 @@
+/*
+ * 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.Manifest.permission.READ_CALENDAR
+import android.content.pm.PackageManager.PERMISSION_DENIED
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.graphics.Rect
+import android.platform.test.annotations.AppModeFull
+import android.provider.DeviceConfig
+import android.support.test.uiautomator.By
+import android.test.InstrumentationTestCase
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.Switch
+import com.android.compatibility.common.util.SystemUtil.*
+import com.android.compatibility.common.util.ThrowingSupplier
+import com.android.compatibility.common.util.UiAutomatorUtils.waitFindObject
+import com.android.compatibility.common.util.UiDumpUtils
+import org.hamcrest.CoreMatchers.containsString
+import org.junit.Assert.assertThat
+
+private const val APK_PATH = "/data/local/tmp/cts/os/CtsAutoRevokeDummyApp.apk"
+private const val APK_PACKAGE_NAME = "android.os.cts.autorevokedummyapp"
+
+/**
+ * Test for auto revoke
+ */
+// TODO test pregrants exempt
+// TODO test manifest whitelist
+class AutoRevokeTest : InstrumentationTestCase() {
+
+ @AppModeFull(reason = "Uses separate apps for testing")
+ fun testUnusedApp_getsPermissionRevoked() {
+ wakeUpScreen()
+ withDeviceConfig("auto_revoke_unused_threshold_millis", "1") {
+ withDummyApp {
+ // Setup
+ startApp()
+ clickPermissionAllow()
+ goHome()
+ Thread.sleep(5)
+
+ // Run
+ runAutoRevoke()
+
+ // Verify
+ eventually {
+ assertPermission(PERMISSION_DENIED)
+ }
+ }
+ }
+ }
+
+ // TODO once implemented, use:
+ // Intent(Intent.ACTION_AUTO_REVOKE_PERMISSIONS).putExtra(Intent.EXTRA_PACKAGE_NAME, packageName)
+ @AppModeFull(reason = "Uses separate apps for testing")
+ fun testAutoRevoke_userWhitelisting() {
+ wakeUpScreen()
+ withDummyApp {
+ // Setup
+ startApp()
+ clickPermissionAllow()
+ assertWhitelistState(false)
+
+ // Verify
+ waitFindObject(By.text("Request whitelist")).click()
+ waitFindObject(By.text("Permissions")).click()
+ val autoRevokeEnabledToggle = getWhitelistToggle()
+ assertTrue(autoRevokeEnabledToggle.isChecked)
+
+ // Grant whitelist
+ autoRevokeEnabledToggle.click()
+ eventually {
+ assertFalse(getWhitelistToggle().isChecked)
+ }
+
+ // Verify
+ goBack()
+ goBack()
+ goBack()
+ startApp()
+ assertWhitelistState(true)
+ }
+ }
+
+ // TODO grantRuntimePermission fails to grant permission
+ @AppModeFull(reason = "Uses separate apps for testing")
+ fun _testInstallGrants_notRevokedImmediately() {
+ wakeUpScreen()
+ withDummyApp {
+ // Setup
+ instrumentation.uiAutomation.grantRuntimePermission(APK_PACKAGE_NAME, READ_CALENDAR)
+ eventually {
+ assertPermission(PERMISSION_GRANTED)
+ }
+
+ // Run
+ runAutoRevoke()
+ Thread.sleep(500)
+
+ // Verify
+ assertPermission(PERMISSION_GRANTED)
+ }
+ }
+
+ private fun wakeUpScreen() {
+ runShellCommand("input keyevent KEYCODE_WAKEUP")
+ runShellCommand("input keyevent 82")
+ }
+
+ private fun runAutoRevoke() {
+ runShellCommand("cmd jobscheduler run -u 0 " +
+ "-f ${context.packageManager.permissionControllerPackageName} 2")
+ }
+
+ private inline fun <T> withDeviceConfig(
+ name: String,
+ value: String,
+ action: () -> T
+ ): T {
+ val oldValue = runWithShellPermissionIdentity(ThrowingSupplier {
+ DeviceConfig.getProperty("permissions", name)
+ })
+ try {
+ runWithShellPermissionIdentity {
+ DeviceConfig.setProperty("permissions", name, value, false /* makeDefault */)
+ }
+ return action()
+ } finally {
+ runWithShellPermissionIdentity {
+ DeviceConfig.setProperty("permissions", name, oldValue, false /* makeDefault */)
+ }
+ }
+ }
+
+ private fun installApp() {
+ assertThat(runShellCommand("pm install -r $APK_PATH"), containsString("Success"))
+ }
+
+ private fun uninstallApp() {
+ assertThat(runShellCommand("pm uninstall $APK_PACKAGE_NAME"), containsString("Success"))
+ }
+
+ private fun startApp() {
+ runShellCommand("am start -n $APK_PACKAGE_NAME/$APK_PACKAGE_NAME.MainActivity")
+ }
+
+ private fun goHome() {
+ runShellCommand("input keyevent KEYCODE_HOME")
+ }
+
+ private fun goBack() {
+ runShellCommand("input keyevent KEYCODE_BACK")
+ }
+
+ private fun clickPermissionAllow() {
+ waitFindObject(By.res("com.android.permissioncontroller:id/permission_allow_button"))
+ .click()
+ }
+
+ private inline fun withDummyApp(action: () -> Unit) {
+ installApp()
+ try {
+ action()
+ } finally {
+ uninstallApp()
+ }
+ }
+
+ private fun assertPermission(state: Int) {
+ assertEquals(
+ state,
+ context.packageManager.checkPermission(READ_CALENDAR, APK_PACKAGE_NAME))
+ }
+
+ private fun assertWhitelistState(state: Boolean) {
+ assertThat(
+ waitFindObject(By.textStartsWith("Auto-revoke whitelisted: ")).text,
+ containsString(state.toString()))
+ }
+
+ private fun getWhitelistToggle(): AccessibilityNodeInfo {
+ waitForIdle()
+ val ui = instrumentation.uiAutomation.rootInActiveWindow
+ return ui.depthFirstSearch {
+ depthFirstSearch {
+ (text as CharSequence?).toString() == "Remove permissions if app isn’t used"
+ } != null &&
+ depthFirstSearch { className == Switch::class.java.name } != null
+ }.assertNotNull {
+ "No auto-revoke whitelist toggle found in\n" +
+ buildString { UiDumpUtils.dumpNodes(ui, this) }
+ }.depthFirstSearch { className == Switch::class.java.name }!!
+ }
+
+ private fun waitForIdle() {
+ instrumentation.uiAutomation.waitForIdle(2000, 5000)
+ }
+
+ private fun <T> T?.assertNotNull(errorMsg: () -> String): T {
+ return if (this == null) throw AssertionError(errorMsg()) else this
+ }
+}
+
+val AccessibilityNodeInfo.bounds: Rect get() = Rect().also { getBoundsInScreen(it) }
+
+fun AccessibilityNodeInfo.click() {
+ runShellCommand("input tap ${bounds.centerX()} ${bounds.centerY()}")
+}
+
+fun AccessibilityNodeInfo.depthFirstSearch(
+ condition: AccessibilityNodeInfo.() -> Boolean
+): AccessibilityNodeInfo? {
+ for (child in children) {
+ child.depthFirstSearch(condition)?.let { return it }
+ }
+ if (this.condition()) return this
+ return null
+}
+
+val AccessibilityNodeInfo.children get() = List(childCount) { i -> getChild(i) }