From 4bf98a0e1e0ff9b2654ce50bcf82f9b85f753bba Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 8 Apr 2026 19:32:55 -0700 Subject: [PATCH] ADFA-2738 Part 1: onboarding --- .../androidide/PermissionsScreenTest.kt | 224 +++------------ .../helper/DevicePermissionGrantUiHelper.kt | 100 +++++++ .../helper/EnsureHomeScreenHelper.kt | 269 ++++++++++++++++++ .../GrantRequiredPermissionsUiHelper.kt | 62 ++++ .../helper/OnboardingPermissionsInfoHelper.kt | 35 +++ .../scenarios/NavigateToMainScreenScenario.kt | 166 +++-------- .../androidide/screens/PermissionScreen.kt | 2 + 7 files changed, 558 insertions(+), 300 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/EnsureHomeScreenHelper.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt index 861d2d0a6b..7f3169f5a8 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/PermissionsScreenTest.kt @@ -4,13 +4,17 @@ 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.screens.SystemPermissionsScreen +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 @@ -23,21 +27,21 @@ class PermissionsScreenTest : TestCase() { @After fun cleanUp() { - InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("pm clear ${BuildConfig.APPLICATION_ID} && pm reset-permissions ${BuildConfig.APPLICATION_ID}") + 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 = 10000) { - // Give app time to fully initialize + flakySafely(timeoutMs = 10_000) { device.uiDevice.waitForIdle(5000) } } step("Click continue button on the Welcome Screen") { - flakySafely(timeoutMs = 15000) { + flakySafely(timeoutMs = 15_000) { OnboardingScreen.nextButton { isVisible() isClickable() @@ -46,10 +50,14 @@ class PermissionsScreenTest : TestCase() { } } + passPermissionsInfoSlideWithPrivacyDialog() + + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val required = PermissionsHelper.getRequiredPermissions(targetContext) + step("Verify items on the Permission Screen") { PermissionScreen { - // Wait for screen to fully load - flakySafely(timeoutMs = 10000) { + flakySafely(timeoutMs = 10_000) { title { isVisible() } @@ -61,201 +69,59 @@ class PermissionsScreenTest : TestCase() { } } - flakySafely(timeoutMs = 15000) { + flakySafely(timeoutMs = 15_000) { rvPermissions { isVisible() - // Wait for RecyclerView to fully load isDisplayed() } } - // Make the size check flaky-safe with increased timeout - flakySafely(timeoutMs = 15000) { - assertEquals(2, rvPermissions.getSize()) + flakySafely(timeoutMs = 15_000) { + assertEquals(required.size, rvPermissions.getSize()) } rvPermissions { - flakySafely(timeoutMs = 10000) { - childAt(0) { - title { - isVisible() - hasText(R.string.permission_title_storage) - } - description { - isVisible() - hasText(R.string.permission_desc_storage) - } - grantButton { - isVisible() - isClickable() - hasText(R.string.title_grant) - } - } - } - - flakySafely(timeoutMs = 10000) { - childAt(1) { - title { - isVisible() - hasText(R.string.permission_title_install_packages) - } - description { - isVisible() - hasText(R.string.permission_desc_install_packages) - } - grantButton { - isVisible() - isClickable() - hasText(R.string.title_grant) - } - } - } - } - } - } - - step("Grant Storage Permissions") { - flakySafely(timeoutMs = 30000) { - PermissionScreen { - rvPermissions { - childAt(0) { - grantButton.click() - } - } - - // Wait for system permission dialog to appear - device.uiDevice.waitForIdle(3000) - - SystemPermissionsScreen { - try { - // Try the original permission text first - storagePermissionView { - isDisplayed() - click() - } - } catch (e: Exception) { - println("Trying alternative text for storage permission: ${e.message}") - try { - storagePermissionViewAlt1 { - isDisplayed() - click() + required.forEachIndexed { index, item -> + flakySafely(timeoutMs = 10_000) { + childAt(index) { + title { + isVisible() + hasText(item.title) } - } catch (e1: Exception) { - try { - storagePermissionViewAlt2 { - isDisplayed() - click() - } - } catch (e2: Exception) { - try { - storagePermissionViewAlt3 { - isDisplayed() - click() - } - } catch (e3: Exception) { - try { - storagePermissionViewAlt4 { - isDisplayed() - click() - } - } catch (e4: Exception) { - try { - storagePermissionViewAlt5 { - isDisplayed() - click() - } - } catch (e5: Exception) { - try { - storagePermissionViewAlt6 { - isDisplayed() - click() - } - } catch (e6: Exception) { - try { - storagePermissionViewAlt7 { - isDisplayed() - click() - } - } catch (e7: Exception) { - storagePermissionViewAlt8 { - isDisplayed() - click() - } - } - } - } - } - } + description { + isVisible() + hasText(item.description) + } + grantButton { + isVisible() + isClickable() + hasText(R.string.title_grant) } } } - } - - // Wait after click and before going back - device.uiDevice.waitForIdle(2000) - device.uiDevice.pressBack() - device.uiDevice.waitForIdle(2000) } } } - step("Grant Install Packages Permissions") { - flakySafely(timeoutMs = 30000) { - PermissionScreen { - rvPermissions { - childAt(1) { - grantButton.click() - } - } - - // Wait for system permission dialog to appear - device.uiDevice.waitForIdle(3000) + grantAllRequiredPermissionsThroughOnboardingUi() - SystemPermissionsScreen { - try { - // Try the original permission text first - installPackagesPermission { - isDisplayed() - click() - } - } catch (e: Exception) { - println("Trying alternative text for install packages permission: ${e.message}") - try { - installPackagesPermissionAlt1 { - isDisplayed() - click() - } - } catch (e: Exception) { - installPackagesPermissionAlt2 { - isDisplayed() - click() - } - } - } - } - - // Wait after click and before going back - device.uiDevice.waitForIdle(2000) - device.uiDevice.pressBack() - device.uiDevice.waitForIdle(2000) - } + step("Confirm Android reports all required permissions granted") { + flakySafely(timeoutMs = 20_000) { + assertTrue(PermissionsHelper.areAllPermissionsGranted(targetContext)) } } - step("Confirm that all menu items don't have allow text") { - flakySafely(timeoutMs = 15000) { + step("Confirm that all grant actions are complete (buttons disabled)") { + flakySafely(timeoutMs = 15_000) { device.uiDevice.waitForIdle(2000) PermissionScreen { rvPermissions { - childAt(0) { - grantButton { - isNotEnabled() - } - } - childAt(1) { - grantButton { - isNotEnabled() + required.indices.forEach { index -> + childAt(index) { + grantButton { + isNotEnabled() + } } } } @@ -263,4 +129,4 @@ class PermissionsScreenTest : TestCase() { } } } -} \ No newline at end of file +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt new file mode 100644 index 0000000000..66c956134d --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/DevicePermissionGrantUiHelper.kt @@ -0,0 +1,100 @@ +package com.itsaky.androidide.helper + +import android.content.Context +import androidx.test.uiautomator.UiSelector +import com.kaspersky.kaspresso.device.Device + +/** + * UiAutomator flows for system Settings / permission dialogs after tapping "Grant" on the onboarding + * permission list. Used by [grantAllRequiredPermissionsThroughOnboardingUi]. + */ +fun Device.grantPostNotificationsUi() { + 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 + } + } +} + +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 + } + } +} + +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 + } + } +} + +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() + } + } + d.waitForIdle(1500) + d.pressBack() + d.waitForIdle(1500) + return + } + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/EnsureHomeScreenHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/EnsureHomeScreenHelper.kt new file mode 100644 index 0000000000..08acde6154 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/EnsureHomeScreenHelper.kt @@ -0,0 +1,269 @@ +package com.itsaky.androidide.helper + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.BuildConfig +import com.itsaky.androidide.R +import com.itsaky.androidide.activities.MainActivity +import com.itsaky.androidide.preferences.internal.GeneralPreferences +import com.itsaky.androidide.screens.HomeScreen +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext +import org.hamcrest.Matchers + +private const val TAG = "EnsureHomeScreen" + +private enum class PostOnboardingScreen { + HOME, + EDITOR, + UNKNOWN, +} + +/** Gradle often hides [println]; logcat + stderr show these reliably. */ +private fun testLog(msg: String) { + Log.e(TAG, msg) + System.err.println("$TAG: $msg") +} + +/** + * After onboarding, [MainActivity] may open the last project. We log state (logcat: `EnsureHomeScreen`), + * then if we appear to be in the editor: open the drawer (hamburger), tap close project, tap + * **Close without saving**. If still not home, disable auto-open, clear last path, and relaunch + * [MainActivity] — **preferences are not restored** so auto-open cannot immediately reopen the + * project during the same instrumentation run. + */ +fun TestContext.ensureOnHomeScreenBeforeCreateProject() { + testLog("ensureOnHomeScreenBeforeCreateProject() entered") + + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val pkg = BuildConfig.APPLICATION_ID + val getStartedId = "$pkg:id/getStarted" + val getStartedText = targetContext.getString(R.string.get_started) + + step("Decline auto-open last project if confirmation dialog is shown") { + runCatching { + val title = targetContext.getString(R.string.title_confirm_open_project) + val dialogTitle = device.uiDevice.findObject(UiSelector().text(title)) + if (dialogTitle.waitForExists(4000) && dialogTitle.exists()) { + testLog("Dismissing 'open last project?' dialog") + val noLabel = targetContext.getString(R.string.no) + runCatching { device.uiDevice.findObject(UiSelector().text(noLabel)).click() } + device.uiDevice.waitForIdle(2000) + } + } + } + + step("Ensure main home — detect screen, drawer close, or relaunch") { + flakySafely(timeoutMs = 180_000) { + device.uiDevice.waitForIdle(4000) + + val state = detectPostOnboardingScreen(device.uiDevice, pkg, getStartedId) + testLog("Post-onboarding screen=$state (package=$pkg)") + + when (state) { + PostOnboardingScreen.EDITOR -> + runCatching { + testLog("Attempting drawer → close project → close without saving") + closeProjectViaDrawerThenCloseWithoutSaving(device.uiDevice, targetContext) + }.onFailure { testLog("Drawer close failed: ${it.message}") } + + PostOnboardingScreen.UNKNOWN -> + testLog("Screen UNKNOWN — will try relaunch if home not visible") + + PostOnboardingScreen.HOME -> testLog("Screen looks like HOME already") + } + + device.uiDevice.waitForIdle(3000) + + if (isGetStartedVisible(device.uiDevice, getStartedId)) { + testLog("Get started visible — asserting HomeScreen") + HomeScreen { + title { + isVisible() + withText(Matchers.equalToIgnoringCase(getStartedText)) + } + } + return@flakySafely + } + + testLog("Home still missing — relaunch MainActivity (autoOpen=false, clear last path)") + GeneralPreferences.autoOpenProjects = false + GeneralPreferences.lastOpenedProject = GeneralPreferences.NO_OPENED_PROJECT + relaunchMainActivityClearTask(targetContext) + device.uiDevice.waitForIdle(6000) + + testLog("After relaunch, asserting HomeScreen") + HomeScreen { + title { + isVisible() + withText(Matchers.equalToIgnoringCase(getStartedText)) + } + } + } + } +} + +private fun detectPostOnboardingScreen(d: UiDevice, pkg: String, getStartedId: String): PostOnboardingScreen { + val editorById = d.findObject(UiSelector().resourceId("$pkg:id/editor_appBarLayout")) + val editorByPattern = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) + for (node in listOf(editorById, editorByPattern)) { + if (node.waitForExists(3000) && node.exists()) { + val b = runCatching { node.visibleBounds }.getOrNull() + if (b != null && b.width() > 4 && b.height() > 4) { + return PostOnboardingScreen.EDITOR + } + } + } + + val home = d.findObject(UiSelector().resourceId(getStartedId)) + if (home.waitForExists(3000) && home.exists()) { + val hb = runCatching { home.visibleBounds }.getOrNull() + if (hb != null && hb.width() > 4 && hb.height() > 4) { + return PostOnboardingScreen.HOME + } + } + + return PostOnboardingScreen.UNKNOWN +} + +private fun isGetStartedVisible(d: UiDevice, getStartedId: String): Boolean { + val byId = d.findObject(UiSelector().resourceId(getStartedId)) + val nodes = listOf(byId, d.findObject(UiSelector().resourceIdMatches(".*:id/getStarted"))) + for (n in nodes) { + if (n.waitForExists(2000) && n.exists()) { + val b = runCatching { n.visibleBounds }.getOrNull() ?: continue + if (b.width() > 4 && b.height() > 4) { + return true + } + } + } + val text = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.get_started) + val byText = d.findObject(UiSelector().text(text)) + return byText.waitForExists(2000) && byText.exists() && + runCatching { byText.visibleBounds }.getOrNull()?.let { it.width() > 4 && it.height() > 4 } == true +} + +private fun closeProjectViaDrawerThenCloseWithoutSaving(d: UiDevice, context: Context) { + val openDrawer = context.getString(R.string.cd_drawer_open) + val closeDrawer = context.getString(R.string.cd_drawer_close) + val closeProjectLabel = context.getString(R.string.title_close_project) + val closeWithoutSaving = context.getString(R.string.close_without_saving) + + testLog("Looking for drawer nav: desc='$openDrawer' or '$closeDrawer'") + val navToClick = + listOf( + UiSelector().description(openDrawer), + UiSelector().description(closeDrawer), + UiSelector().descriptionContains(openDrawer), + UiSelector().descriptionContains(closeDrawer), + ).firstNotNullOfOrNull { sel -> + val o = d.findObject(sel) + if (o.waitForExists(5000) && o.exists()) o else null + } + + if (navToClick == null) { + testLog("Drawer icon not found by description — trying first toolbar ImageButton") + val btn = d.findObject(UiSelector().className("android.widget.ImageButton").instance(0)) + if (btn.waitForExists(3000) && btn.exists()) { + runCatching { btn.click() } + } else { + error("No navigation / ImageButton for drawer") + } + } else { + runCatching { navToClick.click() } + } + d.waitForIdle(2500) + + testLog("Looking for close-project control (desc or text='$closeProjectLabel')") + val closeControl = + listOf( + UiSelector().description(closeProjectLabel), + UiSelector().descriptionContains(closeProjectLabel), + UiSelector().text(closeProjectLabel), + UiSelector().textContains(closeProjectLabel), + ).firstNotNullOfOrNull { sel -> + val o = d.findObject(sel) + if (o.waitForExists(10000) && o.exists()) o else null + } ?: error("Close project control not found") + + runCatching { closeControl.click() } + d.waitForIdle(3000) + + testLog("Looking for '$closeWithoutSaving'") + val withoutSaving = + listOf( + UiSelector().text(closeWithoutSaving), + UiSelector().textContains("without saving"), + ).firstNotNullOfOrNull { sel -> + val o = d.findObject(sel) + if (o.waitForExists(20000) && o.exists()) o else null + } ?: error("Close without saving not found") + + runCatching { withoutSaving.click() } + d.waitForIdle(4000) +} + +private fun relaunchMainActivityClearTask(context: Context) { + val intent = + Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + context.startActivity(intent) +} + +/** Logcat: `adb logcat -s OnboardNavigate:E` (plus stderr in Gradle when shown). */ +const val TAG_ONBOARD_NAV = "OnboardNavigate" + +fun logOnboardingNavigation(msg: String) { + Log.e(TAG_ONBOARD_NAV, msg) + System.err.println("$TAG_ONBOARD_NAV: $msg") +} + +/** + * After "Finish installation" on the permissions slide, [com.itsaky.androidide.fragments.onboarding.PermissionsFragment] + * runs IDE setup — there is **no** guarantee that AppIntro's `@id/done` exists ([OnboardingActivity] does not add + * [com.itsaky.androidide.fragments.onboarding.IdeSetupConfigurationFragment] as a slide). Poll until **Get started** + * or **editor** is visible. + */ +fun waitForMainHomeOrEditorUi(device: UiDevice, maxWaitMs: Long = 300_000L) { + val deadline = System.currentTimeMillis() + maxWaitMs + var lastLog = 0L + logOnboardingNavigation("Waiting up to ${maxWaitMs / 1000}s for main home or editor after IDE setup…") + while (System.currentTimeMillis() < deadline) { + if (mainHomeOrEditorVisible(device)) { + logOnboardingNavigation("Main UI visible (home Get started or editor app bar)") + return + } + val now = System.currentTimeMillis() + if (now - lastLog > 30_000L) { + val left = (deadline - now) / 1000 + logOnboardingNavigation("Still waiting… ~${left}s left (IDE setup can take minutes)") + lastLog = now + } + device.waitForIdle(2000) + Thread.sleep(400) + } + error("Timeout ${maxWaitMs}ms — main home / editor never appeared after IDE setup") +} + +private fun mainHomeOrEditorVisible(device: UiDevice): Boolean { + val getStarted = device.findObject(UiSelector().resourceIdMatches(".*:id/getStarted")) + if (getStarted.waitForExists(600) && getStarted.exists()) { + val b = runCatching { getStarted.visibleBounds }.getOrNull() + if (b != null && b.width() > 4 && b.height() > 4) { + return true + } + } + val editor = device.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) + if (editor.waitForExists(600) && editor.exists()) { + val b = runCatching { editor.visibleBounds }.getOrNull() + if (b != null && b.width() > 4 && b.height() > 4) { + return true + } + } + return false +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt new file mode 100644 index 0000000000..55bcb502fe --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/GrantRequiredPermissionsUiHelper.kt @@ -0,0 +1,62 @@ +package com.itsaky.androidide.helper + +import android.Manifest +import androidx.test.platform.app.InstrumentationRegistry +import com.itsaky.androidide.R +import com.itsaky.androidide.screens.PermissionScreen +import com.itsaky.androidide.utils.PermissionsHelper +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+). + */ +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) { + PermissionScreen { + rvPermissions { + childAt(index) { + grantButton { + isVisible() + click() + } + } + } + } + when (item.permission) { + Manifest.permission.POST_NOTIFICATIONS -> { + device.grantPostNotificationsUi() + } + Manifest.permission_group.STORAGE -> { + device.grantStorageManageAllFilesUi() + } + Manifest.permission.REQUEST_INSTALL_PACKAGES -> { + device.grantInstallUnknownAppsUi() + } + Manifest.permission.SYSTEM_ALERT_WINDOW -> { + device.grantDisplayOverOtherAppsUi( + listOf( + appLabel, + targetContext.getString(R.string.app_name), + targetContext.packageName, + ), + targetContext, + ) + } + else -> { + throw IllegalStateException("Unknown permission row: ${item.permission}") + } + } + } + } + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt new file mode 100644 index 0000000000..9960d9cab7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/OnboardingPermissionsInfoHelper.kt @@ -0,0 +1,35 @@ +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 + +/** + * Second onboarding slide ([com.itsaky.androidide.fragments.onboarding.PermissionsInfoFragment]): + * dismiss the privacy disclosure dialog if shown, then continue to the permission list slide. + */ +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) + } + } + } + step("Continue from permissions information slide") { + flakySafely(timeoutMs = 25_000) { + OnboardingScreen.nextButton { + isVisible() + isClickable() + click() + } + } + } +} 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 43e667ac51..596a5ed1f4 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/NavigateToMainScreenScenario.kt @@ -1,16 +1,20 @@ package com.itsaky.androidide.scenarios import androidx.test.uiautomator.UiSelector +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.itsaky.androidide.screens.SystemPermissionsScreen import com.kaspersky.kaspresso.testcases.api.scenario.Scenario import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext class NavigateToMainScreenScenario : Scenario() { override val steps: TestContext.() -> Unit = { + logOnboardingNavigation("NavigateToMainScreenScenario: first step") step("Click continue button on the Welcome Screen") { try { OnboardingScreen.nextButton { @@ -30,130 +34,27 @@ class NavigateToMainScreenScenario : Scenario() { } } } - val permissionsScreen = device.uiDevice.findObject(UiSelector().text("Permissions")) + passPermissionsInfoSlideWithPrivacyDialog() - if (permissionsScreen.exists()) { - step("Grant storage and install packages permissions") { + step("Wait for onboarding permission list") { + flakySafely(timeoutMs = 30_000) { PermissionScreen { - try { - rvPermissions { - childAt(0) { - grantButton.click() - } - } - } catch (e: Exception) { - println("Storage permission grant button not found: ${e.message}") - } - - SystemPermissionsScreen { - try { - // Try the original permission text first - storagePermissionView { - click() - } - } catch (e: Exception) { - println("Trying alternative text for storage permission") - try { - storagePermissionViewAlt1 { - click() - } - } catch (e1: Exception) { - try { - storagePermissionViewAlt2 { - click() - } - } catch (e2: Exception) { - try { - storagePermissionViewAlt3 { - click() - } - } catch (e3: Exception) { - try { - storagePermissionViewAlt4 { - click() - } - } catch (e4: Exception) { - try { - storagePermissionViewAlt5 { - click() - } - } catch (e5: Exception) { - try { - storagePermissionViewAlt6 { - click() - } - } catch (e6: Exception) { - try { - storagePermissionViewAlt7 { - click() - } - } catch (e7: Exception) { - try { - storagePermissionViewAlt8 { - click() - } - } catch (e8: Exception) { - println("No matching storage permission option found: ${e8.message}") - } - } - } - } - } - } - } - } - } - } - - device.uiDevice.pressBack() - - try { - rvPermissions { - childAt(1) { - grantButton.click() - } - } - } catch (e: Exception) { - println("Install packages grant button not found: ${e.message}") + title { + isVisible() } - - SystemPermissionsScreen { - try { - installPackagesPermission { - click() - } - } catch (e: Exception) { - println("Trying alternative text for install packages permission") - try { - installPackagesPermissionAlt1 { - click() - } - } catch (e1: Exception) { - println("Trying second alternative text for install packages permission") - installPackagesPermissionAlt2 { - click() - } - } - } + rvPermissions { + isDisplayed() } - - device.uiDevice.pressBack() - } - OnboardingScreen.nextButton { - isVisible() - isClickable() - click() } } - } else { - println("skip permissions") } - step("Click continue button on the Install Tools Screen") { - flakySafely(120000) { - device.uiDevice.waitForIdle(10000) - InstallToolsScreen.doneButton { - flakySafely(20000) { + grantAllRequiredPermissionsThroughOnboardingUi() + + step("Finish installation (leave permission screen)") { + flakySafely(timeoutMs = 20_000) { + PermissionScreen { + finishInstallationButton { isVisible() isEnabled() isClickable() @@ -163,10 +64,33 @@ class NavigateToMainScreenScenario : Scenario() { } } - step("Decline notifications permissions") { - flakySafely(1000000) { - device.permissions.isDialogVisible() - device.permissions.denyViaDialog() + 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", + ) + runCatching { + flakySafely(timeoutMs = 12_000) { + InstallToolsScreen.doneButton { + isVisible() + click() + } + } + }.fold( + onSuccess = { logOnboardingNavigation("Clicked legacy AppIntro Done (optional)") }, + onFailure = { + logOnboardingNavigation( + "No AppIntro Done within 12s (expected): ${it.javaClass.simpleName} ${it.message}", + ) + }, + ) + waitForMainHomeOrEditorUi(device.uiDevice, maxWaitMs = 300_000L) + } + + step("Decline runtime permission dialog if still shown") { + runCatching { + flakySafely(timeoutMs = 8000) { + device.permissions.denyViaDialog() + } } } } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionScreen.kt index 42360355cf..50b158eea4 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/PermissionScreen.kt @@ -22,6 +22,8 @@ object PermissionScreen : KScreen() { itemTypeBuilder = { itemType(::PermissionItem) } ) + val finishInstallationButton = KButton { withId(R.id.finish_installation_button) } + class PermissionItem(matcher: Matcher) : KRecyclerItem(matcher) { val grantButton = KButton(matcher) { withId(R.id.grant_button) }