From ad4395279d98a19f1ed4a8ab3e008c61170be913 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Sat, 16 May 2026 15:21:11 -0700 Subject: [PATCH 1/7] add 5 templates and include assemble* tasks then copy maven cache to sdcard --- .../androidide/AutomationEndToEndTest.kt | 253 ++++++++++++++++++ .../itsaky/androidide/AutomationTestSuite.kt | 12 + .../androidide/ExportCacheDirectoryTest.kt | 44 +++ .../androidide/helper/ProjectBuildHelper.kt | 19 +- ...izationProjectAndCancelingBuildScenario.kt | 218 +++++++++++++-- .../scenarios/RunAssembleTasksScenario.kt | 137 ++++++++++ .../androidide/screens/TemplateScreen.kt | 21 +- 7 files changed, 680 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/AutomationTestSuite.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt create mode 100644 app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt new file mode 100644 index 0000000000..c8f66d8324 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt @@ -0,0 +1,253 @@ +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.ensureOnHomeScreenBeforeCreateProject +import com.itsaky.androidide.helper.grantAllRequiredPermissionsThroughOnboardingUi +import com.itsaky.androidide.helper.initializeProjectRunAssembleTasksAndCancelBuild +import com.itsaky.androidide.helper.selectProjectTemplate +import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi +import com.itsaky.androidide.resources.R as ResourcesR +import com.itsaky.androidide.screens.HomeScreen.clickCreateProjectHomeScreen +import com.itsaky.androidide.screens.OnboardingScreen +import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectProjectSettings +import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName +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 AutomationEndToEndTest : 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) + } + + // ── Phase 2: Project creation + build for first 3 templates ── + + ensureOnHomeScreenBeforeCreateProject() + + data class TemplateConfig( + val label: String, + val templateResId: Int, + val projectName: String, + val visibleLabelOverride: String? = null, + ) + + val templates = listOf( + TemplateConfig("No Activity", R.string.template_no_activity, "TestNoActivity"), + TemplateConfig("Empty Activity", R.string.template_empty, "TestEmptyActivity"), + TemplateConfig("Basic Activity", R.string.template_basic, "TestBasicActivity"), + TemplateConfig( + "Navigation Drawer", + R.string.template_navigation_drawer, + "TestNavigationDrawer", + visibleLabelOverride = "Navigation Drawer", + ), + TemplateConfig("Bottom Nav Activity", R.string.template_navigation_tabs, "TestBottomNavActivity"), + TemplateConfig( + "No AndroidX", + R.string.template_no_AndroidX, + "TestNoAndroidX", + visibleLabelOverride = "No AndroidX", + ), + TemplateConfig("Tabbed Activity", R.string.template_tabs, "TestTabbedActivity"), + TemplateConfig("Compose Activity", R.string.template_compose, "TestComposeActivity"), + ) + + for ((index, config) in templates.withIndex()) { + step("Create+build template ${index + 1}/${templates.size}: ${config.label}") { + clickCreateProjectHomeScreen() + } + selectProjectTemplate( + "Select ${config.label} template", + config.templateResId, + config.visibleLabelOverride, + ) + setProjectName(config.projectName) + clickCreateProjectProjectSettings() + initializeProjectRunAssembleTasksAndCancelBuild() + + if (index < templates.lastIndex) { + ensureOnHomeScreenBeforeCreateProject() + } + } + + // ── Future phases (preferences, more templates, etc.) go here ── + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationTestSuite.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationTestSuite.kt new file mode 100644 index 0000000000..b9228953eb --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationTestSuite.kt @@ -0,0 +1,12 @@ +package com.itsaky.androidide + +import org.junit.runner.RunWith +import org.junit.runners.Suite + +@RunWith(Suite::class) +@Suite.SuiteClasses( + CleanupTest::class, + AutomationEndToEndTest::class, + ExportCacheDirectoryTest::class, +) +class AutomationTestSuite diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt new file mode 100644 index 0000000000..89c4fda4f0 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide + +import android.os.Environment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assume.assumeTrue +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class ExportCacheDirectoryTest { + + @Test + fun exportGradleModuleCacheBeforeConnectedTestCleanup() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val args = InstrumentationRegistry.getArguments() + + val destinationRelativePath = + args.getString(ARG_DESTINATION_RELATIVE_PATH) ?: DEFAULT_DESTINATION_RELATIVE_PATH + + val source = File(context.filesDir, SOURCE_RELATIVE_PATH) + val destination = File(Environment.getExternalStorageDirectory(), destinationRelativePath) + + assumeTrue("Source does not exist: ${source.absolutePath}", source.exists()) + assumeTrue("Source is not a directory: ${source.absolutePath}", source.isDirectory) + + destination.deleteRecursively() + destination.parentFile?.mkdirs() + + assertTrue( + "Failed to copy ${source.absolutePath} to ${destination.absolutePath}", + source.copyRecursively(target = destination, overwrite = true), + ) + assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists()) + } + + private companion object { + const val SOURCE_RELATIVE_PATH = "home/.gradle/caches/modules-2/files-2.1" + const val ARG_DESTINATION_RELATIVE_PATH = "androidide.exportCache.destination" + const val DEFAULT_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-cache/modules-2/files-2.1" + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt index b70af9c4fe..658ad758b1 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.helper import com.itsaky.androidide.scenarios.InitializationProjectAndCancelingBuildScenario import com.itsaky.androidide.scenarios.NavigateToMainScreenScenario +import com.itsaky.androidide.scenarios.RunAssembleTasksScenario import com.itsaky.androidide.screens.TemplateScreen.selectTemplate import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext @@ -11,9 +12,13 @@ fun TestContext.navigateToMainScreen() { } } -fun TestContext.selectProjectTemplate(stepTitle: String, templateResId: Int) { +fun TestContext.selectProjectTemplate( + stepTitle: String, + templateResId: Int, + visibleTextOverride: String? = null, +) { step(stepTitle) { - selectTemplate(templateResId) + selectTemplate(templateResId, visibleTextOverride) } } @@ -21,4 +26,12 @@ fun TestContext.initializeProjectAndCancelBuild() { step("Initialization the project and cancelling the build") { scenario(InitializationProjectAndCancelingBuildScenario()) } -} \ No newline at end of file +} + +fun TestContext.initializeProjectRunAssembleTasksAndCancelBuild() { + step("Initialize project, quick-run debug build, and run assemble task set") { + scenario(InitializationProjectAndCancelingBuildScenario(closeProjectAfterBuild = false)) + scenario(RunAssembleTasksScenario()) + scenario(InitializationProjectAndCancelingBuildScenario.CloseProjectScenario()) + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt index f6086f6f85..3f52fe927f 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt @@ -1,12 +1,24 @@ package com.itsaky.androidide.scenarios +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.R import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByDescription import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByText import com.kaspersky.kaspresso.testcases.api.scenario.Scenario import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext -class InitializationProjectAndCancelingBuildScenario : Scenario() { +private const val CLOSE_PROJECT_TAG = "CloseProjectScenario" + +private fun closeProjectLog(message: String) { + Log.e(CLOSE_PROJECT_TAG, message) + System.err.println("$CLOSE_PROJECT_TAG: $message") +} + +class InitializationProjectAndCancelingBuildScenario( + private val closeProjectAfterBuild: Boolean = true, +) : Scenario() { private fun TestContext.clickToolbarButton(description: String, waitMs: Long = 10_000) { val d = device.uiDevice @@ -37,9 +49,33 @@ class InitializationProjectAndCancelingBuildScenario : Scenario() { step("Wait for project initialized") { val d = device.uiDevice - val status = d.findObject(UiSelector().text("Project initialized")) - check(status.waitForExists(120_000)) { "Project never initialized" } - println("Project initialized") + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val initializedText = targetContext.getString(R.string.msg_project_initialized) + val quickRunDescription = targetContext.getString(R.string.cd_toolbar_quick_run) + val deadline = System.currentTimeMillis() + PROJECT_INIT_TIMEOUT_MS + var lastLogAt = 0L + var initialized = false + + while (System.currentTimeMillis() < deadline && !initialized) { + val status = d.findObject(UiSelector().text(initializedText)) + val quickRun = d.findObject(UiSelector().descriptionContains(quickRunDescription)) + + initialized = status.exists() || + runCatching { quickRun.exists() && quickRun.isEnabled }.getOrDefault(false) + + if (!initialized) { + val now = System.currentTimeMillis() + if (now - lastLogAt > 15_000L) { + closeProjectLog("Waiting for project initialization or enabled Quick run") + lastLogAt = now + } + d.waitForIdle(2_000) + Thread.sleep(1_000) + } + } + + check(initialized) { "Project never initialized" } + closeProjectLog("Project initialized or Quick run available") d.waitForIdle() } @@ -63,21 +99,171 @@ class InitializationProjectAndCancelingBuildScenario : Scenario() { d.waitForIdle() } - step("Close project") { - val d = device.uiDevice - d.pressBack() - d.waitForIdle() + if (closeProjectAfterBuild) { + scenario(CloseProjectScenario()) + } + } + + companion object { + private const val PROJECT_INIT_TIMEOUT_MS = 5 * 60 * 1000L + } + + class CloseProjectScenario : Scenario() { + override val steps: TestContext.() -> Unit = { + step("Dismiss post-build overlays") { + val d = device.uiDevice + val dismiss = d.findObject(UiSelector().text("Dismiss")) + if (dismiss.waitForExists(3_000)) { + clickFirstAccessibilityNodeByText("Dismiss") + d.waitForIdle() + } + } - val selector = UiSelector().text("Save files and close project") - if (!d.findObject(selector).waitForExists(3_000)) { - d.pressBack() + step("Close project") { + closeProjectLog("Close project step entered") + val d = device.uiDevice + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val openDrawer = targetContext.getString(R.string.cd_drawer_open) + val closeDrawer = targetContext.getString(R.string.cd_drawer_close) + val closeProject = targetContext.getString(R.string.title_close_project) + val closeWithoutSaving = targetContext.getString(R.string.close_without_saving) + val saveAndClose = "Save files and close project" + + fun findCloseDialogButton() = + listOf( + UiSelector().text(closeWithoutSaving), + UiSelector().textContains("without saving"), + UiSelector().text(saveAndClose), + ).firstNotNullOfOrNull { selector -> + d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + } + + fun findCloseProjectControl() = + listOf( + UiSelector().description(closeProject), + UiSelector().descriptionContains(closeProject), + UiSelector().text(closeProject), + UiSelector().textContains(closeProject), + ).firstNotNullOfOrNull { selector -> + d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + } + + fun tapVisibleProjectMenuFallback() { + val toolbar = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) + val bounds = toolbar.takeIf { it.waitForExists(3_000) && it.exists() }?.visibleBounds + val x = bounds?.let { it.left + 48 } ?: 48 + val y = bounds?.let { it.top + 70 } ?: 140 + d.click(x, y) + d.waitForIdle() + } + + fun tapSystemBackButton() { + d.click((d.displayWidth * 0.24f).toInt(), d.displayHeight - 40) + d.waitForIdle() + } + + var closeDialogButton = findCloseDialogButton() + repeat(2) { attempt -> + if (closeDialogButton == null) { + closeProjectLog("pressBack attempt ${attempt + 1}/2") + d.pressBack() + d.waitForIdle() + Thread.sleep(2_000) + closeDialogButton = findCloseDialogButton() + closeProjectLog("close dialog after pressBack attempt ${attempt + 1}: ${closeDialogButton != null}") + } + } + + if (closeDialogButton != null) { + closeDialogButton?.click() + d.waitForIdle() + return@step + } + + repeat(3) { attempt -> + if (closeDialogButton == null) { + closeProjectLog("system Back button tap attempt ${attempt + 1}/3") + tapSystemBackButton() + closeDialogButton = findCloseDialogButton() + closeProjectLog("close dialog after system Back tap attempt ${attempt + 1}: ${closeDialogButton != null}") + } + } + + if (closeDialogButton != null) { + closeDialogButton?.click() + d.waitForIdle() + return@step + } + + runCatching { + val drawer = listOf( + UiSelector().description(openDrawer), + UiSelector().description(closeDrawer), + UiSelector().descriptionContains(openDrawer), + UiSelector().descriptionContains(closeDrawer), + ).firstNotNullOfOrNull { selector -> + d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + } + + if (drawer != null) { + drawer.click() + } else { + clickFirstAccessibilityNodeByDescription(openDrawer) + } + d.waitForIdle() + } + + var closeProjectControl = findCloseProjectControl() + if (closeProjectControl == null) { + tapVisibleProjectMenuFallback() + closeProjectControl = findCloseProjectControl() + } + + check(closeProjectControl != null) { "Close project control not found" } + closeProjectControl.click() d.waitForIdle() - check(d.findObject(selector).waitForExists(3_000)) { - "Close project dialog not found" + + val closeButton = listOf( + UiSelector().text(closeWithoutSaving), + UiSelector().textContains("without saving"), + UiSelector().text(saveAndClose), + ).firstNotNullOfOrNull { selector -> + d.findObject(selector).takeIf { it.waitForExists(10_000) && it.exists() } } + + if (closeButton != null) { + closeButton.click() + } else { + var projectClosed = false + repeat(4) { attempt -> + if (projectClosed) { + return@repeat + } + + closeProjectLog("fallback pressBack attempt ${attempt + 1}/4") + d.pressBack() + d.waitForIdle() + + val fallbackCloseButton = listOf( + UiSelector().text(closeWithoutSaving), + UiSelector().textContains("without saving"), + UiSelector().text(saveAndClose), + ).firstNotNullOfOrNull { selector -> + d.findObject(selector).takeIf { it.waitForExists(3_000) && it.exists() } + } + + if (fallbackCloseButton != null) { + fallbackCloseButton.click() + projectClosed = true + } + + if (!projectClosed && attempt == 3) { + error("Close project dialog not found") + } + } + } + d.waitForIdle() } - clickFirstAccessibilityNodeByText("Save files and close project") - d.waitForIdle() } } -} \ No newline at end of file +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt new file mode 100644 index 0000000000..d7b8ff37a3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt @@ -0,0 +1,137 @@ +package com.itsaky.androidide.scenarios + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiSelector +import com.itsaky.androidide.R +import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByDescription +import com.itsaky.androidide.helper.clickFirstAccessibilityNodeParentByText +import com.kaspersky.kaspresso.testcases.api.scenario.Scenario +import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext + +private const val RUN_ASSEMBLE_TAG = "RunAssembleTasks" + +private fun runAssembleLog(message: String) { + Log.e(RUN_ASSEMBLE_TAG, message) + System.err.println("$RUN_ASSEMBLE_TAG: $message") +} + +class RunAssembleTasksScenario( + private val tasks: List = DEFAULT_ASSEMBLE_TASKS, +) : Scenario() { + + override val steps: TestContext.() -> Unit = { + step("Dismiss post-build overlays before running assemble tasks") { + val d = device.uiDevice + val dismiss = d.findObject(UiSelector().text("Dismiss")) + if (dismiss.waitForExists(3_000)) { + dismiss.click() + d.waitForIdle() + } + } + + step("Open Run tasks dialog") { + val d = device.uiDevice + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val runTasksDescription = targetContext.getString(R.string.cd_toolbar_run_gradle_tasks) + val toolbar = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) + check(toolbar.waitForExists(10_000)) { "Editor toolbar not found" } + clickFirstAccessibilityNodeByDescription(runTasksDescription) + d.waitForIdle() + + val title = targetContext.getString(R.string.title_run_tasks) + check(d.findObject(UiSelector().text(title)).waitForExists(10_000)) { + "Run tasks dialog did not open" + } + } + + step("Filter assemble tasks") { + val d = device.uiDevice + val search = d.findObject(UiSelector().className("android.widget.EditText")) + check(search.waitForExists(10_000)) { "Run tasks search field not found" } + search.setText("assemble") + d.waitForIdle() + } + + tasks.forEach { task -> + step("Select Gradle task $task") { + val d = device.uiDevice + check(d.findObject(UiSelector().text(task)).waitForExists(20_000)) { + "Task not found in Run tasks dialog: $task" + } + clickFirstAccessibilityNodeParentByText(task) + d.waitForIdle() + } + } + + step("Confirm and run selected Gradle tasks") { + val d = device.uiDevice + val runButton = d.findObject(UiSelector().resourceIdMatches(".*:id/exec")) + check(runButton.waitForExists(10_000)) { "Run tasks execute button not found" } + runButton.click() + d.waitForIdle() + + check(d.findObject(UiSelector().textContains(":app:assemble")).waitForExists(10_000)) { + "Run tasks confirmation did not show selected tasks" + } + + runButton.click() + d.waitForIdle() + runAssembleLog("Selected assemble tasks submitted") + } + + step("Wait for selected Gradle tasks to finish") { + runAssembleLog("Waiting for selected assemble tasks to finish") + val d = device.uiDevice + val success = d.findObject(UiSelector().textContains("Build completed successfully")) + val gradleSuccess = d.findObject(UiSelector().textContains("BUILD SUCCESSFUL")) + val failure = d.findObject(UiSelector().textContains("Build failed")) + val gradleFailure = d.findObject(UiSelector().textContains("BUILD FAILED")) + + val deadline = System.currentTimeMillis() + BUILD_TIMEOUT_MS + var lastLogAt = 0L + var completed = false + + while (System.currentTimeMillis() < deadline && !completed) { + if (success.exists() || gradleSuccess.exists()) { + completed = true + break + } + + check(!failure.exists() && !gradleFailure.exists()) { + "Selected Gradle tasks failed" + } + + val now = System.currentTimeMillis() + if (now - lastLogAt > 10_000L) { + runAssembleLog("Still waiting for selected assemble tasks to finish") + lastLogAt = now + } + + Thread.sleep(1_000) + d.waitForIdle() + } + + check(completed) { + "Timed out waiting for selected Gradle tasks to complete" + } + runAssembleLog("Selected assemble tasks finished; continuing to close project") + d.waitForIdle() + } + } + + companion object { + val DEFAULT_ASSEMBLE_TASKS = listOf( + ":app:assemble", + ":app:assembleAndroidTest", + ":app:assembleDebug", + ":app:assembleDebugAndroidTest", + ":app:assembleDebugUnitTest", + ":app:assembleRelease", + ":app:assembleReleaseUnitTest", + ":app:assembleUnitTest", + ) + + private const val BUILD_TIMEOUT_MS = 10 * 60 * 1000L + } +} diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/TemplateScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/TemplateScreen.kt index b12d3ea026..5cc40b1fbb 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/TemplateScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/TemplateScreen.kt @@ -1,22 +1,33 @@ package com.itsaky.androidide.screens import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.UiScrollable import com.itsaky.androidide.helper.clickFirstAccessibilityNodeParentByText import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext object TemplateScreen { - fun TestContext.selectTemplate(templateResId: Int) { - val templateText = device.targetContext.getString(templateResId) + fun TestContext.selectTemplate(templateResId: Int, visibleTextOverride: String? = null) { + val templateText = visibleTextOverride ?: device.targetContext.getString(templateResId) val d = device.uiDevice - val templateItem = d.findObject( - UiSelector().resourceIdMatches(".*:id/template_name").text(templateText) + var templateItem = d.findObject( + UiSelector().resourceIdMatches(".*:id/template_name").text(templateText), ) + if (!templateItem.waitForExists(3_000)) { + runCatching { + UiScrollable(UiSelector().scrollable(true)).scrollTextIntoView(templateText) + } + d.waitForIdle() + templateItem = d.findObject( + UiSelector().resourceIdMatches(".*:id/template_name").text(templateText), + ) + } + check(templateItem.waitForExists(3_000)) { "Template '$templateText' not found in template list" } clickFirstAccessibilityNodeParentByText(templateText, "template '$templateText'") d.waitForIdle() } -} \ No newline at end of file +} From 1bfe5fa9e5bdbd0c59e7576fecc8bede73827dec Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Sun, 17 May 2026 18:00:04 -0700 Subject: [PATCH 2/7] add first 7 template projects created with kotlin project language --- .../androidide/AutomationEndToEndTest.kt | 19 +++- ...izationProjectAndCancelingBuildScenario.kt | 52 +++++++++-- .../screens/ProjectSettingsScreen.kt | 87 ++++++++----------- 3 files changed, 95 insertions(+), 63 deletions(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt index c8f66d8324..3c1f75649d 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt @@ -17,6 +17,7 @@ import com.itsaky.androidide.resources.R as ResourcesR import com.itsaky.androidide.screens.HomeScreen.clickCreateProjectHomeScreen import com.itsaky.androidide.screens.OnboardingScreen import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectProjectSettings +import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName import com.itsaky.androidide.screens.PermissionScreen import com.itsaky.androidide.screens.PermissionsInfoScreen @@ -198,7 +199,7 @@ class AutomationEndToEndTest : TestCase() { waitForMainHomeOrEditorUi(device.uiDevice) } - // ── Phase 2: Project creation + build for first 3 templates ── + // ── Phase 2: Project creation + build across default and Kotlin template variants ── ensureOnHomeScreenBeforeCreateProject() @@ -207,9 +208,10 @@ class AutomationEndToEndTest : TestCase() { val templateResId: Int, val projectName: String, val visibleLabelOverride: String? = null, + val useKotlinLanguage: Boolean = false, ) - val templates = listOf( + val defaultLanguageTemplates = listOf( TemplateConfig("No Activity", R.string.template_no_activity, "TestNoActivity"), TemplateConfig("Empty Activity", R.string.template_empty, "TestEmptyActivity"), TemplateConfig("Basic Activity", R.string.template_basic, "TestBasicActivity"), @@ -230,6 +232,16 @@ class AutomationEndToEndTest : TestCase() { TemplateConfig("Compose Activity", R.string.template_compose, "TestComposeActivity"), ) + val kotlinLanguageTemplates = defaultLanguageTemplates.take(7).map { config -> + config.copy( + label = "Kotlin ${config.label}", + projectName = "Kt${config.projectName}", + useKotlinLanguage = true, + ) + } + + val templates = defaultLanguageTemplates + kotlinLanguageTemplates + for ((index, config) in templates.withIndex()) { step("Create+build template ${index + 1}/${templates.size}: ${config.label}") { clickCreateProjectHomeScreen() @@ -239,6 +251,9 @@ class AutomationEndToEndTest : TestCase() { config.templateResId, config.visibleLabelOverride, ) + if (config.useKotlinLanguage) { + selectKotlinLanguage() + } setProjectName(config.projectName) clickCreateProjectProjectSettings() initializeProjectRunAssembleTasksAndCancelBuild() diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt index 3f52fe927f..4701da6c47 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt @@ -83,20 +83,53 @@ class InitializationProjectAndCancelingBuildScenario( clickToolbarButton("Quick run") } - step("Wait for APK install offer") { - // After a successful build, the system package installer appears. - // If it never appears, the build failed. + step("Wait for quick-run outcome") { val d = device.uiDevice + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val installer = d.findObject( UiSelector().packageNameMatches(".*packageinstaller.*|.*permissioncontroller.*") ) - check(installer.waitForExists(120_000)) { - "APK install offer never appeared — build may have failed" + val success = d.findObject(UiSelector().textContains("Build completed successfully")) + val gradleSuccess = d.findObject(UiSelector().textContains("BUILD SUCCESSFUL")) + val failure = d.findObject(UiSelector().textContains("Build failed")) + val gradleFailure = d.findObject(UiSelector().textContains("BUILD FAILED")) + val quickRunFailure = d.findObject( + UiSelector().textContains(targetContext.getString(R.string.error_quick_run_failed)) + ) + + closeProjectLog("Waiting for quick-run installer or build success") + val deadline = System.currentTimeMillis() + QUICK_RUN_TIMEOUT_MS + var lastLogAt = 0L + + while (System.currentTimeMillis() < deadline) { + if (installer.exists()) { + closeProjectLog("Quick-run installer appeared; dismissing") + d.pressBack() + d.waitForIdle() + return@step + } + + if (success.exists() || gradleSuccess.exists()) { + closeProjectLog("Quick-run build success detected without installer; continuing") + d.waitForIdle() + return@step + } + + check(!failure.exists() && !gradleFailure.exists() && !quickRunFailure.exists()) { + "Quick-run build failed" + } + + val now = System.currentTimeMillis() + if (now - lastLogAt > 15_000L) { + closeProjectLog("Still waiting for quick-run installer or build success") + lastLogAt = now + } + + d.waitForIdle(2_000) + Thread.sleep(1_000) } - println("APK install offer appeared — build succeeded") - // Dismiss it — we don't need to install - d.pressBack() - d.waitForIdle() + + error("Quick-run timed out waiting for installer or build success") } if (closeProjectAfterBuild) { @@ -106,6 +139,7 @@ class InitializationProjectAndCancelingBuildScenario( companion object { private const val PROJECT_INIT_TIMEOUT_MS = 5 * 60 * 1000L + private const val QUICK_RUN_TIMEOUT_MS = 5 * 60 * 1000L } class CloseProjectScenario : Scenario() { diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt index 3c25b41461..101f684757 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt @@ -50,61 +50,44 @@ object ProjectSettingsScreen : KScreen() { fun TestContext.selectKotlinLanguage() { step("Select the kotlin language") { - flakySafely(30000) { // Increased timeout - try { - ProjectSettingsScreen { - spinner { - isVisible() - open() - - // Wait for spinner to fully open - Thread.sleep(1000) - - // Retry mechanism for selecting Kotlin - var attempts = 0 - var success = false - while (attempts < 3 && !success) { - try { - childAt(1) { - isVisible() - hasText("Kotlin") - click() - } - success = true - } catch (e: Exception) { - attempts++ - println("Failed to select Kotlin on attempt $attempts: ${e.message}") - if (attempts < 3) { - // Close and reopen spinner - device.uiDevice.pressBack() - Thread.sleep(1000) - open() - Thread.sleep(1000) - } - } - } - } - } - } catch (e: Exception) { - println("Error in selectKotlinLanguage: ${e.message}") - // One more attempt with a different approach - ProjectSettingsScreen { - spinner { - isVisible() - open() - - // Wait for spinner to fully open - Thread.sleep(1000) - - // Try to select by text instead of position - device.uiDevice.findObject(UiSelector().text("Kotlin")).click() - } - } - } + flakySafely(30000) { + openProjectLanguageDropdown() + + val d = device.uiDevice + val kotlin = d.findObject(UiSelector().text("Kotlin")) + check(kotlin.waitForExists(5_000)) { "Kotlin language option not found" } + kotlin.click() + d.waitForIdle() } } } + private fun TestContext.openProjectLanguageDropdown() { + val d = device.uiDevice + + val javaValue = d.findObject(UiSelector().text("Java")) + if (javaValue.waitForExists(5_000)) { + val bounds = javaValue.visibleBounds + d.click(bounds.centerX(), bounds.centerY()) + d.waitForIdle() + if (d.findObject(UiSelector().text("Kotlin")).waitForExists(2_000)) { + return + } + } + + val languageLabel = d.findObject(UiSelector().textMatches("(?i)Project language")) + if (languageLabel.waitForExists(5_000)) { + val bounds = languageLabel.visibleBounds + d.click(d.displayWidth - 80, bounds.centerY()) + d.waitForIdle() + if (d.findObject(UiSelector().text("Kotlin")).waitForExists(2_000)) { + return + } + } + + error("Project language dropdown did not open") + } + fun TestContext.clickCreateProjectProjectSettings() { step("Click create project on the Settings Page") { val createText = device.targetContext.getString(R.string.create_project) @@ -130,4 +113,4 @@ object ProjectSettingsScreen : KScreen() { } } } -} \ No newline at end of file +} From 4ca4bca84b89246cc0896e9a84420a901d299ea3 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Tue, 19 May 2026 10:27:32 -0700 Subject: [PATCH 3/7] add tasks to convert cache to repo and to recompress the repo for br --- build.gradle.kts | 192 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 8c09555454..4da605585a 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,9 +27,18 @@ import com.itsaky.androidide.plugins.AndroidIDEPlugin import com.itsaky.androidide.plugins.conf.configureAndroidModule import com.itsaky.androidide.plugins.conf.configureJavaModule import com.itsaky.androidide.plugins.conf.configureMavenPublish +import org.gradle.api.logging.Logger import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.Serializable +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream plugins { id("build-logic.root-project") @@ -358,5 +367,188 @@ tasks.register("jacocoAggregateReport") { executionData.setFrom(execFiles) } +val mavenCacheDirProvider = providers.gradleProperty("mavenCacheDir").orElse("build/maven-cache") +val localMavenRepoDirProvider = providers.gradleProperty("localMavenRepoDir").orElse("build/localMavenRepository") +val mavenRepoDirProvider = providers.gradleProperty("mavenRepoDir").orElse(localMavenRepoDirProvider) +val zeroMavenRepoDirProvider = + providers.gradleProperty("zeroMavenRepoDir") + .orElse(mavenRepoDirProvider.map { repoDir -> + val repoPath = file(repoDir).toPath() + val fileName = repoPath.fileName.toString() + repoPath.resolveSibling("$fileName-zero").toString() + }) +tasks.register("cacheToLocalMavenRepo") { + group = "cicd" + description = "Converts an exported Gradle module cache into a local Maven repository layout." + val source = mavenCacheDirProvider.map { file(it) } + val destination = localMavenRepoDirProvider.map { file(it) } + + inputs.dir(source) + outputs.dir(destination) + + doLast { + convertCacheToLocalMavenRepo(source.get().toPath(), destination.get().toPath(), logger) + } +} + +tasks.register("zeroCompressMavenRepo") { + group = "cicd" + description = "Copies a Maven repository and rewrites all JAR/AAR archives with ZIP zero compression." + + val source = mavenRepoDirProvider.map { file(it) } + val destination = zeroMavenRepoDirProvider.map { file(it) } + val validateArchives = + providers.gradleProperty("zeroMavenRepoValidate") + .map(String::toBoolean) + .orElse(true) + + inputs.dir(source) + inputs.property("validateArchives", validateArchives) + outputs.dir(destination) + + doLast { + zeroCompressMavenRepo( + source = source.get().toPath(), + destination = destination.get().toPath(), + validateArchives = validateArchives.get(), + logger = logger, + ) + } +} + +tasks.register("zeroCompressLocalMavenRepo") { + group = "cicd" + description = "Converts an exported Gradle cache to a local Maven repository, then zero-compresses all JAR/AAR archives." + dependsOn("cacheToLocalMavenRepo", "zeroCompressMavenRepo") +} + +tasks.named("zeroCompressMavenRepo") { + mustRunAfter("cacheToLocalMavenRepo") +} + +fun Path.resolveParts(parts: Iterable): Path = + parts.fold(this) { path, part -> path.resolve(part) } + +fun Path.relativeTo(base: Path): Path = + base.relativize(this) + +fun convertCacheToLocalMavenRepo(source: Path, destination: Path, logger: Logger) { + val allowedExtensions = setOf("aar", "jar", "module", "pom") + + require(Files.isDirectory(source)) { + "Maven cache directory does not exist or is not a directory: $source" + } + + Files.createDirectories(destination) + + source.toFile().walkTopDown() + .filter { it.isFile } + .filter { it.extension.lowercase() in allowedExtensions } + .forEach { file -> + val filePath = file.toPath() + val relativeParent = + filePath.parent + ?.let { source.relativize(it).map(Path::toString).toList() } + .orEmpty() + + val targetParts = + if (relativeParent.firstOrNull()?.contains(".") == true) { + relativeParent.first().split(".") + relativeParent.drop(1).dropLast(1) + } else { + relativeParent.dropLast(1) + } + + val targetParent = destination.resolveParts(targetParts) + val targetFile = targetParent.resolve(file.name) + + Files.createDirectories(targetParent) + Files.copy(filePath, targetFile, StandardCopyOption.REPLACE_EXISTING) + logger.lifecycle("Copied ${filePath.relativeTo(source)} -> ${targetFile.relativeTo(destination)}") + } +} + +fun zeroCompressMavenRepo(source: Path, destination: Path, validateArchives: Boolean, logger: Logger) { + require(Files.isDirectory(source)) { + "Maven repository directory does not exist or is not a directory: $source" + } + require(!destination.startsWith(source)) { + "Zero-compressed output directory must not be inside the input repository: $destination" + } + + Files.createDirectories(destination) + + source.toFile().walkTopDown() + .filter { it.isFile } + .forEach { file -> + val sourceFile = file.toPath() + val targetFile = destination.resolve(source.relativize(sourceFile)) + + Files.createDirectories(targetFile.parent) + + when (file.extension.lowercase()) { + "aar", "jar" -> { + zeroCompressArchive(sourceFile, targetFile) + if (validateArchives) { + validateZeroCompressedArchive(sourceFile, targetFile) + } + logger.lifecycle("Zero-compressed ${sourceFile.relativeTo(source)}") + } + else -> Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) + } + } +} + +fun zeroCompressArchive(source: Path, destination: Path) { + ZipInputStream(Files.newInputStream(source).buffered()).use { input -> + ZipOutputStream(Files.newOutputStream(destination).buffered()).use { output -> + while (true) { + val entry = input.nextEntry ?: break + val bytes = if (entry.isDirectory) ByteArray(0) else input.readBytes() + val crc = CRC32().apply { update(bytes) }.value + + val outputEntry = + ZipEntry(entry.name).apply { + comment = entry.comment + extra = entry.extra + method = ZipEntry.STORED + size = bytes.size.toLong() + compressedSize = bytes.size.toLong() + this.crc = crc + if (entry.time >= 0) { + time = entry.time + } + } + + output.putNextEntry(outputEntry) + if (!entry.isDirectory) { + output.write(bytes) + } + output.closeEntry() + input.closeEntry() + } + } + } +} + +fun validateZeroCompressedArchive(source: Path, destination: Path) { + ZipFile(source.toFile()).use { sourceZip -> + ZipFile(destination.toFile()).use { destinationZip -> + val sourceEntries = sourceZip.entries().asSequence().map { it.name }.toSet() + val destinationEntries = destinationZip.entries().asSequence().toList() + val destinationEntryNames = destinationEntries.map { it.name }.toSet() + + require(sourceEntries == destinationEntryNames) { + "Entry mismatch after zero-compressing $source" + } + + val compressedEntry = + destinationEntries.firstOrNull { !it.isDirectory && it.method != ZipEntry.STORED } + + require(compressedEntry == null) { + "Archive entry was not zero-compressed in $destination: ${compressedEntry?.name}" + } + } + } +} From 2e881ec93118e3c31d02a9df5b54ffa4b2737200 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 25 May 2026 14:14:47 -0700 Subject: [PATCH 4/7] account for removal of onboarding screen --- .../androidide/AutomationEndToEndTest.kt | 36 +------------------ ...izationProjectAndCancelingBuildScenario.kt | 23 ++++++++++-- .../scenarios/RunAssembleTasksScenario.kt | 16 ++++++++- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt index 3c1f75649d..40f5febf4a 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt @@ -20,7 +20,6 @@ import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectPro import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName 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 @@ -76,7 +75,7 @@ class AutomationEndToEndTest : TestCase() { advancePastWelcomeScreen() - // ── Permissions Info Screen ── + // ── Permissions Screen (with privacy disclosure dialog overlay) ── step("Verify privacy disclosure dialog") { val d = device.uiDevice @@ -91,19 +90,6 @@ class AutomationEndToEndTest : TestCase() { 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", @@ -111,26 +97,6 @@ class AutomationEndToEndTest : TestCase() { ) } - // ── 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") { diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt index 4701da6c47..7c3a57d01c 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt @@ -86,9 +86,11 @@ class InitializationProjectAndCancelingBuildScenario( step("Wait for quick-run outcome") { val d = device.uiDevice val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val quickRunDescription = targetContext.getString(R.string.cd_toolbar_quick_run) val installer = d.findObject( UiSelector().packageNameMatches(".*packageinstaller.*|.*permissioncontroller.*") ) + val quickRun = d.findObject(UiSelector().descriptionContains(quickRunDescription)) val success = d.findObject(UiSelector().textContains("Build completed successfully")) val gradleSuccess = d.findObject(UiSelector().textContains("BUILD SUCCESSFUL")) val failure = d.findObject(UiSelector().textContains("Build failed")) @@ -100,11 +102,18 @@ class InitializationProjectAndCancelingBuildScenario( closeProjectLog("Waiting for quick-run installer or build success") val deadline = System.currentTimeMillis() + QUICK_RUN_TIMEOUT_MS var lastLogAt = 0L + var sawQuickRunDisabled = false while (System.currentTimeMillis() < deadline) { if (installer.exists()) { closeProjectLog("Quick-run installer appeared; dismissing") - d.pressBack() + val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) + if (cancel.waitForExists(2_000)) { + cancel.click() + } else { + d.pressBack() + } + runCatching { installer.waitUntilGone(5_000) } d.waitForIdle() return@step } @@ -115,6 +124,16 @@ class InitializationProjectAndCancelingBuildScenario( return@step } + runCatching { quickRun.exists() && quickRun.isEnabled }.getOrNull()?.let { enabled -> + if (!enabled) { + sawQuickRunDisabled = true + } else if (sawQuickRunDisabled) { + closeProjectLog("Quick-run action re-enabled; continuing") + d.waitForIdle() + return@step + } + } + check(!failure.exists() && !gradleFailure.exists() && !quickRunFailure.exists()) { "Quick-run build failed" } @@ -139,7 +158,7 @@ class InitializationProjectAndCancelingBuildScenario( companion object { private const val PROJECT_INIT_TIMEOUT_MS = 5 * 60 * 1000L - private const val QUICK_RUN_TIMEOUT_MS = 5 * 60 * 1000L + private const val QUICK_RUN_TIMEOUT_MS = 10 * 60 * 1000L } class CloseProjectScenario : Scenario() { diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt index d7b8ff37a3..69afa684b4 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt @@ -28,6 +28,20 @@ class RunAssembleTasksScenario( dismiss.click() d.waitForIdle() } + val installer = d.findObject( + UiSelector().packageNameMatches(".*packageinstaller.*|.*permissioncontroller.*") + ) + if (installer.exists()) { + runAssembleLog("Package installer still visible before assemble tasks; dismissing") + val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) + if (cancel.waitForExists(2_000)) { + cancel.click() + } else { + d.pressBack() + } + runCatching { installer.waitUntilGone(5_000) } + d.waitForIdle() + } } step("Open Run tasks dialog") { @@ -35,7 +49,7 @@ class RunAssembleTasksScenario( val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val runTasksDescription = targetContext.getString(R.string.cd_toolbar_run_gradle_tasks) val toolbar = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) - check(toolbar.waitForExists(10_000)) { "Editor toolbar not found" } + check(toolbar.waitForExists(30_000)) { "Editor toolbar not found" } clickFirstAccessibilityNodeByDescription(runTasksDescription) d.waitForIdle() From 4100dfdc075b6e8ce238e651b4c253ef0d1d2337 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Mon, 25 May 2026 16:09:38 -0700 Subject: [PATCH 5/7] fine tune --- .../androidide/ExportCacheDirectoryTest.kt | 19 ++++++++- .../androidide/helper/ProjectBuildHelper.kt | 17 ++++++-- ...izationProjectAndCancelingBuildScenario.kt | 2 +- .../scenarios/RunAssembleTasksScenario.kt | 14 +++++-- .../screens/ProjectSettingsScreen.kt | 17 +++++--- build.gradle.kts | 41 ++++++++++++------- 6 files changed, 81 insertions(+), 29 deletions(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt index 89c4fda4f0..3896e93794 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt @@ -21,7 +21,8 @@ class ExportCacheDirectoryTest { args.getString(ARG_DESTINATION_RELATIVE_PATH) ?: DEFAULT_DESTINATION_RELATIVE_PATH val source = File(context.filesDir, SOURCE_RELATIVE_PATH) - val destination = File(Environment.getExternalStorageDirectory(), destinationRelativePath) + val externalBase = File(Environment.getExternalStorageDirectory(), EXPORT_BASE_DIRECTORY).canonicalFile + val destination = resolveSafeDestination(externalBase, destinationRelativePath) assumeTrue("Source does not exist: ${source.absolutePath}", source.exists()) assumeTrue("Source is not a directory: ${source.absolutePath}", source.isDirectory) @@ -36,9 +37,25 @@ class ExportCacheDirectoryTest { assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists()) } + private fun resolveSafeDestination(base: File, relativePath: String): File { + require(relativePath.isNotBlank()) { + "Destination path must be a non-blank relative path" + } + require(!File(relativePath).isAbsolute) { + "Destination path must be relative" + } + + val destination = File(base, relativePath.removePrefix("$EXPORT_BASE_DIRECTORY/")).canonicalFile + require(destination.toPath().startsWith(base.toPath())) { + "Destination must stay under ${base.absolutePath}: ${destination.absolutePath}" + } + return destination + } + private companion object { const val SOURCE_RELATIVE_PATH = "home/.gradle/caches/modules-2/files-2.1" const val ARG_DESTINATION_RELATIVE_PATH = "androidide.exportCache.destination" + const val EXPORT_BASE_DIRECTORY = "CodeOnTheGoProjects" const val DEFAULT_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-cache/modules-2/files-2.1" } } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt index 658ad758b1..55aac447be 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/helper/ProjectBuildHelper.kt @@ -30,8 +30,19 @@ fun TestContext.initializeProjectAndCancelBuild() { fun TestContext.initializeProjectRunAssembleTasksAndCancelBuild() { step("Initialize project, quick-run debug build, and run assemble task set") { - scenario(InitializationProjectAndCancelingBuildScenario(closeProjectAfterBuild = false)) - scenario(RunAssembleTasksScenario()) - scenario(InitializationProjectAndCancelingBuildScenario.CloseProjectScenario()) + var failure: Throwable? = null + try { + scenario(InitializationProjectAndCancelingBuildScenario(closeProjectAfterBuild = false)) + scenario(RunAssembleTasksScenario()) + } catch (throwable: Throwable) { + failure = throwable + throw throwable + } finally { + runCatching { + scenario(InitializationProjectAndCancelingBuildScenario.CloseProjectScenario()) + }.onFailure { closeFailure -> + failure?.addSuppressed(closeFailure) ?: throw closeFailure + } + } } } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt index 7c3a57d01c..0f1dbac806 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt @@ -180,7 +180,7 @@ class InitializationProjectAndCancelingBuildScenario( val closeDrawer = targetContext.getString(R.string.cd_drawer_close) val closeProject = targetContext.getString(R.string.title_close_project) val closeWithoutSaving = targetContext.getString(R.string.close_without_saving) - val saveAndClose = "Save files and close project" + val saveAndClose = targetContext.getString(R.string.save_and_close) fun findCloseDialogButton() = listOf( diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt index 69afa684b4..037a6e1345 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.scenarios import android.util.Log import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import com.itsaky.androidide.R import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByDescription @@ -70,7 +71,12 @@ class RunAssembleTasksScenario( tasks.forEach { task -> step("Select Gradle task $task") { val d = device.uiDevice - check(d.findObject(UiSelector().text(task)).waitForExists(20_000)) { + var taskNode = d.findObject(UiSelector().text(task)) + if (!taskNode.waitForExists(3_000)) { + UiScrollable(UiSelector().scrollable(true)).scrollTextIntoView(task) + taskNode = d.findObject(UiSelector().text(task)) + } + check(taskNode.waitForExists(20_000)) { "Task not found in Run tasks dialog: $task" } clickFirstAccessibilityNodeParentByText(task) @@ -85,8 +91,10 @@ class RunAssembleTasksScenario( runButton.click() d.waitForIdle() - check(d.findObject(UiSelector().textContains(":app:assemble")).waitForExists(10_000)) { - "Run tasks confirmation did not show selected tasks" + tasks.forEach { task -> + check(d.findObject(UiSelector().textContains(task)).waitForExists(10_000)) { + "Run tasks confirmation missing selected task: $task" + } } runButton.click() diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt index 101f684757..abc17eb795 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt @@ -33,6 +33,7 @@ object ProjectSettingsScreen : KScreen() { fun TestContext.selectJavaLanguage() { step("Select the java language") { + val javaText = device.targetContext.getString(R.string.lang_java) ProjectSettingsScreen { spinner { isVisible() @@ -40,7 +41,7 @@ object ProjectSettingsScreen : KScreen() { childAt(0) { isVisible() - hasText("Java") + hasText(javaText) click() } } @@ -51,10 +52,11 @@ object ProjectSettingsScreen : KScreen() { fun TestContext.selectKotlinLanguage() { step("Select the kotlin language") { flakySafely(30000) { + val kotlinText = device.targetContext.getString(R.string.lang_kotlin) openProjectLanguageDropdown() val d = device.uiDevice - val kotlin = d.findObject(UiSelector().text("Kotlin")) + val kotlin = d.findObject(UiSelector().text(kotlinText)) check(kotlin.waitForExists(5_000)) { "Kotlin language option not found" } kotlin.click() d.waitForIdle() @@ -64,23 +66,26 @@ object ProjectSettingsScreen : KScreen() { private fun TestContext.openProjectLanguageDropdown() { val d = device.uiDevice + val javaText = device.targetContext.getString(R.string.lang_java) + val kotlinText = device.targetContext.getString(R.string.lang_kotlin) + val languageLabelText = device.targetContext.getString(R.string.wizard_language) - val javaValue = d.findObject(UiSelector().text("Java")) + val javaValue = d.findObject(UiSelector().text(javaText)) if (javaValue.waitForExists(5_000)) { val bounds = javaValue.visibleBounds d.click(bounds.centerX(), bounds.centerY()) d.waitForIdle() - if (d.findObject(UiSelector().text("Kotlin")).waitForExists(2_000)) { + if (d.findObject(UiSelector().text(kotlinText)).waitForExists(2_000)) { return } } - val languageLabel = d.findObject(UiSelector().textMatches("(?i)Project language")) + val languageLabel = d.findObject(UiSelector().text(languageLabelText)) if (languageLabel.waitForExists(5_000)) { val bounds = languageLabel.visibleBounds d.click(d.displayWidth - 80, bounds.centerY()) d.waitForIdle() - if (d.findObject(UiSelector().text("Kotlin")).waitForExists(2_000)) { + if (d.findObject(UiSelector().text(kotlinText)).waitForExists(2_000)) { return } } diff --git a/build.gradle.kts b/build.gradle.kts index 4da605585a..9ef5678b05 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -434,23 +434,31 @@ fun Path.resolveParts(parts: Iterable): Path = fun Path.relativeTo(base: Path): Path = base.relativize(this) +fun Path.normalizedAbsolute(): Path = + toAbsolutePath().normalize() + fun convertCacheToLocalMavenRepo(source: Path, destination: Path, logger: Logger) { val allowedExtensions = setOf("aar", "jar", "module", "pom") + val normalizedSource = source.normalizedAbsolute() + val normalizedDestination = destination.normalizedAbsolute() - require(Files.isDirectory(source)) { - "Maven cache directory does not exist or is not a directory: $source" + require(Files.isDirectory(normalizedSource)) { + "Maven cache directory does not exist or is not a directory: $normalizedSource" + } + require(!normalizedDestination.startsWith(normalizedSource)) { + "Local Maven output directory must not be inside the input cache: $normalizedDestination" } - Files.createDirectories(destination) + Files.createDirectories(normalizedDestination) - source.toFile().walkTopDown() + normalizedSource.toFile().walkTopDown() .filter { it.isFile } .filter { it.extension.lowercase() in allowedExtensions } .forEach { file -> val filePath = file.toPath() val relativeParent = filePath.parent - ?.let { source.relativize(it).map(Path::toString).toList() } + ?.let { normalizedSource.relativize(it).map(Path::toString).toList() } .orEmpty() val targetParts = @@ -460,30 +468,33 @@ fun convertCacheToLocalMavenRepo(source: Path, destination: Path, logger: Logger relativeParent.dropLast(1) } - val targetParent = destination.resolveParts(targetParts) + val targetParent = normalizedDestination.resolveParts(targetParts) val targetFile = targetParent.resolve(file.name) Files.createDirectories(targetParent) Files.copy(filePath, targetFile, StandardCopyOption.REPLACE_EXISTING) - logger.lifecycle("Copied ${filePath.relativeTo(source)} -> ${targetFile.relativeTo(destination)}") + logger.lifecycle("Copied ${filePath.relativeTo(normalizedSource)} -> ${targetFile.relativeTo(normalizedDestination)}") } } fun zeroCompressMavenRepo(source: Path, destination: Path, validateArchives: Boolean, logger: Logger) { - require(Files.isDirectory(source)) { - "Maven repository directory does not exist or is not a directory: $source" + val normalizedSource = source.normalizedAbsolute() + val normalizedDestination = destination.normalizedAbsolute() + + require(Files.isDirectory(normalizedSource)) { + "Maven repository directory does not exist or is not a directory: $normalizedSource" } - require(!destination.startsWith(source)) { - "Zero-compressed output directory must not be inside the input repository: $destination" + require(!normalizedDestination.startsWith(normalizedSource)) { + "Zero-compressed output directory must not be inside the input repository: $normalizedDestination" } - Files.createDirectories(destination) + Files.createDirectories(normalizedDestination) - source.toFile().walkTopDown() + normalizedSource.toFile().walkTopDown() .filter { it.isFile } .forEach { file -> val sourceFile = file.toPath() - val targetFile = destination.resolve(source.relativize(sourceFile)) + val targetFile = normalizedDestination.resolve(normalizedSource.relativize(sourceFile)) Files.createDirectories(targetFile.parent) @@ -493,7 +504,7 @@ fun zeroCompressMavenRepo(source: Path, destination: Path, validateArchives: Boo if (validateArchives) { validateZeroCompressedArchive(sourceFile, targetFile) } - logger.lifecycle("Zero-compressed ${sourceFile.relativeTo(source)}") + logger.lifecycle("Zero-compressed ${sourceFile.relativeTo(normalizedSource)}") } else -> Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING) } From 9fbbf5396a274802d9f1c05f823a045329d6af88 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Tue, 26 May 2026 12:08:28 -0700 Subject: [PATCH 6/7] constant definitions --- .../androidide/AutomationEndToEndTest.kt | 15 ++- ...izationProjectAndCancelingBuildScenario.kt | 94 ++++++++++++------- .../scenarios/RunAssembleTasksScenario.kt | 32 ++++--- .../screens/ProjectSettingsScreen.kt | 22 +++-- 4 files changed, 105 insertions(+), 58 deletions(-) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt index 40f5febf4a..e792b87ddb 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/AutomationEndToEndTest.kt @@ -28,6 +28,11 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +private const val LAUNCH_SETTLE_DELAY_MS = 1_000L +private const val PRIVACY_DIALOG_TIMEOUT_MS = 2_000L +private const val PERMISSIONS_ASSERTION_TIMEOUT_MS = 3_000L +private const val KOTLIN_LANGUAGE_TEMPLATE_COUNT = 7 + /** * Single continuous E2E test that drives the app from first launch through * onboarding, project creation, builds, and beyond. @@ -57,7 +62,7 @@ class AutomationEndToEndTest : TestCase() { step("Launch app") { ActivityScenario.launch(SplashActivity::class.java) - Thread.sleep(1000) + Thread.sleep(LAUNCH_SETTLE_DELAY_MS) } // ── Welcome Screen ── @@ -80,7 +85,7 @@ class AutomationEndToEndTest : TestCase() { 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("Dialog title missing", title.waitForExists(PRIVACY_DIALOG_TIMEOUT_MS)) assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists()) assertTrue("Learn more button missing", d.findObject(UiSelector().text(learnMoreText)).exists()) } @@ -100,7 +105,7 @@ class AutomationEndToEndTest : TestCase() { val required = PermissionsHelper.getRequiredPermissions(targetContext) step("Verify all permission items") { - flakySafely(timeoutMs = 3_000) { + flakySafely(timeoutMs = PERMISSIONS_ASSERTION_TIMEOUT_MS) { PermissionScreen { title { isVisible() } subTitle { isVisible() } @@ -136,7 +141,7 @@ class AutomationEndToEndTest : TestCase() { grantAllRequiredPermissionsThroughOnboardingUi() step("Confirm all permissions granted") { - flakySafely(timeoutMs = 3_000) { + flakySafely(timeoutMs = PERMISSIONS_ASSERTION_TIMEOUT_MS) { assertTrue(PermissionsHelper.areAllPermissionsGranted(targetContext)) } } @@ -198,7 +203,7 @@ class AutomationEndToEndTest : TestCase() { TemplateConfig("Compose Activity", R.string.template_compose, "TestComposeActivity"), ) - val kotlinLanguageTemplates = defaultLanguageTemplates.take(7).map { config -> + val kotlinLanguageTemplates = defaultLanguageTemplates.take(KOTLIN_LANGUAGE_TEMPLATE_COUNT).map { config -> config.copy( label = "Kotlin ${config.label}", projectName = "Kt${config.projectName}", diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt index 0f1dbac806..35c012f69d 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/InitializationProjectAndCancelingBuildScenario.kt @@ -10,6 +10,22 @@ import com.kaspersky.kaspresso.testcases.api.scenario.Scenario import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext private const val CLOSE_PROJECT_TAG = "CloseProjectScenario" +private const val DEFAULT_TOOLBAR_BUTTON_TIMEOUT_MS = 10_000L +private const val SHORT_UI_TIMEOUT_MS = 2_000L +private const val DEFAULT_UI_TIMEOUT_MS = 3_000L +private const val CLOSE_DIALOG_TIMEOUT_MS = 10_000L +private const val INSTALLER_GONE_TIMEOUT_MS = 5_000L +private const val POLL_INTERVAL_MS = 1_000L +private const val PROJECT_STATUS_LOG_INTERVAL_MS = 15_000L +private const val CLOSE_DIALOG_RETRY_DELAY_MS = 2_000L +private const val PROJECT_MENU_FALLBACK_X_OFFSET = 48 +private const val PROJECT_MENU_FALLBACK_Y_OFFSET = 70 +private const val PROJECT_MENU_FALLBACK_Y = 140 +private const val SYSTEM_BACK_BUTTON_X_RATIO = 0.24f +private const val SYSTEM_BACK_BUTTON_BOTTOM_OFFSET = 40 +private const val PRESS_BACK_RETRY_COUNT = 2 +private const val SYSTEM_BACK_TAP_RETRY_COUNT = 3 +private const val FALLBACK_CLOSE_RETRY_COUNT = 4 private fun closeProjectLog(message: String) { Log.e(CLOSE_PROJECT_TAG, message) @@ -20,7 +36,10 @@ class InitializationProjectAndCancelingBuildScenario( private val closeProjectAfterBuild: Boolean = true, ) : Scenario() { - private fun TestContext.clickToolbarButton(description: String, waitMs: Long = 10_000) { + private fun TestContext.clickToolbarButton( + description: String, + waitMs: Long = DEFAULT_TOOLBAR_BUTTON_TIMEOUT_MS, + ) { val d = device.uiDevice val btn = d.findObject(UiSelector().descriptionContains(description)) check(btn.waitForExists(waitMs)) { "Toolbar button '$description' not found" } @@ -32,7 +51,7 @@ class InitializationProjectAndCancelingBuildScenario( step("Dismiss first build notice and start init") { val d = device.uiDevice val okBtn = d.findObject(UiSelector().text("OK").className("android.widget.Button")) - if (okBtn.waitForExists(3_000)) { + if (okBtn.waitForExists(DEFAULT_UI_TIMEOUT_MS)) { clickFirstAccessibilityNodeByText("OK") d.waitForIdle() } @@ -41,7 +60,7 @@ class InitializationProjectAndCancelingBuildScenario( val toolbar = d.findObject( UiSelector().resourceIdMatches(".*:id/editor_appBarLayout") ) - check(toolbar.waitForExists(3_000)) { "Editor toolbar not found" } + check(toolbar.waitForExists(DEFAULT_UI_TIMEOUT_MS)) { "Editor toolbar not found" } d.waitForIdle() // The button may be "Sync project" or "Quick run" depending on state clickToolbarButton("Sync project") @@ -65,12 +84,12 @@ class InitializationProjectAndCancelingBuildScenario( if (!initialized) { val now = System.currentTimeMillis() - if (now - lastLogAt > 15_000L) { + if (now - lastLogAt > PROJECT_STATUS_LOG_INTERVAL_MS) { closeProjectLog("Waiting for project initialization or enabled Quick run") lastLogAt = now } - d.waitForIdle(2_000) - Thread.sleep(1_000) + d.waitForIdle(SHORT_UI_TIMEOUT_MS) + Thread.sleep(POLL_INTERVAL_MS) } } @@ -108,12 +127,12 @@ class InitializationProjectAndCancelingBuildScenario( if (installer.exists()) { closeProjectLog("Quick-run installer appeared; dismissing") val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) - if (cancel.waitForExists(2_000)) { + if (cancel.waitForExists(SHORT_UI_TIMEOUT_MS)) { cancel.click() } else { d.pressBack() } - runCatching { installer.waitUntilGone(5_000) } + runCatching { installer.waitUntilGone(INSTALLER_GONE_TIMEOUT_MS) } d.waitForIdle() return@step } @@ -139,13 +158,13 @@ class InitializationProjectAndCancelingBuildScenario( } val now = System.currentTimeMillis() - if (now - lastLogAt > 15_000L) { + if (now - lastLogAt > PROJECT_STATUS_LOG_INTERVAL_MS) { closeProjectLog("Still waiting for quick-run installer or build success") lastLogAt = now } - d.waitForIdle(2_000) - Thread.sleep(1_000) + d.waitForIdle(SHORT_UI_TIMEOUT_MS) + Thread.sleep(POLL_INTERVAL_MS) } error("Quick-run timed out waiting for installer or build success") @@ -166,7 +185,7 @@ class InitializationProjectAndCancelingBuildScenario( step("Dismiss post-build overlays") { val d = device.uiDevice val dismiss = d.findObject(UiSelector().text("Dismiss")) - if (dismiss.waitForExists(3_000)) { + if (dismiss.waitForExists(DEFAULT_UI_TIMEOUT_MS)) { clickFirstAccessibilityNodeByText("Dismiss") d.waitForIdle() } @@ -188,7 +207,7 @@ class InitializationProjectAndCancelingBuildScenario( UiSelector().textContains("without saving"), UiSelector().text(saveAndClose), ).firstNotNullOfOrNull { selector -> - d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + d.findObject(selector).takeIf { it.waitForExists(SHORT_UI_TIMEOUT_MS) && it.exists() } } fun findCloseProjectControl() = @@ -198,52 +217,61 @@ class InitializationProjectAndCancelingBuildScenario( UiSelector().text(closeProject), UiSelector().textContains(closeProject), ).firstNotNullOfOrNull { selector -> - d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + d.findObject(selector).takeIf { it.waitForExists(SHORT_UI_TIMEOUT_MS) && it.exists() } } fun tapVisibleProjectMenuFallback() { val toolbar = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) - val bounds = toolbar.takeIf { it.waitForExists(3_000) && it.exists() }?.visibleBounds - val x = bounds?.let { it.left + 48 } ?: 48 - val y = bounds?.let { it.top + 70 } ?: 140 + val bounds = toolbar + .takeIf { it.waitForExists(DEFAULT_UI_TIMEOUT_MS) && it.exists() } + ?.visibleBounds + val x = bounds?.let { it.left + PROJECT_MENU_FALLBACK_X_OFFSET } ?: PROJECT_MENU_FALLBACK_X_OFFSET + val y = bounds?.let { it.top + PROJECT_MENU_FALLBACK_Y_OFFSET } ?: PROJECT_MENU_FALLBACK_Y d.click(x, y) d.waitForIdle() } fun tapSystemBackButton() { - d.click((d.displayWidth * 0.24f).toInt(), d.displayHeight - 40) + d.click( + (d.displayWidth * SYSTEM_BACK_BUTTON_X_RATIO).toInt(), + d.displayHeight - SYSTEM_BACK_BUTTON_BOTTOM_OFFSET, + ) d.waitForIdle() } var closeDialogButton = findCloseDialogButton() - repeat(2) { attempt -> + repeat(PRESS_BACK_RETRY_COUNT) { attempt -> if (closeDialogButton == null) { - closeProjectLog("pressBack attempt ${attempt + 1}/2") + closeProjectLog("pressBack attempt ${attempt + 1}/$PRESS_BACK_RETRY_COUNT") d.pressBack() d.waitForIdle() - Thread.sleep(2_000) + Thread.sleep(CLOSE_DIALOG_RETRY_DELAY_MS) closeDialogButton = findCloseDialogButton() - closeProjectLog("close dialog after pressBack attempt ${attempt + 1}: ${closeDialogButton != null}") + closeProjectLog( + "close dialog after pressBack attempt ${attempt + 1}: ${closeDialogButton != null}" + ) } } if (closeDialogButton != null) { - closeDialogButton?.click() + closeDialogButton.click() d.waitForIdle() return@step } - repeat(3) { attempt -> + repeat(SYSTEM_BACK_TAP_RETRY_COUNT) { attempt -> if (closeDialogButton == null) { - closeProjectLog("system Back button tap attempt ${attempt + 1}/3") + closeProjectLog("system Back button tap attempt ${attempt + 1}/$SYSTEM_BACK_TAP_RETRY_COUNT") tapSystemBackButton() closeDialogButton = findCloseDialogButton() - closeProjectLog("close dialog after system Back tap attempt ${attempt + 1}: ${closeDialogButton != null}") + closeProjectLog( + "close dialog after system Back tap attempt ${attempt + 1}: ${closeDialogButton != null}" + ) } } if (closeDialogButton != null) { - closeDialogButton?.click() + closeDialogButton.click() d.waitForIdle() return@step } @@ -255,7 +283,7 @@ class InitializationProjectAndCancelingBuildScenario( UiSelector().descriptionContains(openDrawer), UiSelector().descriptionContains(closeDrawer), ).firstNotNullOfOrNull { selector -> - d.findObject(selector).takeIf { it.waitForExists(2_000) && it.exists() } + d.findObject(selector).takeIf { it.waitForExists(SHORT_UI_TIMEOUT_MS) && it.exists() } } if (drawer != null) { @@ -281,19 +309,19 @@ class InitializationProjectAndCancelingBuildScenario( UiSelector().textContains("without saving"), UiSelector().text(saveAndClose), ).firstNotNullOfOrNull { selector -> - d.findObject(selector).takeIf { it.waitForExists(10_000) && it.exists() } + d.findObject(selector).takeIf { it.waitForExists(CLOSE_DIALOG_TIMEOUT_MS) && it.exists() } } if (closeButton != null) { closeButton.click() } else { var projectClosed = false - repeat(4) { attempt -> + repeat(FALLBACK_CLOSE_RETRY_COUNT) { attempt -> if (projectClosed) { return@repeat } - closeProjectLog("fallback pressBack attempt ${attempt + 1}/4") + closeProjectLog("fallback pressBack attempt ${attempt + 1}/$FALLBACK_CLOSE_RETRY_COUNT") d.pressBack() d.waitForIdle() @@ -302,7 +330,7 @@ class InitializationProjectAndCancelingBuildScenario( UiSelector().textContains("without saving"), UiSelector().text(saveAndClose), ).firstNotNullOfOrNull { selector -> - d.findObject(selector).takeIf { it.waitForExists(3_000) && it.exists() } + d.findObject(selector).takeIf { it.waitForExists(DEFAULT_UI_TIMEOUT_MS) && it.exists() } } if (fallbackCloseButton != null) { @@ -310,7 +338,7 @@ class InitializationProjectAndCancelingBuildScenario( projectClosed = true } - if (!projectClosed && attempt == 3) { + if (!projectClosed && attempt == FALLBACK_CLOSE_RETRY_COUNT - 1) { error("Close project dialog not found") } } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt index 037a6e1345..37aa151486 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/scenarios/RunAssembleTasksScenario.kt @@ -11,6 +11,14 @@ import com.kaspersky.kaspresso.testcases.api.scenario.Scenario import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext private const val RUN_ASSEMBLE_TAG = "RunAssembleTasks" +private const val SHORT_UI_TIMEOUT_MS = 2_000L +private const val DEFAULT_UI_TIMEOUT_MS = 3_000L +private const val INSTALLER_GONE_TIMEOUT_MS = 5_000L +private const val RUN_TASKS_DIALOG_TIMEOUT_MS = 10_000L +private const val TASK_SELECTION_TIMEOUT_MS = 20_000L +private const val EDITOR_TOOLBAR_TIMEOUT_MS = 30_000L +private const val BUILD_LOG_INTERVAL_MS = 10_000L +private const val POLL_INTERVAL_MS = 1_000L private fun runAssembleLog(message: String) { Log.e(RUN_ASSEMBLE_TAG, message) @@ -25,7 +33,7 @@ class RunAssembleTasksScenario( step("Dismiss post-build overlays before running assemble tasks") { val d = device.uiDevice val dismiss = d.findObject(UiSelector().text("Dismiss")) - if (dismiss.waitForExists(3_000)) { + if (dismiss.waitForExists(DEFAULT_UI_TIMEOUT_MS)) { dismiss.click() d.waitForIdle() } @@ -35,12 +43,12 @@ class RunAssembleTasksScenario( if (installer.exists()) { runAssembleLog("Package installer still visible before assemble tasks; dismissing") val cancel = d.findObject(UiSelector().textMatches("(?i)cancel")) - if (cancel.waitForExists(2_000)) { + if (cancel.waitForExists(SHORT_UI_TIMEOUT_MS)) { cancel.click() } else { d.pressBack() } - runCatching { installer.waitUntilGone(5_000) } + runCatching { installer.waitUntilGone(INSTALLER_GONE_TIMEOUT_MS) } d.waitForIdle() } } @@ -50,12 +58,12 @@ class RunAssembleTasksScenario( val targetContext = InstrumentationRegistry.getInstrumentation().targetContext val runTasksDescription = targetContext.getString(R.string.cd_toolbar_run_gradle_tasks) val toolbar = d.findObject(UiSelector().resourceIdMatches(".*:id/editor_appBarLayout")) - check(toolbar.waitForExists(30_000)) { "Editor toolbar not found" } + check(toolbar.waitForExists(EDITOR_TOOLBAR_TIMEOUT_MS)) { "Editor toolbar not found" } clickFirstAccessibilityNodeByDescription(runTasksDescription) d.waitForIdle() val title = targetContext.getString(R.string.title_run_tasks) - check(d.findObject(UiSelector().text(title)).waitForExists(10_000)) { + check(d.findObject(UiSelector().text(title)).waitForExists(RUN_TASKS_DIALOG_TIMEOUT_MS)) { "Run tasks dialog did not open" } } @@ -63,7 +71,7 @@ class RunAssembleTasksScenario( step("Filter assemble tasks") { val d = device.uiDevice val search = d.findObject(UiSelector().className("android.widget.EditText")) - check(search.waitForExists(10_000)) { "Run tasks search field not found" } + check(search.waitForExists(RUN_TASKS_DIALOG_TIMEOUT_MS)) { "Run tasks search field not found" } search.setText("assemble") d.waitForIdle() } @@ -72,11 +80,11 @@ class RunAssembleTasksScenario( step("Select Gradle task $task") { val d = device.uiDevice var taskNode = d.findObject(UiSelector().text(task)) - if (!taskNode.waitForExists(3_000)) { + if (!taskNode.waitForExists(DEFAULT_UI_TIMEOUT_MS)) { UiScrollable(UiSelector().scrollable(true)).scrollTextIntoView(task) taskNode = d.findObject(UiSelector().text(task)) } - check(taskNode.waitForExists(20_000)) { + check(taskNode.waitForExists(TASK_SELECTION_TIMEOUT_MS)) { "Task not found in Run tasks dialog: $task" } clickFirstAccessibilityNodeParentByText(task) @@ -87,12 +95,12 @@ class RunAssembleTasksScenario( step("Confirm and run selected Gradle tasks") { val d = device.uiDevice val runButton = d.findObject(UiSelector().resourceIdMatches(".*:id/exec")) - check(runButton.waitForExists(10_000)) { "Run tasks execute button not found" } + check(runButton.waitForExists(RUN_TASKS_DIALOG_TIMEOUT_MS)) { "Run tasks execute button not found" } runButton.click() d.waitForIdle() tasks.forEach { task -> - check(d.findObject(UiSelector().textContains(task)).waitForExists(10_000)) { + check(d.findObject(UiSelector().textContains(task)).waitForExists(RUN_TASKS_DIALOG_TIMEOUT_MS)) { "Run tasks confirmation missing selected task: $task" } } @@ -125,12 +133,12 @@ class RunAssembleTasksScenario( } val now = System.currentTimeMillis() - if (now - lastLogAt > 10_000L) { + if (now - lastLogAt > BUILD_LOG_INTERVAL_MS) { runAssembleLog("Still waiting for selected assemble tasks to finish") lastLogAt = now } - Thread.sleep(1_000) + Thread.sleep(POLL_INTERVAL_MS) d.waitForIdle() } diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt index abc17eb795..4f2935b96a 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/screens/ProjectSettingsScreen.kt @@ -11,6 +11,12 @@ import io.github.kakaocup.kakao.spinner.KSpinner import io.github.kakaocup.kakao.spinner.KSpinnerItem import io.github.kakaocup.kakao.text.KButton +private const val KOTLIN_LANGUAGE_SELECTION_TIMEOUT_MS = 30_000L +private const val LANGUAGE_OPTION_TIMEOUT_MS = 5_000L +private const val LANGUAGE_DROPDOWN_EXPANSION_TIMEOUT_MS = 2_000L +private const val PROJECT_NAME_FIELD_TIMEOUT_MS = 3_000L +private const val LANGUAGE_DROPDOWN_FALLBACK_X_OFFSET = 80 + object ProjectSettingsScreen : KScreen() { override val layoutId: Int? = null @@ -51,13 +57,13 @@ object ProjectSettingsScreen : KScreen() { fun TestContext.selectKotlinLanguage() { step("Select the kotlin language") { - flakySafely(30000) { + flakySafely(KOTLIN_LANGUAGE_SELECTION_TIMEOUT_MS) { val kotlinText = device.targetContext.getString(R.string.lang_kotlin) openProjectLanguageDropdown() val d = device.uiDevice val kotlin = d.findObject(UiSelector().text(kotlinText)) - check(kotlin.waitForExists(5_000)) { "Kotlin language option not found" } + check(kotlin.waitForExists(LANGUAGE_OPTION_TIMEOUT_MS)) { "Kotlin language option not found" } kotlin.click() d.waitForIdle() } @@ -71,21 +77,21 @@ object ProjectSettingsScreen : KScreen() { val languageLabelText = device.targetContext.getString(R.string.wizard_language) val javaValue = d.findObject(UiSelector().text(javaText)) - if (javaValue.waitForExists(5_000)) { + if (javaValue.waitForExists(LANGUAGE_OPTION_TIMEOUT_MS)) { val bounds = javaValue.visibleBounds d.click(bounds.centerX(), bounds.centerY()) d.waitForIdle() - if (d.findObject(UiSelector().text(kotlinText)).waitForExists(2_000)) { + if (d.findObject(UiSelector().text(kotlinText)).waitForExists(LANGUAGE_DROPDOWN_EXPANSION_TIMEOUT_MS)) { return } } val languageLabel = d.findObject(UiSelector().text(languageLabelText)) - if (languageLabel.waitForExists(5_000)) { + if (languageLabel.waitForExists(LANGUAGE_OPTION_TIMEOUT_MS)) { val bounds = languageLabel.visibleBounds - d.click(d.displayWidth - 80, bounds.centerY()) + d.click(d.displayWidth - LANGUAGE_DROPDOWN_FALLBACK_X_OFFSET, bounds.centerY()) d.waitForIdle() - if (d.findObject(UiSelector().text(kotlinText)).waitForExists(2_000)) { + if (d.findObject(UiSelector().text(kotlinText)).waitForExists(LANGUAGE_DROPDOWN_EXPANSION_TIMEOUT_MS)) { return } } @@ -105,7 +111,7 @@ object ProjectSettingsScreen : KScreen() { step("Set project name to '$name'") { val d = device.uiDevice val byText = d.findObject(UiSelector().textStartsWith("My Application")) - check(byText.waitForExists(3_000)) { "Project name field not found" } + check(byText.waitForExists(PROJECT_NAME_FIELD_TIMEOUT_MS)) { "Project name field not found" } setAccessibilityEditText("My Application", name, "project name") d.waitForIdle() } From 674365b4dc58262cd3876dcca0326f594b3d8c42 Mon Sep 17 00:00:00 2001 From: Joel Menchavez Date: Tue, 26 May 2026 18:47:15 -0700 Subject: [PATCH 7/7] export gradle-api-*.jar --- .../androidide/ExportCacheDirectoryTest.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt index 3896e93794..317160cb1a 100644 --- a/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt +++ b/app/src/androidTest/kotlin/com/itsaky/androidide/ExportCacheDirectoryTest.kt @@ -37,6 +37,39 @@ class ExportCacheDirectoryTest { assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists()) } + @Test + fun exportGeneratedGradleApiJarWhenRequested() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val args = InstrumentationRegistry.getArguments() + val version = args.getString(ARG_GRADLE_API_VERSION) + + assumeTrue("No Gradle API version requested", !version.isNullOrBlank()) + require(!version!!.contains(File.separatorChar)) { + "Gradle API version must not contain path separators: $version" + } + + val destinationRelativePath = + args.getString(ARG_GRADLE_API_DESTINATION_RELATIVE_PATH) + ?: DEFAULT_GRADLE_API_DESTINATION_RELATIVE_PATH + + val source = + File( + context.filesDir, + "$GRADLE_CACHE_RELATIVE_PATH/$version/generated-gradle-jars/gradle-api-$version.jar", + ) + val externalBase = File(Environment.getExternalStorageDirectory(), EXPORT_BASE_DIRECTORY).canonicalFile + val destinationDir = resolveSafeDestination(externalBase, destinationRelativePath) + val destination = File(destinationDir, source.name) + + assertTrue("Source does not exist: ${source.absolutePath}", source.exists()) + assertTrue("Source is not a file: ${source.absolutePath}", source.isFile) + + destination.parentFile?.mkdirs() + source.copyTo(target = destination, overwrite = true) + + assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists()) + } + private fun resolveSafeDestination(base: File, relativePath: String): File { require(relativePath.isNotBlank()) { "Destination path must be a non-blank relative path" @@ -54,8 +87,12 @@ class ExportCacheDirectoryTest { private companion object { const val SOURCE_RELATIVE_PATH = "home/.gradle/caches/modules-2/files-2.1" + const val GRADLE_CACHE_RELATIVE_PATH = "home/.gradle/caches" const val ARG_DESTINATION_RELATIVE_PATH = "androidide.exportCache.destination" + const val ARG_GRADLE_API_VERSION = "androidide.exportGradleApi.version" + const val ARG_GRADLE_API_DESTINATION_RELATIVE_PATH = "androidide.exportGradleApi.destination" const val EXPORT_BASE_DIRECTORY = "CodeOnTheGoProjects" const val DEFAULT_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-cache/modules-2/files-2.1" + const val DEFAULT_GRADLE_API_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-api" } }