diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8686bdad..8e284908 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,8 +34,10 @@ android { versionCode = 1 versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" } + buildTypes { getByName("debug") { signingConfig = signingConfigs.getByName("debug") @@ -83,8 +85,11 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + @Suppress("UnstableApiUsage") testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + managedDevices { localDevices { create("pixel2Api28") { @@ -138,6 +143,8 @@ dependencies { androidTestImplementation(libs.androidx.rules) androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.truth) + androidTestUtil(libs.androidx.orchestrator) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.compose) @@ -166,7 +173,6 @@ dependencies { // benchmark implementation(libs.androidx.profileinstaller) - } // Allow references to generated code diff --git a/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt new file mode 100644 index 00000000..f75533bb --- /dev/null +++ b/app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2024 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.google.jetpackcamera + +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.google.jetpackcamera.feature.preview.ui.CAPTURE_BUTTON +import com.google.jetpackcamera.permissions.ui.CAMERA_PERMISSION_BUTTON +import com.google.jetpackcamera.permissions.ui.RECORD_AUDIO_PERMISSION_BUTTON +import com.google.jetpackcamera.permissions.ui.REQUEST_PERMISSION_BUTTON +import com.google.jetpackcamera.utils.APP_REQUIRED_PERMISSIONS +import com.google.jetpackcamera.utils.APP_START_TIMEOUT_MILLIS +import com.google.jetpackcamera.utils.IndividualTestGrantPermissionRule +import com.google.jetpackcamera.utils.askEveryTimeDialog +import com.google.jetpackcamera.utils.denyPermissionDialog +import com.google.jetpackcamera.utils.grantPermissionDialog +import com.google.jetpackcamera.utils.onNodeWithText +import com.google.jetpackcamera.utils.runScenarioTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +const val CAMERA_PERMISSION = "android.permission.CAMERA" + +@RunWith(AndroidJUnit4::class) +class PermissionsTest { + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @get:Rule + val allPermissionsRule = IndividualTestGrantPermissionRule( + permissions = APP_REQUIRED_PERMISSIONS.toTypedArray(), + targetTestNames = arrayOf( + "allPermissions_alreadyGranted_screenNotShown" + ) + ) + + @get:Rule + val cameraPermissionRule = IndividualTestGrantPermissionRule( + permissions = arrayOf(CAMERA_PERMISSION), + targetTestNames = arrayOf( + "recordAudioPermission_granted_closesPage", + "recordAudioPermission_denied_closesPage" + ) + ) + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val uiDevice = UiDevice.getInstance(instrumentation) + + @Test + fun allPermissions_alreadyGranted_screenNotShown() { + runScenarioTest { + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).isDisplayed() + } + } + } + + @Test + fun cameraPermission_granted_closesPage() = runScenarioTest { + // Wait for the camera permission screen to be displayed + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() + } + + // Click button to request permission + composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) + .assertExists() + .performClick() + + uiDevice.waitForIdle() + // grant permission + grantPermissionDialog(uiDevice) + + // Assert we're no longer on camera permission screen + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).assertDoesNotExist() + } + + @SdkSuppress(minSdkVersion = 30) + @Test + fun cameraPermission_askEveryTime_closesPage() { + uiDevice.waitForIdle() + runScenarioTest { + // Wait for the camera permission screen to be displayed + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() + } + + // Click button to request permission + composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) + .assertExists() + .performClick() + + // set permission to ask every time + askEveryTimeDialog(uiDevice) + + // Assert we're no longer on camera permission screen + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).assertDoesNotExist() + } + } + + @Test + fun cameraPermission_declined_staysOnScreen() { + // required permissions should persist on screen + // Wait for the permission screen to be displayed + runScenarioTest { + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() + } + + // Click button to request permission + composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) + .assertExists() + .performClick() + + // deny permission + denyPermissionDialog(uiDevice) + + uiDevice.waitForIdle() + + // Assert we're still on camera permission screen + composeTestRule.onNodeWithTag(CAMERA_PERMISSION_BUTTON).isDisplayed() + + // request permissions button should now say to navigate to settings + composeTestRule.onNodeWithText( + com.google.jetpackcamera.permissions + .R.string.navigate_to_settings + ).assertExists() + } + } + + @Test + fun recordAudioPermission_granted_closesPage() { + // optional permissions should close the screen after declining + runScenarioTest { + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isDisplayed() + } + + // Click button to request permission + composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) + .assertExists() + .performClick() + + // deny permission + grantPermissionDialog(uiDevice) + uiDevice.waitForIdle() + + // Assert we're now on preview screen + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().isDisplayed() + } + } + } + + @Test + fun recordAudioPermission_denied_closesPage() { + // optional permissions should close the screen after declining + runScenarioTest { + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(RECORD_AUDIO_PERMISSION_BUTTON).isDisplayed() + } + + // Click button to request permission + composeTestRule.onNodeWithTag(REQUEST_PERMISSION_BUTTON) + .assertExists() + .performClick() + + // deny permission + denyPermissionDialog(uiDevice) + uiDevice.waitForIdle() + + // Assert we're now on preview screen + composeTestRule.waitUntil(timeoutMillis = APP_START_TIMEOUT_MILLIS) { + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().isDisplayed() + } + } + } +} diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index 782ede2e..45f0e1e7 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -19,6 +19,8 @@ import android.app.Activity import android.app.Instrumentation import android.content.ComponentName import android.content.Intent +import android.os.Build +import android.util.Log import android.net.Uri import android.provider.MediaStore import androidx.compose.ui.semantics.SemanticsProperties @@ -27,17 +29,27 @@ import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario +import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until import com.google.jetpackcamera.MainActivity import com.google.jetpackcamera.feature.preview.R import com.google.jetpackcamera.feature.preview.quicksettings.ui.QUICK_SETTINGS_FLIP_CAMERA_BUTTON import com.google.jetpackcamera.settings.model.LensFacing import java.io.File import java.net.URLConnection +import org.junit.Assert.fail +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement const val APP_START_TIMEOUT_MILLIS = 10_000L const val IMAGE_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_CAPTURE_TIMEOUT_MILLIS = 5_000L const val VIDEO_DURATION_MILLIS = 2_000L +private const val TAG = "UiTestUtil" inline fun runScenarioTest( crossinline block: ActivityScenario.() -> Unit @@ -147,3 +159,108 @@ fun getIntent(uri: Uri, action: String): Intent { intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) return intent } + +/** + * Rule that you to specify test methods that will have permissions granted prior to starting + * + * @param permissions the permissions to be granted + * @param targetTestNames the names of the tests that this rule will apply to + */ +class IndividualTestGrantPermissionRule( + private val permissions: Array, + private val targetTestNames: Array +) : + TestRule { + private lateinit var wrappedRule: GrantPermissionRule + + override fun apply(base: Statement, description: Description): Statement { + for (targetName in targetTestNames) { + if (description.methodName == targetName) { + wrappedRule = GrantPermissionRule.grant(*permissions) + return wrappedRule.apply(base, description) + } + } + // If no match, return the base statement without granting permissions + return base + } +} + +// functions for interacting with system permission dialog +fun askEveryTimeDialog(uiDevice: UiDevice) { + if (Build.VERSION.SDK_INT >= 30) { + Log.d(TAG, "Searching for Allow Once Button...") + + val askPermission = findObjectById( + uiDevice = uiDevice, + resId = "com.android.permissioncontroller:id/permission_allow_one_time_button" + ) + + Log.d(TAG, "Clicking Allow Once Button") + + askPermission!!.click() + } +} + +/** + * Clicks ALLOW option on an open permission dialog + */ +fun grantPermissionDialog(uiDevice: UiDevice) { + if (Build.VERSION.SDK_INT >= 23) { + Log.d(TAG, "Searching for Allow Button...") + + val allowPermission = findObjectById( + uiDevice = uiDevice, + resId = when { + Build.VERSION.SDK_INT <= 29 -> + "com.android.packageinstaller:id/permission_allow_button" + else -> + "com.android.permissioncontroller:id/permission_allow_foreground_only_button" + } + ) + Log.d(TAG, "Clicking Allow Button") + + allowPermission!!.click() + } +} + +/** + * Clicks the DENY option on an open permission dialog + */ +fun denyPermissionDialog(uiDevice: UiDevice) { + if (Build.VERSION.SDK_INT >= 23) { + Log.d(TAG, "Searching for Deny Button...") + val denyPermission = findObjectById( + uiDevice = uiDevice, + + resId = when { + Build.VERSION.SDK_INT <= 29 -> + "com.android.packageinstaller:id/permission_deny_button" + else -> "com.android.permissioncontroller:id/permission_deny_button" + } + ) + Log.d(TAG, "Clicking Deny Button") + + denyPermission!!.click() + } +} + +/** + * Finds a system button by its resource ID. + * fails if not found + */ +private fun findObjectById( + uiDevice: UiDevice, + resId: String, + timeout: Long = 10000, + shouldFailIfNotFound: Boolean = true +): UiObject2? { + val selector = By.res(resId) + return if (!uiDevice.wait(Until.hasObject(selector), timeout)) { + if (shouldFailIfNotFound) { + fail("Could not find object with RESOURCE ID: $resId") + } + null + } else { + uiDevice.findObject(selector) + } +} diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt index 57fd0b17..941d6605 100644 --- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt +++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/PermissionsEnums.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource +import com.google.jetpackcamera.permissions.ui.CAMERA_PERMISSION_BUTTON +import com.google.jetpackcamera.permissions.ui.RECORD_AUDIO_PERMISSION_BUTTON const val CAMERA_PERMISSION = "android.permission.CAMERA" const val AUDIO_RECORD_PERMISSION = "android.permission.RECORD_AUDIO" @@ -55,6 +57,8 @@ sealed interface PermissionInfoProvider { fun isOptional(): Boolean + fun getTestTag(): String + @DrawableRes fun getDrawableResId(): Int? @@ -84,6 +88,8 @@ enum class PermissionEnum : PermissionInfoProvider { override fun isOptional(): Boolean = false + override fun getTestTag(): String = CAMERA_PERMISSION_BUTTON + override fun getDrawableResId(): Int? = null override fun getImageVector(): ImageVector = Icons.Outlined.CameraAlt @@ -105,6 +111,8 @@ enum class PermissionEnum : PermissionInfoProvider { override fun isOptional(): Boolean = true + override fun getTestTag(): String = RECORD_AUDIO_PERMISSION_BUTTON + override fun getDrawableResId(): Int? = null override fun getImageVector(): ImageVector = Icons.Outlined.Mic diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt index efe8a922..b889726d 100644 --- a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt +++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/PermissionsScreenComponents.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 The Android Open Source Project + * 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. @@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -67,6 +68,7 @@ fun PermissionTemplate( ) { PermissionTemplate( modifier = modifier, + testTag = permissionEnum.getTestTag(), onRequestPermission = { if (permissionState.status.shouldShowRationale) { onOpenAppSettings() @@ -101,6 +103,7 @@ fun PermissionTemplate( @Composable fun PermissionTemplate( modifier: Modifier = Modifier, + testTag: String, onRequestPermission: () -> Unit, onSkipPermission: (() -> Unit)? = null, imageVector: ImageVector, @@ -117,7 +120,8 @@ fun PermissionTemplate( PermissionImage( modifier = Modifier .height(IntrinsicSize.Min) - .align(Alignment.CenterHorizontally), + .align(Alignment.CenterHorizontally) + .testTag(testTag), imageVector = imageVector, accessibilityText = iconAccessibilityText ) @@ -133,6 +137,7 @@ fun PermissionTemplate( // permission button section PermissionButtonSection( modifier = Modifier + .testTag(REQUEST_PERMISSION_BUTTON) .fillMaxWidth() .align(Alignment.CenterHorizontally) .height(IntrinsicSize.Min), @@ -153,6 +158,7 @@ Permission UI Previews private fun Preview_Camera_Permission_Page() { PermissionTemplate( onRequestPermission = { /*TODO*/ }, + testTag = "", imageVector = PermissionEnum.CAMERA.getImageVector()!!, iconAccessibilityText = "", title = stringResource(id = PermissionEnum.CAMERA.getPermissionTitleResId()), @@ -166,6 +172,7 @@ private fun Preview_Camera_Permission_Page() { private fun Preview_Audio_Permission_Page() { PermissionTemplate( onRequestPermission = { /*TODO*/ }, + testTag = "", imageVector = PermissionEnum.RECORD_AUDIO.getImageVector()!!, iconAccessibilityText = "", title = stringResource(id = PermissionEnum.RECORD_AUDIO.getPermissionTitleResId()), diff --git a/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/TestTags.kt b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/TestTags.kt new file mode 100644 index 00000000..f09e503b --- /dev/null +++ b/feature/permissions/src/main/java/com/google/jetpackcamera/permissions/ui/TestTags.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2024 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.google.jetpackcamera.permissions.ui + +const val REQUEST_PERMISSION_BUTTON = "RequestPermissionButton" +const val CAMERA_PERMISSION_BUTTON = "CameraPermissionButton" +const val RECORD_AUDIO_PERMISSION_BUTTON = "RecordAudioPermissionButton" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36ea1328..ab8deda6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ compileSdk = "34" compileSdkPreview = "VanillaIceCream" minSdk = "21" +orchestrator = "1.4.2" targetSdk = "34" targetSdkPreview = "VanillaIceCream" composeCompiler = "1.5.10" @@ -64,6 +65,7 @@ androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata- androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigationCompose" } +androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidxProfileinstaller" } androidx-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidxTestMonitor" }