diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60c0fc93c4..5a381ca221 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,7 @@ android { defaultConfig { applicationId = BuildConfig.PACKAGE_NAME vectorDrawables.useSupportLibrary = true + testInstrumentationRunnerArguments["class"] = "com.itsaky.androidide.OrderedTestSuite" } signingConfigs { @@ -123,8 +124,6 @@ android { } testOptions { - execution = "ANDROIDX_TEST_ORCHESTRATOR" - unitTests { isIncludeAndroidResources = true all { @@ -341,7 +340,7 @@ dependencies { androidTestImplementation(libs.tests.kaspresso) androidTestImplementation(libs.tests.junit.kts) androidTestImplementation(libs.tests.androidx.test.runner) - androidTestUtil(libs.tests.orchestrator) + // androidTestUtil(libs.tests.orchestrator) testImplementation(libs.tests.kotlinx.coroutines) // brotli4j diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/EndToEndTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/EndToEndTest.kt new file mode 100644 index 0000000000..a92f5f94ad --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/EndToEndTest.kt @@ -0,0 +1,200 @@ +package com.itsaky.androidide + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.activities.SplashActivity +import com.itsaky.androidide.helper.advancePastWelcomeScreen +import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByText +import com.itsaky.androidide.helper.grantAllRequiredPermissionsThroughOnboardingUi +import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi +import com.itsaky.androidide.resources.R as ResourcesR +import com.itsaky.androidide.screens.OnboardingScreen +import com.itsaky.androidide.screens.PermissionScreen +import com.itsaky.androidide.screens.PermissionsInfoScreen +import com.itsaky.androidide.utils.PermissionsHelper +import com.kaspersky.kaspresso.testcases.api.testcase.TestCase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Single continuous E2E test that drives the app from first launch through + * onboarding, project creation, builds, and beyond. + * + * The activity launches once and stays alive. Each stage is a Kaspresso + * `step()` so failures report exactly which stage broke. + */ +@RunWith(AndroidJUnit4::class) +class EndToEndTest : TestCase() { + + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val acceptText: String + get() = targetContext.getString(ResourcesR.string.privacy_disclosure_accept) + + private val learnMoreText: String + get() = targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more) + + private val dialogTitle: String + get() = targetContext.getString(ResourcesR.string.privacy_disclosure_title) + + @Test + fun test_endToEnd() = run { + + // ── Launch ── + + step("Launch app") { + ActivityScenario.launch(SplashActivity::class.java) + Thread.sleep(1000) + } + + // ── Welcome Screen ── + + step("Verify welcome screen") { + OnboardingScreen { + greetingTitle.isVisible() + greetingSubtitle.isVisible() + nextButton { + isVisible() + isClickable() + } + } + } + + advancePastWelcomeScreen() + + // ── Permissions Info Screen ── + + step("Verify privacy disclosure dialog") { + val d = device.uiDevice + val title = d.findObject(UiSelector().text(dialogTitle)) + assertTrue("Dialog title missing", title.waitForExists(2_000)) + assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists()) + assertTrue("Learn more button missing", d.findObject(UiSelector().text(learnMoreText)).exists()) + } + + step("Accept privacy disclosure") { + clickFirstAccessibilityNodeByText(acceptText) + device.uiDevice.waitForIdle() + } + + step("Verify permissions info content") { + flakySafely(timeoutMs = 2_000) { + PermissionsInfoScreen { + introText { isVisible() } + permissionsList { isVisible() } + } + } + } + + step("Verify NEXT button on permissions info") { + OnboardingScreen.nextButton { isVisible(); isClickable() } + } + + step("Verify privacy dialog does not reappear") { + assertFalse( + "Dialog should not reappear", + device.uiDevice.findObject(UiSelector().text(dialogTitle)).exists(), + ) + } + + // ── Permissions Screen ── + + step("Advance to permissions list") { + val d = device.uiDevice + val nextObj = d.findObject(UiSelector().descriptionContains("NEXT")) + if (!nextObj.waitForExists(3_000)) { + throw AssertionError("NEXT button not found on permissions info slide") + } + clickFirstAccessibilityNodeByText( + searchText = "NEXT", + errorLabel = "NEXT", + matchBy = { node -> + val desc = node.contentDescription?.toString() ?: "" + val text = node.text?.toString() ?: "" + desc.contains("NEXT", ignoreCase = true) || text.contains("NEXT", ignoreCase = true) + }, + ) + d.waitForIdle() + } + + val required = PermissionsHelper.getRequiredPermissions(targetContext) + + step("Verify all permission items") { + flakySafely(timeoutMs = 3_000) { + PermissionScreen { + title { isVisible() } + subTitle { isVisible() } + rvPermissions { + isVisible() + isDisplayed() + } + assertEquals(required.size, rvPermissions.getSize()) + + rvPermissions { + required.forEachIndexed { index, item -> + childAt(index) { + title { + isVisible() + hasText(item.title) + } + description { + isVisible() + hasText(item.description) + } + grantButton { + isVisible() + isClickable() + hasText(R.string.title_grant) + } + } + } + } + } + } + } + + grantAllRequiredPermissionsThroughOnboardingUi() + + step("Confirm all permissions granted") { + flakySafely(timeoutMs = 3_000) { + assertTrue(PermissionsHelper.areAllPermissionsGranted(targetContext)) + } + } + + step("Confirm all grant buttons disabled") { + device.uiDevice.waitForIdle() + PermissionScreen { + rvPermissions { + required.indices.forEach { index -> + childAt(index) { + grantButton { + isNotEnabled() + } + } + } + } + } + } + + step("Tap Finish installation") { + // The button is in the gesture exclusion zone — use accessibility click + clickFirstAccessibilityNodeByText("Finish installation") + } + + step("Wait for IDE setup to complete") { + waitForMainHomeOrEditorUi( + device.uiDevice, + maxWaitMs = 60_000L, + ) + } + + // ── Future phases (project creation, builds, preferences) go here ── + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt index 12be58244a..348e40141d 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/OrderedTestSuite.kt @@ -5,9 +5,7 @@ import org.junit.runners.Suite @RunWith(Suite::class) @Suite.SuiteClasses( - WelcomeScreenTest::class, - PermissionsScreenTest::class, - ProjectBuildTestWithKtsGradle::class, - CleanupTest::class + CleanupTest::class, + EndToEndTest::class, ) -class OrderedTestSuite \ No newline at end of file +class OrderedTestSuite diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt deleted file mode 100644 index 7f3169f5a8..0000000000 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.itsaky.androidide - -import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled -import androidx.test.ext.junit.rules.activityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.itsaky.androidide.R -import com.itsaky.androidide.activities.SplashActivity -import com.itsaky.androidide.helper.grantAllRequiredPermissionsThroughOnboardingUi -import com.itsaky.androidide.helper.passPermissionsInfoSlideWithPrivacyDialog -import com.itsaky.androidide.screens.OnboardingScreen -import com.itsaky.androidide.screens.PermissionScreen -import com.itsaky.androidide.utils.PermissionsHelper -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PermissionsScreenTest : TestCase() { - - @get:Rule - val activityRule = activityScenarioRule() - - @After - fun cleanUp() { - InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand( - "pm clear ${BuildConfig.APPLICATION_ID} && pm reset-permissions ${BuildConfig.APPLICATION_ID}", - ) - } - - @Test - fun test_permissionsScreen_greenCheckMarksAppearCorrectly() = run { - step("Wait for app to start") { - flakySafely(timeoutMs = 10_000) { - device.uiDevice.waitForIdle(5000) - } - } - - step("Click continue button on the Welcome Screen") { - flakySafely(timeoutMs = 15_000) { - OnboardingScreen.nextButton { - isVisible() - isClickable() - click() - } - } - } - - passPermissionsInfoSlideWithPrivacyDialog() - - val targetContext = InstrumentationRegistry.getInstrumentation().targetContext - val required = PermissionsHelper.getRequiredPermissions(targetContext) - - step("Verify items on the Permission Screen") { - PermissionScreen { - flakySafely(timeoutMs = 10_000) { - title { - isVisible() - } - } - - flakySafely(timeoutMs = 8000) { - subTitle { - isVisible() - } - } - - flakySafely(timeoutMs = 15_000) { - rvPermissions { - isVisible() - isDisplayed() - } - } - - flakySafely(timeoutMs = 15_000) { - assertEquals(required.size, rvPermissions.getSize()) - } - - rvPermissions { - required.forEachIndexed { index, item -> - flakySafely(timeoutMs = 10_000) { - childAt(index) { - title { - isVisible() - hasText(item.title) - } - description { - isVisible() - hasText(item.description) - } - grantButton { - isVisible() - isClickable() - hasText(R.string.title_grant) - } - } - } - } - } - } - } - - grantAllRequiredPermissionsThroughOnboardingUi() - - step("Confirm Android reports all required permissions granted") { - flakySafely(timeoutMs = 20_000) { - assertTrue(PermissionsHelper.areAllPermissionsGranted(targetContext)) - } - } - - step("Confirm that all grant actions are complete (buttons disabled)") { - flakySafely(timeoutMs = 15_000) { - device.uiDevice.waitForIdle(2000) - PermissionScreen { - rvPermissions { - required.indices.forEach { index -> - childAt(index) { - grantButton { - isNotEnabled() - } - } - } - } - } - } - } - } -} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithGroovyGradle.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithGroovyGradle.kt index 31fadd398a..72b29811e3 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithGroovyGradle.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithGroovyGradle.kt @@ -1,8 +1,6 @@ package com.itsaky.androidide -import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.platform.app.InstrumentationRegistry -import com.itsaky.androidide.activities.SplashActivity import com.itsaky.androidide.helper.initializeProjectAndCancelBuild import com.itsaky.androidide.helper.navigateToMainScreen import com.itsaky.androidide.helper.selectProjectTemplate @@ -12,21 +10,11 @@ import com.itsaky.androidide.screens.ProjectSettingsScreen.selectJavaLanguage import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage import com.itsaky.androidide.screens.ProjectSettingsScreen.uncheckKotlinScript import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.After -import org.junit.Rule import org.junit.Test class ProjectBuildTestWithGroovyGradle : TestCase() { - @get:Rule - val activityRule = activityScenarioRule() - - @After - fun cleanUp() { - InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("pm clear ${BuildConfig.APPLICATION_ID} && pm reset-permissions ${BuildConfig.APPLICATION_ID}") - } - @Test fun test_projectBuild_emptyProject_java_groovyGradle() { run { diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithKtsGradle.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithKtsGradle.kt index 422bae6164..9bde1720c2 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithKtsGradle.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/ProjectBuildTestWithKtsGradle.kt @@ -1,8 +1,6 @@ package com.itsaky.androidide -import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.platform.app.InstrumentationRegistry -import com.itsaky.androidide.activities.SplashActivity import com.itsaky.androidide.helper.initializeProjectAndCancelBuild import com.itsaky.androidide.helper.navigateToMainScreen import com.itsaky.androidide.helper.selectProjectTemplate @@ -11,20 +9,10 @@ import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectPro import com.itsaky.androidide.screens.ProjectSettingsScreen.selectJavaLanguage import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.After -import org.junit.Rule import org.junit.Test class ProjectBuildTestWithKtsGradle : TestCase() { - @get:Rule - val activityRule = activityScenarioRule() - - @After - fun cleanUp() { - InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("pm clear ${BuildConfig.APPLICATION_ID} && pm reset-permissions ${BuildConfig.APPLICATION_ID}") - } - @Test fun test_projectBuild_emptyProject_java() { diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/WelcomeScreenTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/WelcomeScreenTest.kt deleted file mode 100644 index 72f3255b29..0000000000 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/WelcomeScreenTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.itsaky.androidide - -import androidx.test.ext.junit.rules.activityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.itsaky.androidide.activities.SplashActivity -import com.itsaky.androidide.screens.OnboardingScreen -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class WelcomeScreenTest : TestCase() { - - @get:Rule - val activityRule = activityScenarioRule() - - @After - fun cleanUp() { - InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("pm clear ${BuildConfig.APPLICATION_ID} && pm reset-permissions ${BuildConfig.APPLICATION_ID}") - } - - - @Test - fun test_welcomeScreen_itemsAppearCorrectly() { - // The SplashActivity immediately starts OnboardingActivity and finishes itself - // This might cause a race condition, so we need to wait for OnboardingActivity to be fully shown - Thread.sleep(1000) // Wait for activity transitions to complete - - OnboardingScreen { - greetingTitle.isVisible() - greetingSubtitle.isVisible() - nextButton { - isVisible() - isClickable() - } - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/AdvancePastWelcomeScreenHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/AdvancePastWelcomeScreenHelper.kt new file mode 100644 index 0000000000..ffbe5a17d6 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/AdvancePastWelcomeScreenHelper.kt @@ -0,0 +1,60 @@ +package com.itsaky.androidide.helper + +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.platform.app.InstrumentationRegistry +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext + +/** + * Clicks the Next button on the welcome screen using accessibility ACTION_CLICK. + * The button is in the system gesture exclusion zone so coordinate-based clicks fail. + */ +fun TestContext.advancePastWelcomeScreen() { + step("Click continue button on the Welcome Screen") { + flakySafely(timeoutMs = 3_000) { + device.uiDevice.waitForIdle() + + val clicked = accessibilityClickByDescription("NEXT") + || accessibilityClickByText("Next") + || accessibilityClickByText("Continue") + + if (!clicked) { + throw AssertionError("Next/Continue button not found on welcome screen") + } + device.uiDevice.waitForIdle() + } + } +} + +private fun accessibilityClickByDescription(desc: String): Boolean { + val root = InstrumentationRegistry.getInstrumentation().uiAutomation + .rootInActiveWindow ?: return false + val nodes = root.findAccessibilityNodeInfosByText(desc) + for (node in nodes) { + if (node.contentDescription?.toString()?.contains(desc, ignoreCase = true) == true) { + val result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + node.recycle() + root.recycle() + return result + } + node.recycle() + } + root.recycle() + return false +} + +private fun accessibilityClickByText(text: String): Boolean { + val root = InstrumentationRegistry.getInstrumentation().uiAutomation + .rootInActiveWindow ?: return false + val nodes = root.findAccessibilityNodeInfosByText(text) + for (node in nodes) { + if (node.text?.toString()?.contains(text, ignoreCase = true) == true) { + val result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) + node.recycle() + root.recycle() + return result + } + node.recycle() + } + root.recycle() + return false +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt index 66c956134d..8eb5f8f8ef 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt @@ -1,100 +1,108 @@ package com.itsaky.androidide.helper -import android.content.Context -import androidx.test.uiautomator.UiSelector +import android.Manifest +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.platform.app.InstrumentationRegistry import com.kaspersky.kaspresso.device.Device /** - * UiAutomator flows for system Settings / permission dialogs after tapping "Grant" on the onboarding - * permission list. Used by [grantAllRequiredPermissionsThroughOnboardingUi]. + * Grant permissions after tapping "Allow" on the onboarding permission list. + * Each method handles the Settings page that the app opened, grants the permission, + * and presses back to return to onboarding. + * + * Notifications uses grantRuntimePermission (standard runtime permission). + * Storage, Install, and Overlay use appops shell commands because the Settings UI + * varies across API levels and emulators, making UI-based toggling fragile. */ fun Device.grantPostNotificationsUi() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.grantRuntimePermission( + instrumentation.targetContext.packageName, + Manifest.permission.POST_NOTIFICATIONS, + ) val d = uiDevice - d.waitForIdle(1500) - val labels = listOf("Allow", "While using the app", "Only this time", "Allow notifications") - for (label in labels) { - val o = d.findObject(UiSelector().text(label)) - if (o.waitForExists(4000) && o.exists() && o.isEnabled) { - o.click() - d.waitForIdle(1500) - return - } - } + d.waitForIdle() + d.pressBack() + d.waitForIdle() } fun Device.grantStorageManageAllFilesUi() { - val d = uiDevice - d.waitForIdle(2000) - val texts = - listOf( - "Allow access to manage all files", - "Files and media", - "Access all files", - "Allow", - "Allow file management", - "Allow permission", - "Use USB storage", - "Storage", - "Files", - ) - for (t in texts) { - val o = d.findObject(UiSelector().text(t)) - if (o.waitForExists(3500) && o.exists() && o.isEnabled) { - o.click() - d.waitForIdle(2000) - d.pressBack() - d.waitForIdle(1500) - return - } - } + grantViaAppOpsAndBack("MANAGE_EXTERNAL_STORAGE") } fun Device.grantInstallUnknownAppsUi() { - val d = uiDevice - d.waitForIdle(2000) - val texts = - listOf( - "Allow from this source", - "Allow install of apps", - "Allow", - ) - for (t in texts) { - val o = d.findObject(UiSelector().text(t)) - if (o.waitForExists(3500) && o.exists() && o.isEnabled) { - o.click() - d.waitForIdle(2000) - d.pressBack() - d.waitForIdle(1500) - return - } - } + grantViaAppOpsAndBack("REQUEST_INSTALL_PACKAGES") } -fun Device.grantDisplayOverOtherAppsUi(candidates: List, context: Context) { - val d = uiDevice - d.waitForIdle(2000) - for (label in candidates) { - if (label.isBlank()) continue - val row = d.findObject(UiSelector().text(label)) - if (row.waitForExists(6000) && row.exists()) { - row.click() - d.waitForIdle(2000) - val switchNode = d.findObject(UiSelector().className("android.widget.Switch")) - if (switchNode.waitForExists(4000) && switchNode.exists()) { - if (!switchNode.isChecked) { - switchNode.click() - } - } else { - val toggle = - d.findObject(UiSelector().resourceId("android:id/switch_widget")) - if (toggle.waitForExists(3000) && toggle.exists() && !toggle.isChecked) { - toggle.click() - } +fun Device.grantDisplayOverOtherAppsUi() { + grantViaAppOpsAndBack("SYSTEM_ALERT_WINDOW") +} + +/** + * Finds accessibility nodes matching [searchText] and clicks the first one accepted by [matchBy]. + * + * Handles root-window acquisition, node iteration, recycling, and throws + * [AssertionError] if no matching node was clicked. + */ +fun clickFirstAccessibilityNodeByText( + searchText: String, + errorLabel: String = searchText, + matchBy: (AccessibilityNodeInfo) -> Boolean = { node -> + node.text?.toString().equals(searchText, ignoreCase = true) == true + && node.isClickable + && node.isEnabled + && node.isVisibleToUser + }, +) { + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + val root = uiAutomation.rootInActiveWindow + ?: throw AssertionError("No active window for accessibility") + + val nodes = root.findAccessibilityNodeInfosByText(searchText) + var clicked = false + try { + for (node in nodes) { + if (!clicked && matchBy(node)) { + clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK) } - d.waitForIdle(1500) - d.pressBack() - d.waitForIdle(1500) - return + node.recycle() } + } finally { + root.recycle() + } + + if (!clicked) { + throw AssertionError("No '$errorLabel' button found via accessibility") + } +} + +/** Appops that are granted via [grantViaAppOpsAndBack] and must be explicitly revoked in cleanup. */ +private val APPOPS_PERMISSIONS = listOf( + "MANAGE_EXTERNAL_STORAGE", + "REQUEST_INSTALL_PACKAGES", + "SYSTEM_ALERT_WINDOW", +) + +/** + * Resets appops permissions back to default and clears app data. + * + * `pm clear` / `pm reset-permissions` only handle runtime permissions; + * appops granted via `appops set ... allow` survive those commands. + */ +fun resetAppPermissionsAndClear(pkg: String) { + val ua = InstrumentationRegistry.getInstrumentation().uiAutomation + for (op in APPOPS_PERMISSIONS) { + ua.executeShellCommand("appops set $pkg $op default") } + ua.executeShellCommand("pm clear $pkg && pm reset-permissions $pkg") +} + +private fun Device.grantViaAppOpsAndBack(appOp: String) { + val pkg = InstrumentationRegistry.getInstrumentation().targetContext.packageName + InstrumentationRegistry.getInstrumentation().uiAutomation + .executeShellCommand("appops set $pkg $appOp allow") + val d = uiDevice + d.waitForIdle() + d.pressBack() + d.waitForIdle() } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt index 55bcb502fe..00878cf82a 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt @@ -9,29 +9,29 @@ import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext /** * Drives the onboarding permission list and system Settings UIs for every entry in - * [PermissionsHelper.getRequiredPermissions]. Matches [com.itsaky.androidide.PermissionsScreenTest] - * so scenarios like [com.itsaky.androidide.scenarios.NavigateToMainScreenScenario] stay in sync - * with API-level permission sets (e.g. notifications + overlay on API 33+). + * [PermissionsHelper.getRequiredPermissions]. */ fun TestContext.grantAllRequiredPermissionsThroughOnboardingUi() { val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val required = PermissionsHelper.getRequiredPermissions(targetContext) - val appLabel = - targetContext.applicationInfo.loadLabel(targetContext.packageManager).toString() required.forEachIndexed { index, item -> step("Grant: ${targetContext.getString(item.title)}") { - flakySafely(timeoutMs = 120_000) { + flakySafely(timeoutMs = 10_000) { + // Scroll to the permission item and click its grant button via accessibility + // ACTION_CLICK. The button may be in the gesture exclusion zone where + // coordinate-based clicks (Espresso) get swallowed by the OS. PermissionScreen { rvPermissions { childAt(index) { grantButton { isVisible() - click() } } } } + clickFirstGrantButton() + when (item.permission) { Manifest.permission.POST_NOTIFICATIONS -> { device.grantPostNotificationsUi() @@ -43,14 +43,7 @@ fun TestContext.grantAllRequiredPermissionsThroughOnboardingUi() { device.grantInstallUnknownAppsUi() } Manifest.permission.SYSTEM_ALERT_WINDOW -> { - device.grantDisplayOverOtherAppsUi( - listOf( - appLabel, - targetContext.getString(R.string.app_name), - targetContext.packageName, - ), - targetContext, - ) + device.grantDisplayOverOtherAppsUi() } else -> { throw IllegalStateException("Unknown permission row: ${item.permission}") @@ -60,3 +53,16 @@ fun TestContext.grantAllRequiredPermissionsThroughOnboardingUi() { } } } + +/** + * Clicks the first visible, enabled "Allow" grant button using accessibility ACTION_CLICK. + * + * We always click the FIRST "Allow" button because permissions are granted in order. + * After each grant, the button text changes (e.g. to a checkmark), so the first remaining + * "Allow" is always the next permission to grant. + */ +private fun clickFirstGrantButton() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val grantText = ctx.getString(R.string.title_grant) + clickFirstAccessibilityNodeByText(grantText) +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt index 9960d9cab7..fe4025a342 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.helper import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiSelector -import com.itsaky.androidide.screens.OnboardingScreen import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext /** @@ -11,25 +10,35 @@ import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext */ fun TestContext.passPermissionsInfoSlideWithPrivacyDialog() { step("Permissions info: accept privacy dialog if shown") { - flakySafely(timeoutMs = 25_000) { - val ctx = InstrumentationRegistry.getInstrumentation().targetContext - val accept = - ctx.getString(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) - val d = device.uiDevice - val btn = d.findObject(UiSelector().text(accept)) - if (btn.waitForExists(12_000) && btn.exists()) { - btn.click() - d.waitForIdle(1500) - } + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val accept = + ctx.getString(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) + val d = device.uiDevice + val btn = d.findObject(UiSelector().text(accept)) + if (btn.waitForExists(2_000)) { + clickFirstAccessibilityNodeByText(accept) + d.waitForIdle() } } step("Continue from permissions information slide") { - flakySafely(timeoutMs = 25_000) { - OnboardingScreen.nextButton { - isVisible() - isClickable() - click() - } + // After dismissing the dialog, the accessibility tree transitions from 2 windows + // to 1. Use UIAutomator's waitForExists (which handles window transitions) to + // wait for the NEXT button to become reachable, then click via accessibility. + val d = device.uiDevice + val nextObj = d.findObject(UiSelector().descriptionContains("NEXT")) + if (!nextObj.waitForExists(3_000)) { + throw AssertionError("NEXT button not found on permissions info slide") } + + clickFirstAccessibilityNodeByText( + searchText = "NEXT", + errorLabel = "NEXT", + matchBy = { node -> + val desc = node.contentDescription?.toString() ?: "" + val text = node.text?.toString() ?: "" + desc.contains("NEXT", ignoreCase = true) || text.contains("NEXT", ignoreCase = true) + }, + ) + d.waitForIdle() } } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt index 596a5ed1f4..7e06514ea5 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt @@ -1,55 +1,103 @@ package com.itsaky.androidide.scenarios -import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.helper.advancePastWelcomeScreen +import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByText import com.itsaky.androidide.helper.grantAllRequiredPermissionsThroughOnboardingUi import com.itsaky.androidide.helper.logOnboardingNavigation import com.itsaky.androidide.helper.passPermissionsInfoSlideWithPrivacyDialog import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi import com.itsaky.androidide.screens.InstallToolsScreen -import com.itsaky.androidide.screens.OnboardingScreen import com.itsaky.androidide.screens.PermissionScreen import com.kaspersky.kaspresso.testcases.api.scenario.Scenario import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector +/** + * Navigates from whatever the current onboarding state is to the main screen. + * + * Each step checks whether it's needed before acting, so this scenario is + * idempotent — safe to call whether onboarding is at the welcome screen, + * the permissions list, or already on the main screen. + */ class NavigateToMainScreenScenario : Scenario() { override val steps: TestContext.() -> Unit = { - logOnboardingNavigation("NavigateToMainScreenScenario: first step") - step("Click continue button on the Welcome Screen") { - try { - OnboardingScreen.nextButton { - click() - } - } catch (e: Exception) { - val nextByDesc = - device.uiDevice.findObject(UiSelector().descriptionContains("NEXT")) - val nextByText = device.uiDevice.findObject(UiSelector().textContains("Next")) - val continueByText = - device.uiDevice.findObject(UiSelector().textContains("Continue")) - when { - nextByDesc.exists() -> nextByDesc.click() - nextByText.exists() -> nextByText.click() - continueByText.exists() -> continueByText.click() - else -> println("Next/Continue button not found on onboarding: ${e.message}") - } + logOnboardingNavigation("NavigateToMainScreenScenario: starting") + + val d = device.uiDevice + val pkg = InstrumentationRegistry.getInstrumentation().targetContext.packageName + + // If we're already on the main screen, skip everything + step("Check if already on main screen") { + d.waitForIdle() + val getStarted = d.findObject(UiSelector().resourceIdMatches(".*:id/getStarted")) + val editor = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) + if (getStarted.waitForExists(1_000) || editor.exists()) { + logOnboardingNavigation("Already on main screen — skipping onboarding") + return@step } } - passPermissionsInfoSlideWithPrivacyDialog() - step("Wait for onboarding permission list") { - flakySafely(timeoutMs = 30_000) { - PermissionScreen { - title { - isVisible() - } - rvPermissions { - isDisplayed() - } + // If the welcome screen NEXT button is visible, advance past it + step("Advance past welcome screen (if showing)") { + val nextBtn = d.findObject(UiSelector().descriptionContains("NEXT")) + val permissionTitle = d.findObject(UiSelector().resourceIdMatches(".*:id/onboarding_title")) + // Only advance if we appear to be on the welcome slide (NEXT visible but not yet on permissions) + if (nextBtn.waitForExists(1_000)) { + // Check if we're on the welcome slide vs permissions info slide + // The welcome slide has a greeting title, not the permissions title + val greetingTitle = d.findObject(UiSelector().resourceIdMatches(".*:id/title").textContains("Code on the Go")) + if (greetingTitle.exists()) { + logOnboardingNavigation("Welcome screen visible — advancing") + advancePastWelcomeScreen() } } } - grantAllRequiredPermissionsThroughOnboardingUi() + // If the privacy dialog is showing, accept it + step("Accept privacy dialog (if showing)") { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val accept = ctx.getString(com.itsaky.androidide.resources.R.string.privacy_disclosure_accept) + val btn = d.findObject(UiSelector().text(accept)) + if (btn.waitForExists(1_000)) { + logOnboardingNavigation("Privacy dialog visible — accepting") + clickFirstAccessibilityNodeByText(accept) + d.waitForIdle() + } + } + + // If NEXT button is visible (permissions info slide), advance to permissions list + step("Advance past permissions info (if showing)") { + val nextBtn = d.findObject(UiSelector().descriptionContains("NEXT")) + if (nextBtn.waitForExists(1_000)) { + logOnboardingNavigation("Permissions info slide — clicking NEXT") + clickFirstAccessibilityNodeByText( + searchText = "NEXT", + errorLabel = "NEXT", + matchBy = { node -> + val desc = node.contentDescription?.toString() ?: "" + val text = node.text?.toString() ?: "" + desc.contains("NEXT", ignoreCase = true) || text.contains("NEXT", ignoreCase = true) + }, + ) + d.waitForIdle() + } + } + + // If the permission list is showing, grant all permissions + step("Grant permissions (if on permission list)") { + val permScreen = d.findObject(UiSelector().resourceIdMatches(".*:id/onboarding_items")) + if (permScreen.waitForExists(2_000)) { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val grantText = ctx.getString(com.itsaky.androidide.R.string.title_grant) + val grantBtn = d.findObject(UiSelector().text(grantText)) + if (grantBtn.exists()) { + logOnboardingNavigation("Permission list visible with ungranted permissions — granting") + grantAllRequiredPermissionsThroughOnboardingUi() + } + } + } step("Finish installation (leave permission screen)") { flakySafely(timeoutMs = 20_000) { @@ -64,7 +112,7 @@ class NavigateToMainScreenScenario : Scenario() { } } - step("After Finish installation: optional AppIntro Done, then wait for IDE setup → main UI") { + step("After Finish installation: optional AppIntro Done, then wait for IDE setup -> main UI") { logOnboardingNavigation( "Permissions Finish starts in-app IDE setup; AppIntro R.id.done is often absent — waiting for main UI", ) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionsInfoScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionsInfoScreen.kt new file mode 100644 index 0000000000..020e18ab69 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionsInfoScreen.kt @@ -0,0 +1,26 @@ +package com.itsaky.androidide.screens + +import android.view.View +import com.itsaky.androidide.R +import com.kaspersky.kaspresso.screens.KScreen +import io.github.kakaocup.kakao.recycler.KRecyclerItem +import io.github.kakaocup.kakao.recycler.KRecyclerView +import io.github.kakaocup.kakao.text.KTextView +import org.hamcrest.Matcher + +object PermissionsInfoScreen : KScreen() { + + override val layoutId: Int? = null + override val viewClass: Class<*>? = null + + val introText = KTextView { withId(R.id.intro_text) } + + val permissionsList = KRecyclerView( + builder = { withId(R.id.permissions_list) }, + itemTypeBuilder = { itemType(::InfoItem) }, + ) + + class InfoItem(matcher: Matcher) : KRecyclerItem(matcher) { + val text = KTextView(matcher) { withId(R.id.text) } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index ea8e16c63a..770299bcab 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -49,6 +49,7 @@ import com.itsaky.androidide.preferences.internal.prefManager import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.isAtLeastV import com.itsaky.androidide.utils.isSystemInDarkMode @@ -208,9 +209,11 @@ class OnboardingActivity : AppIntro2() { override fun onResume() { super.onResume() - lifecycleScope.launch { - reloadJdkDistInfo { - tryNavigateToMainIfSetupIsCompleted() + if (!isTestMode()) { + lifecycleScope.launch { + reloadJdkDistInfo { + tryNavigateToMainIfSetupIsCompleted() + } } } } @@ -232,12 +235,9 @@ class OnboardingActivity : AppIntro2() { override fun onPageSelected(position: Int) { super.onPageSelected(position) - if (nextButton.isVisible) { - if (nextButton.animation == null) { - nextButton.startAnimation(pulseAnimation) - } - } else { - nextButton.clearAnimation() + when { + !nextButton.isVisible -> nextButton.clearAnimation() + !isTestMode() && nextButton.animation == null -> nextButton.startAnimation(pulseAnimation) } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt index 487bc5f4b4..271c6c14a1 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/onboarding/PermissionsFragment.kt @@ -43,6 +43,7 @@ import com.itsaky.androidide.events.InstallationEvent import com.itsaky.androidide.tasks.doAsyncWithProgress import com.itsaky.androidide.utils.PermissionsHelper import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.isTestMode import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.isAtLeastR import com.itsaky.androidide.utils.viewLifecycleScope @@ -356,7 +357,9 @@ class PermissionsFragment : private fun enableFinishButton() { finishButton?.isEnabled = true - finishButton?.startAnimation(pulseAnimation) + if (!isTestMode()) { + finishButton?.startAnimation(pulseAnimation) + } } private fun disableFinishButton() {