diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index cf12fd35d..9b17e083f 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -3,6 +3,19 @@ diff --git a/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt b/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt deleted file mode 100644 index 10a31a3be..000000000 --- a/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide - -import android.util.Log -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.itsaky.androidide.R.string -import com.itsaky.androidide.app.IDEApplication -import com.itsaky.androidide.shell.executeProcessAsync -import com.itsaky.androidide.testing.android.clickAndWaitForWindowUpdate -import com.itsaky.androidide.testing.android.findObjectWithText -import com.itsaky.androidide.testing.android.hasObjectWithText -import com.itsaky.androidide.testing.android.launchAndroidIDE -import com.itsaky.androidide.utils.Environment -import com.termux.shared.termux.shell.command.environment.TermuxShellEnvironment -import org.junit.Test -import org.junit.runner.RunWith - -/** - * A special test case which is used to install the build tools on a test device. This is used to - * setup the test device before running further tests. - * - * This class should not define any other tests. - * - * @author Akash Yadav - */ -@RunWith(AndroidJUnit4::class) -class BuildToolsInstallerTest { - - companion object { - private const val TAG = "BuildToolsInstallerTest" - } - - @Test - fun setupDevice() { - if (!System.getenv("IDE_INSTRUMENTATION_SETUP_BUILDTOOLS").toBoolean()) { - return - } - - val device = launchAndroidIDE() - - // Allow permissions - while (true) { - val obj = device.findObjectWithText("Allow") - obj?.clickAndWaitForWindowUpdate(device) ?: break - } - - // Dismiss the 'AndroidIDE statistics' dialog - if (device.hasObjectWithText(string.title_androidide_statistics) - && device.hasObjectWithText(string.msg_androidide_statistics) - ) { - device.findObjectWithText(string.btn_opt_in).clickAndWaitForWindowUpdate(device) - } - - // The 'Warning' dialog will be shown, no need to dismiss it. - - // Start the installation process - execProcAndLogOutput("pkg upd") - execProcAndLogOutput("yes | pkg upg") // do not use -y - execProcAndLogOutput("yes | idesetup -c") - - // Check if the tools were installed - assertThat(Environment.JAVA.exists()).isTrue() - assertThat(Environment.ANDROID_HOME.exists()).isTrue() - - Thread.sleep(5000L) - } - - private fun execProcAndLogOutput(cmd: String) { - val process = executeProcessAsync { - redirectErrorStream = true - workingDirectory = null - environment = TermuxShellEnvironment().getEnvironment(ApplicationProvider.getApplicationContext(), false) - command = listOf(Environment.SHELL.absolutePath, "-c", cmd).also { - Log.d(TAG, "setupDevice: Full command : ${it.joinToString(separator = " ")}") - } - } - - logOutput(cmd, process) - - process.waitFor() - } - - private fun logOutput(cmd: String, process: Process) { - process.inputStream.bufferedReader().forEachLine { - Log.d(TAG, "setupDevice: '${cmd}': $it") - } - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityBehaviorTest.kt b/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityBehaviorTest.kt new file mode 100644 index 000000000..0e39d6600 --- /dev/null +++ b/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityBehaviorTest.kt @@ -0,0 +1,231 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import android.Manifest +import android.content.Context +import android.os.Process +import android.widget.Button +import android.widget.GridLayout +import android.widget.Switch +import androidx.annotation.StringRes +import androidx.core.os.UserHandleCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import androidx.viewpager.widget.ViewPager +import com.adevinta.android.barista.rule.cleardata.ClearDatabaseRule +import com.adevinta.android.barista.rule.cleardata.ClearFilesRule +import com.adevinta.android.barista.rule.cleardata.ClearPreferencesRule +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.testing.android.clickAndWaitForNewWindow +import com.itsaky.androidide.testing.android.clickAndWaitForWindowUpdate +import com.itsaky.androidide.testing.android.findObjectWithText +import com.itsaky.androidide.testing.android.getApplicationContext +import com.itsaky.androidide.testing.android.getObjectWithText +import com.itsaky.androidide.testing.android.launchAndroidIDE +import com.itsaky.androidide.testing.android.stringRes +import com.itsaky.androidide.testing.android.uiAutomation +import com.itsaky.androidide.utils.isAtLeastT +import org.junit.FixMethodOrder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters + +/** + * @author Akash Yadav + */ +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class OnboardingActivityBehaviorTest { + + /** + * Clear shared preferences after tests. + */ + @JvmField + @Rule + val clearPrefsRule = ClearPreferencesRule() + + /** + * Clear databases after tests. + */ + @JvmField + @Rule + val clearDBsRule = ClearDatabaseRule() + + /** + * Delete private data files after each test. + */ + @JvmField + @Rule + val clearFilesRule = ClearFilesRule() + + @Test + fun A_testOnboarding_permissionScreen_storagePermissionBehavior() { + checkPermissionsBehavior(R.string.permission_desc_storage) + } + + @Test + fun B_testOnboarding_permissionScreen_installPackagesPermissionBehavior() { + checkPermissionsBehavior(R.string.permission_desc_install_packages) + } + + @Test + fun C_testOnboarding_toolsSetup_autoInstallBehavior() { + val device = launchAndroidIDE(fromLauncher = true, clearTasks = true) + device.grantAndroidIDEPermissions() + + val autoInstallSwitch = device.findObject(By.checkable(true).clazz(Switch::class.java)) + assertThat(autoInstallSwitch).isNotNull() + assertThat(autoInstallSwitch.isChecked).isTrue() + + // verify basic switch behavior + autoInstallSwitch.clickAndWaitForWindowUpdate(device) + assertThat(autoInstallSwitch.isChecked).isFalse() + assertThat(device.getObjectWithText(R.string.action_install_git).isEnabled).isFalse() + assertThat(device.getObjectWithText(R.string.action_install_openssh).isEnabled).isFalse() + autoInstallSwitch.clickAndWaitForWindowUpdate(device) + assertThat(autoInstallSwitch.isChecked).isTrue() + + // clicking on the 'done' button with 'Automatic installation' must navigate the user to + // terminal and start the installation + val doneButton = device.findObject(By.desc(stringRes(R.string.app_intro_done_button))) + assertThat(doneButton).isNotNull() + assertThat(doneButton.isClickable).isTrue() + + if (isAtLeastT()) { + // grant notification permission first + device.uiAutomation.grantRuntimePermissionAsUser( + getApplicationContext().packageName, + Manifest.permission.POST_NOTIFICATIONS, + UserHandleCompat.getUserHandleForUid(Process.myUid()) + ) + } + + doneButton.clickAndWaitForNewWindow() + + // 'Installing bootstrap packages' dialog.. + assertThat(device.findObjectWithText(R.string.bootstrap_installer_body)).isNotNull() + + // wait for the packages to be installed + // this should take a minute at most + // we wait until the installation finishes and the keys GridLayout is visible + device.wait(Until.hasObject(By.clazz(GridLayout::class.java)), 60 * 1000L) + + // verify that the terminal keys view is visible + assertThat(device.findObject(By.clazz(GridLayout::class.java))).isNotNull() + assertThat(device.findObject(By.clazz(ViewPager::class.java))).isNotNull() + } + + @Test + fun C_testOnboarding_toolsSetup_manualInstallBehavior() { + val device = launchAndroidIDE(fromLauncher = true, clearTasks = true) + device.grantAndroidIDEPermissions() + + val autoInstallSwitch = device.findObject(By.checkable(true).clazz(Switch::class.java)) + assertThat(autoInstallSwitch).isNotNull() + assertThat(autoInstallSwitch.isChecked).isTrue() + + // verify basic switch behavior + autoInstallSwitch.clickAndWaitForWindowUpdate(device) + assertThat(autoInstallSwitch.isChecked).isFalse() + assertThat(device.getObjectWithText(R.string.action_install_git).isEnabled).isFalse() + assertThat(device.getObjectWithText(R.string.action_install_openssh).isEnabled).isFalse() + + // clicking on the 'done' button with 'Automatic installation' must navigate the user to + // terminal and start the installation + val doneButton = device.findObject(By.desc(stringRes(R.string.app_intro_done_button))) + assertThat(doneButton).isNotNull() + assertThat(doneButton.isClickable).isTrue() + + if (isAtLeastT()) { + // grant notification permission first + device.uiAutomation.grantRuntimePermissionAsUser( + getApplicationContext().packageName, + Manifest.permission.POST_NOTIFICATIONS, + UserHandleCompat.getUserHandleForUid(Process.myUid()) + ) + } + + doneButton.clickAndWaitForNewWindow() + + // 'Installing bootstrap packages' dialog.. + assertThat(device.findObjectWithText(R.string.bootstrap_installer_body)).isNotNull() + + // wait for the packages to be installed + // this should take a minute at most + // we wait until the installation finishes and the keys GridLayout is visible + device.wait(Until.hasObject(By.clazz(GridLayout::class.java)), 60 * 1000L) + + // verify that the terminal keys view is visible + assertThat(device.findObject(By.clazz(GridLayout::class.java))).isNotNull() + assertThat(device.findObject(By.clazz(ViewPager::class.java))).isNotNull() + } + + private fun checkPermissionsBehavior(@StringRes permissionDesc: Int) { + val device = launchAndroidIDE(fromLauncher = true, clearTasks = true) + + device.nextButton().clickAndWaitForWindowUpdate(device) + device.nextButton().clickAndWaitForWindowUpdate(device) + + var grantButton = device.getObjectWithText(permissionDesc) + .parent!! + .parent!! + .findObject(By.clazz(Button::class.java)) + + // verify that clicking the 'Grant' button next to storage navigates requests the permissions + grantButton.clickAndWaitForNewWindow() + + // check permission denied behavior + device.handleAndroidRPermissions(false) + + // verify AndroidIDE is visible + assertThat(device.currentPackageName).isEqualTo(BuildInfo.PACKAGE_NAME) + + grantButton = device.getObjectWithText(permissionDesc) + .parent!! + .parent!! + .findObject(By.clazz(Button::class.java)) + + // verify button state after permission was not granted + assertThat(grantButton).isNotNull() + assertThat(grantButton.text).isNotNull() + + // navigate to the permission page again + grantButton.clickAndWaitForNewWindow() + + // check permission granted behavior + device.handleAndroidRPermissions(true) + + grantButton = device.getObjectWithText(permissionDesc) + .parent!! + .parent!! + .findObject(By.clazz(Button::class.java)) + + // verify AndroidIDE is visible + assertThat(device.currentPackageName).isEqualTo(BuildInfo.PACKAGE_NAME) + + // verify button state after permission was granted + assertThat(grantButton).isNotNull() + assertThat(grantButton.text).isNull() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityStatesTest.kt b/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityStatesTest.kt new file mode 100644 index 000000000..b46d1a332 --- /dev/null +++ b/app/src/androidTest/java/com/itsaky/androidide/activities/OnboardingActivityStatesTest.kt @@ -0,0 +1,164 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.FlakyTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.adevinta.android.barista.assertion.BaristaCheckedAssertions.assertChecked +import com.adevinta.android.barista.assertion.BaristaCheckedAssertions.assertUnchecked +import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertDisabled +import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertEnabled +import com.adevinta.android.barista.assertion.BaristaRecyclerViewAssertions.assertRecyclerViewItemCount +import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed +import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertNotDisplayed +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickBack +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.rule.BaristaRule +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.preferences.internal.statOptIn +import com.itsaky.androidide.testing.android.launchAndroidIDE +import com.itsaky.androidide.testing.android.stringRes +import org.junit.FixMethodOrder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters + + +/** + * @author Akash Yadav + */ +@Suppress("TestFunctionName") +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class OnboardingActivityStatesTest { + + @JvmField + @Rule + val baristaActivityRule = BaristaRule.create(OnboardingActivity::class.java) + + @Test + fun A_testOnboarding_welcomeScreen() { + baristaActivityRule.launchActivity() + + assertDisplayed(stringRes(R.string.greeting_title)) + assertDisplayed(stringRes(R.string.greeting_subtitle)) + assertDisplayed(R.id.next) + assertNotDisplayed(R.id.back) + } + + @Test + fun B_testOnboarding_statConsentScreen() { + baristaActivityRule.launchActivity() + + clickOn(R.id.next) + + assertDisplayed(R.id.next) + assertDisplayed(stringRes(R.string.title_androidide_statistics)) + assertDisplayed(stringRes(R.string.idepref_stats_optIn_summary)) + assertDisplayed(stringRes(R.string.msg_androidide_statistics)) + + assertDisplayed(R.id.stat_opt_in, stringRes(R.string.title_androidide_statistics)) + + assertThat(statOptIn).isTrue() + assertChecked(R.id.stat_opt_in) + + // disable and navigate to next screen, then verify + clickOn(R.id.stat_opt_in) + clickOn(R.id.next) + assertThat(statOptIn).isFalse() + assertUnchecked(R.id.stat_opt_in) + + // Navigate back (statistics) + clickBack() + + // enable again and navigate to next screen, then verify + clickOn(R.id.stat_opt_in) + clickOn(R.id.next) + assertThat(statOptIn).isTrue() + assertChecked(R.id.stat_opt_in) + } + + @Test + fun C_testOnboarding_permissionsScreen() { + baristaActivityRule.launchActivity() + + clickOn(R.id.next) + clickOn(R.id.next) + + assertDisplayed(stringRes(R.string.onboarding_title_permissions)) + assertDisplayed(stringRes(R.string.permission_title_storage)) + assertDisplayed(stringRes(R.string.permission_desc_storage)) + assertDisplayed(stringRes(R.string.permission_title_install_packages)) + assertDisplayed(stringRes(R.string.permission_desc_install_packages)) + + assertRecyclerViewItemCount(R.id.onboarding_items, 2) + + clickOn(R.id.next) + assertDisplayed(stringRes(R.string.msg_grant_permissions)) + } + + @Test + fun F_testOnboarding_toolsSetup() { + + baristaActivityRule.launchActivity() + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.grantAndroidIDEPermissions() + + assertDisplayed(stringRes(R.string.title_install_tools)) + assertDisplayed(stringRes(R.string.subtitle_install_tools)) + assertDisplayed(R.id.auto_install_switch) + assertDisplayed(R.id.sdk_version_layout) + assertDisplayed(R.id.jdk_version_layout) + assertDisplayed(R.id.install_git) + assertDisplayed(R.id.install_openssh) + + assertEnabled(R.id.install_git) + assertEnabled(R.id.install_openssh) + assertEnabled(R.id.sdk_version_layout) + assertEnabled(R.id.jdk_version_layout) + + assertChecked(R.id.auto_install_switch) + assertChecked(R.id.install_git) + assertChecked(R.id.install_openssh) + + // switch to manual install + clickOn(R.id.auto_install_switch) + + // check that the auto-install related fields are disabled + assertUnchecked(R.id.auto_install_switch) + assertDisabled(R.id.install_git) + assertDisabled(R.id.install_openssh) + assertDisabled(R.id.sdk_version_layout) + assertDisabled(R.id.jdk_version_layout) + + // switch back to auto-install + clickOn(R.id.auto_install_switch) + + // check that the auto-instal related fields are enabled again + assertChecked(R.id.auto_install_switch) + assertEnabled(R.id.install_git) + assertEnabled(R.id.install_openssh) + assertEnabled(R.id.sdk_version_layout) + assertEnabled(R.id.jdk_version_layout) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/itsaky/androidide/activities/onboardingUtils.kt b/app/src/androidTest/java/com/itsaky/androidide/activities/onboardingUtils.kt new file mode 100644 index 000000000..8e706b181 --- /dev/null +++ b/app/src/androidTest/java/com/itsaky/androidide/activities/onboardingUtils.kt @@ -0,0 +1,92 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities + +import android.widget.Button +import android.widget.Switch +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiSelector +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.testing.android.clickAndWaitForNewWindow +import com.itsaky.androidide.testing.android.clickAndWaitForWindowUpdate +import com.itsaky.androidide.testing.android.getObjectWithText +import com.itsaky.androidide.testing.android.hasObjectWithText + +/** + * @author Akash Yadav + */ + +fun UiDevice.grantAndroidIDEPermissions() { + nextButton().clickAndWaitForWindowUpdate(this) + + if (hasObjectWithText(R.string.title_androidide_statistics)) { + // If we haven't opted-in for statistics yet... + nextButton().clickAndWaitForWindowUpdate(this) + } + + if (hasObjectWithText(R.string.onboarding_title_permissions)) { + // If the permissions are not granted, grant them + for (desc in arrayOf(R.string.permission_desc_storage, + R.string.permission_desc_install_packages)) { + val grantButton = getObjectWithText(desc) + .parent!! + .parent!! + .findObject(By.clazz(Button::class.java)) + + if (grantButton.text != null) { + grantButton.clickAndWaitForNewWindow() + handleAndroidRPermissions(allowPermission = true) + } + } + + nextButton().clickAndWaitForWindowUpdate(this) + } +} + +fun UiDevice.nextButton(): UiObject2 { + return findObject(By.desc("NEXT"))!! +} + +fun UiDevice.handleAndroidRPermissions( + allowPermission: Boolean = true, +) { + assertThat(currentPackageName).isEqualTo("com.android.settings") + assertThat(hasObjectWithText(R.string.app_name)).isTrue() + + val storageSwitch = findObject(UiSelector().className(Switch::class.java)) + assertThat(storageSwitch).isNotNull() + + // verify initial state + assertThat(storageSwitch.isCheckable).isTrue() + assertThat(storageSwitch.isEnabled).isTrue() + assertThat(storageSwitch.isChecked).isFalse() + + if (allowPermission) { + // enable permission + storageSwitch.click() + + // verify changed state + assertThat(storageSwitch.isChecked).isTrue() + } + + // navigate back without enabling the permission + pressBack() +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/itsaky/androidide/fragments/MainFragmentTest.kt b/app/src/androidTest/java/com/itsaky/androidide/fragments/MainFragmentTest.kt deleted file mode 100644 index ce29c9d1c..000000000 --- a/app/src/androidTest/java/com/itsaky/androidide/fragments/MainFragmentTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.fragments - -import android.view.View -import android.view.ViewGroup -import androidx.cardview.widget.CardView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isChecked -import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isNotChecked -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withParent -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import com.google.common.truth.Truth.assertThat -import com.itsaky.androidide.R -import com.itsaky.androidide.activities.MainActivity -import com.itsaky.androidide.models.MainScreenAction -import com.itsaky.androidide.templates.BooleanParameter -import com.itsaky.androidide.templates.EnumParameter -import com.itsaky.androidide.templates.ITemplateProvider -import com.itsaky.androidide.templates.StringParameter -import org.hamcrest.Description -import org.hamcrest.Matcher -import org.hamcrest.Matchers.allOf -import org.hamcrest.TypeSafeMatcher -import org.hamcrest.core.IsInstanceOf.instanceOf -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@LargeTest -@RunWith(AndroidJUnit4::class) -class MainFragmentTest { - - @Rule - @JvmField - var mActivityScenarioRule = ActivityScenarioRule(MainActivity::class.java) - - @Test - fun testMainFragmentActionsAreShown() { - val recyclerView = onView(allOf( - withId(R.id.actions), - childAtPosition( - withId(R.id.coordinator), - 3))) - - recyclerView.check(matches(isDisplayed())) - - for (action in MainScreenAction.all()) { - val button = onView( - allOf(withText(action.text), - withParent(allOf(withId(R.id.actions), - withParent(withId(R.id.coordinator)))), - isDisplayed())) - button.check(matches(isDisplayed())) - } - } - - @Test - fun testTemplateWizardUi() { - - onView(withText(R.string.get_started)).check(matches(isCompletelyDisplayed())) - - val recyclerView = onView(allOf( - withId(R.id.actions), - childAtPosition( - withId(R.id.coordinator), - 3))) - - recyclerView.check(matches(isDisplayed())) - - val action = MainScreenAction.all().find { it.id == MainScreenAction.ACTION_CREATE_PROJECT } - assertThat(action).isNotNull() - - action!! - - val createProject = onView(allOf(withText(action.text), isDisplayed())) - - createProject.check(matches(isCompletelyDisplayed())) - createProject.perform(click()) - - val exitButton = onView( - allOf(withId(R.id.exit_button), isCompletelyDisplayed()) - ) - - exitButton.check(matches(isCompletelyDisplayed())) - - val templates = ITemplateProvider.getInstance().getTemplates() - for (template in templates) { - val templateTitle = onView( - allOf(withId(R.id.template_name), withText(template.templateName), - withParent( - withParent(instanceOf(CardView::class.java))), - isCompletelyDisplayed())) - - templateTitle.check(matches(isCompletelyDisplayed())) - } - - val template = templates.first() - val firstTemplateItem = onView( - allOf(withId(R.id.template_name), withText(template.templateName), - withParent( - withParent(instanceOf(CardView::class.java))), - isCompletelyDisplayed())) - - firstTemplateItem.perform(click()) - - val previousButton = onView( - allOf(withId(R.id.previous), withText(R.string.previous), isCompletelyDisplayed())) - - val createProjectFinishButton = onView( - allOf(withId(R.id.finish), withText(R.string.create_project), - isCompletelyDisplayed())) - - previousButton.check(matches(isCompletelyDisplayed())) - createProjectFinishButton.check(matches(isCompletelyDisplayed())) - - for (parameter in template.parameters) { - val selector = when (parameter) { - is StringParameter -> withText(parameter.value) - is BooleanParameter -> allOf(withText(parameter.name), - if (parameter.value) isChecked() else isNotChecked()) - - is EnumParameter -> withText(parameter.getDisplayName()) - else -> throw IllegalStateException("Unknown parameter type: $parameter") - } - val widget = onView(selector) - widget.check(matches(isCompletelyDisplayed())) - } - - previousButton.perform(click()) - - Thread.sleep(1000L) - - exitButton.check(matches(isCompletelyDisplayed())) - exitButton.perform(click()) - - Thread.sleep(1000L) - - onView(withText(R.string.get_started)).check(matches(isCompletelyDisplayed())) - } - - private fun childAtPosition( - parentMatcher: Matcher, position: Int): Matcher { - - return object : TypeSafeMatcher() { - override fun describeTo(description: Description) { - description.appendText("Child at position $position in parent ") - parentMatcher.describeTo(description) - } - - public override fun matchesSafely(view: View): Boolean { - val parent = view.parent - return parent is ViewGroup && parentMatcher.matches(parent) - && view == parent.getChildAt(position) - } - } - } - -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b92f4089d..08f34528d 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,7 @@ tools:ignore="ScopedStorage" /> + diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index d60b0cae7..dd0dc5642 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -42,12 +42,6 @@ import com.termux.shared.termux.TermuxConstants class OnboardingActivity : AppIntro2() { - private val onBackPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - finishAffinity() - } - } - companion object { private const val KEY_ARCHCONFIG_WARNING_IS_SHOWN = "ide.archConfig.experimentalWarning.isShown" @@ -63,8 +57,6 @@ class OnboardingActivity : AppIntro2() { finish() } - onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - setSwipeLock(true) setTransformer(AppIntroPageTransformerType.Fade) setProgressIndicator() diff --git a/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java b/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java index a816f41d7..c1db815b0 100755 --- a/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java +++ b/common/src/main/java/com/itsaky/androidide/managers/PreferenceManager.java @@ -27,6 +27,7 @@ import org.greenrobot.eventbus.EventBus; public class PreferenceManager { private final SharedPreferences prefs; + private boolean isReadOnly = false; public PreferenceManager(Context ctx) { this(ctx, null); @@ -46,7 +47,9 @@ public class PreferenceManager { } public PreferenceManager remove(String key) { - prefs.edit().remove(key).apply(); + final var edit = prefs.edit(); + edit.remove(key); + applyChanges(edit); dispatchRemoveEvent(key); return this; } @@ -55,8 +58,27 @@ public class PreferenceManager { EventBus.getDefault().post(new PreferenceRemoveEvent(key)); } + /** + * @return Whether this preference manager is read-only. + */ + public boolean isReadOnly() { + return isReadOnly; + } + + /** + * Whether this preference manager is read-only. + * + * @param readOnly true, if the preference manager is read-only, false + * otherwise. + */ + public void setReadOnly(boolean readOnly) { + isReadOnly = readOnly; + } + public PreferenceManager putInt(String key, int val) { - prefs.edit().putInt(key, val).apply(); + final var edit = prefs.edit(); + edit.putInt(key, val); + applyChanges(edit); dispatchChangeEvent(key, val); return this; } @@ -70,7 +92,9 @@ public class PreferenceManager { } public void putFloat(String key, float val) { - prefs.edit().putFloat(key, val).apply(); + final var edit = prefs.edit(); + edit.putFloat(key, val); + applyChanges(edit); dispatchChangeEvent(key, val); } @@ -91,7 +115,9 @@ public class PreferenceManager { } public PreferenceManager putString(String key, String value) { - prefs.edit().putString(key, value).apply(); + final var edit = prefs.edit(); + edit.putString(key, value); + applyChanges(edit); dispatchChangeEvent(key, value); return this; } @@ -105,7 +131,9 @@ public class PreferenceManager { } public void putBoolean(String key, boolean value) { - prefs.edit().putBoolean(key, value).apply(); + final var edit = prefs.edit(); + edit.putBoolean(key, value); + applyChanges(edit); dispatchChangeEvent(key, value); } @@ -114,7 +142,9 @@ public class PreferenceManager { } public void putLong(String key, long value) { - prefs.edit().putLong(key, value).apply(); + final var edit = prefs.edit(); + edit.putLong(key, value); + applyChanges(edit); dispatchChangeEvent(key, value); } @@ -122,4 +152,7 @@ public class PreferenceManager { return prefs.getInt(key, def); } + protected void applyChanges(SharedPreferences.Editor editor) { + editor.apply(); + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8171c618..ffd8142eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -132,6 +132,7 @@ tests-androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-co tests-androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version = "2.2.0" } tests-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.2.1" } tests-mockk = { module = "io.mockk:mockk", version = "1.13.8" } +tests-barista = { module = "com.adevinta.android:barista", version = "4.2.0" } # Tooling tooling-builderModel = { module = "com.android.tools.build:builder-model", version.ref = "agp-tooling" } diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt index 49f73e276..f50bab91e 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/statPreferences.kt @@ -28,7 +28,7 @@ var statConsentDialogShown: Boolean } var statOptIn: Boolean - get() = prefManager.getBoolean(STAT_OPT_IN, false) + get() = prefManager.getBoolean(STAT_OPT_IN, true) set(value) { prefManager.putBoolean(STAT_OPT_IN, value) } diff --git a/testing/android/build.gradle.kts b/testing/android/build.gradle.kts index 2d5045115..a027fa485 100644 --- a/testing/android/build.gradle.kts +++ b/testing/android/build.gradle.kts @@ -35,6 +35,9 @@ dependencies { api(libs.tests.androidx.uiautomator) api(libs.tests.junit) api(libs.tests.google.truth) + api(libs.tests.barista) { + exclude("org.jetbrains.kotlin") + } api(projects.buildInfo) api(projects.common) diff --git a/testing/android/src/main/java/com/itsaky/androidide/testing/android/contextUtils.kt b/testing/android/src/main/java/com/itsaky/androidide/testing/android/contextUtils.kt index d2d69a256..33f56c878 100644 --- a/testing/android/src/main/java/com/itsaky/androidide/testing/android/contextUtils.kt +++ b/testing/android/src/main/java/com/itsaky/androidide/testing/android/contextUtils.kt @@ -20,6 +20,18 @@ package com.itsaky.androidide.testing.android import android.content.Context import androidx.annotation.StringRes import androidx.test.core.app.ApplicationProvider +import com.itsaky.androidide.app.BaseApplication + +/** + * Get the application context. + */ +fun getApplicationContext(): AppContext = + ApplicationProvider.getApplicationContext() + +/** + * Get the [BaseApplication] instance. + */ +fun getBaseApplication() = getApplicationContext() /** * Get the value of the string resource. diff --git a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt index c9031b03c..30b3620fc 100644 --- a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt +++ b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt @@ -18,11 +18,11 @@ package com.itsaky.androidide.testing.android import android.view.KeyCharacterMap -import android.view.KeyEvent import androidx.annotation.StringRes import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 +import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.buildinfo.BuildInfo private val keyCharMap by lazy { @@ -45,7 +45,7 @@ fun UiDevice.hasObjectWithText(text: String) = * @see UiDevice.hasObject */ fun UiDevice.hasObjectWithText(@StringRes text: Int) = - hasObject(By.text(stringRes(text))) + hasObjectWithText(stringRes(text)) /** * @see UiDevice.findObject @@ -57,7 +57,16 @@ fun UiDevice.findObjectWithText(text: String): UiObject2? = * @see UiDevice.findObjectWithText */ fun UiDevice.findObjectWithText(@StringRes text: Int): UiObject2? = - findObject(By.text(stringRes(text))) + findObjectWithText(stringRes(text)) + + +fun UiDevice.getObjectWithText(text: String): UiObject2 { + val obj = findObjectWithText(text) + assertThat(obj).isNotNull() + return obj!! +} + +fun UiDevice.getObjectWithText(@StringRes text: Int): UiObject2 = getObjectWithText(stringRes(text)) /** * Sends key events to this [UiDevice] to type the given [text]. @@ -67,7 +76,6 @@ fun UiDevice.sendKeyEvents(text: String) { val keyEvents = keyCharMap.getEvents(text.toCharArray()) for (keyEvent in keyEvents) { uiAutomation.injectInputEvent(keyEvent, true) -// pressKeyCode(keyEvent.keyCode, keyEvent.metaState) waitForIdle() } } \ No newline at end of file diff --git a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiObjectUtils.kt b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiObjectUtils.kt index 03a725c40..9bb9bb914 100644 --- a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiObjectUtils.kt +++ b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiObjectUtils.kt @@ -17,6 +17,8 @@ package com.itsaky.androidide.testing.android +import androidx.annotation.StringRes +import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.Until @@ -43,4 +45,37 @@ fun UiObject2?.clickAndWaitForNewWindow(timeout: Long = LAUNCH_TIMEOUT) = run { assertThat(this).isNotNull() this!!.clickAndWait(Until.newWindow(), timeout) - } \ No newline at end of file + } + +/** + * @see UiDevice.hasObject + */ +fun UiObject2.hasObjectWithText(text: String) = + hasObject(By.text(text)) + +/** + * @see UiObject2.hasObject + */ +fun UiObject2.hasObjectWithText(@StringRes text: Int) = + hasObjectWithText(stringRes(text)) + +/** + * @see UiObject2.findObject + */ +fun UiObject2.findObjectWithText(text: String): UiObject2? = + findObject(By.text(text)) + +/** + * @see UiObject2.findObjectWithText + */ +fun UiObject2.findObjectWithText(@StringRes text: Int): UiObject2? = + findObjectWithText(stringRes(text)) + + +fun UiObject2.getObjectWithText(text: String): UiObject2 { + val obj = findObjectWithText(text) + assertThat(obj).isNotNull() + return obj!! +} + +fun UiObject2.getObjectWithText(@StringRes text: Int): UiObject2 = getObjectWithText(stringRes(text)) \ No newline at end of file