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