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) }