diff --git a/.circleci/config.yml b/.circleci/config.yml index b84cfb92..2bb8aa1c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: codecov: codecov/codecov@5.4.3 - ruby: circleci/ruby@2.0.0 + ruby: circleci/ruby@2.5.3 commands: restore_gradle_cache: @@ -10,10 +10,11 @@ commands: key: v1-gradle-wrapper-{{ arch }}-{{ checksum "gradle/wrapper/gradle-wrapper.properties" }} - restore_cache: key: v1-gradle-cache-{{ arch }}-{{ checksum "build.gradle.kts" }}-{{ checksum "settings.gradle.kts" }}-{{ checksum "gradle.properties" }}-{{ checksum "app/build.gradle.kts" }}-{{ checksum "gradle/libs.versions.toml" }} + restore_bundler_cache: steps: - restore_cache: - key: v1-bundler-cache-{{ checksum "Gemfile.lock" }} + key: v1-bundler-cache-{{ arch }}-{{ checksum "Gemfile.lock" }} save_gradle_cache: steps: @@ -25,6 +26,7 @@ commands: paths: - ~/.gradle/caches key: v1-gradle-cache-{{ arch }}-{{ checksum "build.gradle.kts" }}-{{ checksum "settings.gradle.kts" }}-{{ checksum "gradle.properties" }}-{{ checksum "app/build.gradle.kts" }}-{{ checksum "gradle/libs.versions.toml" }} + save_bundler_cache: steps: - save_cache: @@ -32,34 +34,38 @@ commands: - vendor/bundle key: v1-bundler-cache-{{ checksum "Gemfile.lock" }} + setup_android: + steps: + - checkout + - restore_gradle_cache + - restore_bundler_cache + executors: android-machine: machine: - image: android:2024.01.1 + image: android:2024.11.1 android-docker: docker: - - image: cimg/android:2024.01 + - image: cimg/android:2025.04 jobs: android-test: executor: android-machine resource_class: large steps: - - checkout - - restore_gradle_cache - - restore_bundler_cache + - setup_android - ruby/install-deps: with-cache: true - run: name: Fastlane - run all tests with coverage report command: | bundle exec fastlane testDev - bundle exec fastlane lint + - run: ./gradlew lintDebug - save_gradle_cache - save_bundler_cache - run: name: Analyze on SonarCloud - command: ./gradlew lintDebug sonar + command: ./gradlew sonar - codecov/upload: files: app/build/mergedReportDir/jacocoTestReport/jacocoTestReport.xml - store_test_results: @@ -71,14 +77,15 @@ jobs: executor: android-docker resource_class: large steps: - - checkout - - restore_gradle_cache + - setup_android - run: name: Prepare Fastlane command: sudo bundle update - run: name: Distribute to Firebase AppTester Dev command: bundle exec fastlane distDev + - save_gradle_cache + - save_bundler_cache - store_artifacts: path: /home/circleci/project/app/build/outputs/apk/debug/app-debug.apk destination: fastlane-output-debug @@ -87,8 +94,7 @@ jobs: executor: android-docker resource_class: large steps: - - checkout - - restore_gradle_cache + - setup_android - run: name: Prepare Fastlane command: sudo bundle update @@ -98,6 +104,8 @@ jobs: - run: name: Distribute to Firebase AppTester Prod command: bundle exec fastlane distProd + - save_gradle_cache + - save_bundler_cache - store_artifacts: path: /home/circleci/project/app/build/outputs/bundle/release/app-release.aab destination: fastlane-output-release @@ -108,4 +116,7 @@ workflows: - android-test: context: SonarCloud - distribute-dev - - distribute-internal-testing + - distribute-internal-testing: + requires: + - android-test + diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml deleted file mode 100644 index 87ee7a8a..00000000 --- a/.idea/androidTestResultsUserPreferences.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 88ea3aa1..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 4ec9b032..9b2175e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,8 +10,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1125.0) - aws-sdk-core (3.226.2) + aws-partitions (1.1131.0) + aws-sdk-core (3.226.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -21,7 +21,7 @@ GEM aws-sdk-kms (1.106.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.192.0) + aws-sdk-s3 (1.193.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -164,13 +164,13 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.12.2) + json (2.13.0) jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 306f01ea..64997382 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ import java.io.FileInputStream -import java.util.* +import java.util.Base64 +import java.util.Properties plugins { id("com.android.application") @@ -36,8 +37,8 @@ android { applicationId = "org.kabiri.android.usbterminal" minSdk = 24 targetSdk = 35 - versionCode = System.getenv("CIRCLE_BUILD_NUM")?.toIntOrNull() ?: 15 - versionName = "0.9.85${System.getenv("CIRCLE_BUILD_NUM") ?: ""}" + versionCode = System.getenv("CIRCLE_BUILD_NUM")?.toIntOrNull() ?: 16 + versionName = "0.9.86${System.getenv("CIRCLE_BUILD_NUM") ?: ""}" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -110,6 +111,35 @@ tasks.register("jacocoTestReport") { csv.required.set(false) } + val fileFilter = listOf( + "**/R.class", + "**/MainActivity.*", + "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", + "**/*Test*.*", "android/**/*.*", + "**/Dagger*.*", "**/*_Hilt*.*", "**/*Hilt*.*", + ) + val kotlinDebugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(fileFilter) } + val mainKotlinSrc = layout.projectDirectory.dir("src/main/kotlin") + sourceDirectories.from(files(mainKotlinSrc)) + classDirectories.from(files(kotlinDebugTree)) + executionData.from(fileTree(layout.buildDirectory) { + include( + "outputs/managed_device_code_coverage/**/*.ec", + "outputs/unit_test_code_coverage/**/*.exec", + ) + }) +} + +tasks.register("jacocoUiOnly") { + + dependsOn("pixel2api30DebugAndroidTest") + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } + val fileFilter = listOf( "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", @@ -123,7 +153,6 @@ tasks.register("jacocoTestReport") { classDirectories.from(files(javaDebugTree, kotlinDebugTree)) executionData.from(fileTree(layout.buildDirectory) { include( - "outputs/unit_test_code_coverage/**/*.exec", "outputs/managed_device_code_coverage/**/*.ec", "outputs/managed_device_code_coverage/**/*.exec" ) @@ -224,7 +253,6 @@ dependencies { // Firebase implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics.ktx) implementation(libs.firebase.crashlytics.ktx) // Dependency Injection @@ -263,6 +291,8 @@ dependencies { // Instrumented Test Libraries androidTestImplementation(composeBom) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.coroutines.test) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/MainActivityAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/MainActivityAndroidTest.kt index bda96883..1100bacf 100644 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/MainActivityAndroidTest.kt +++ b/app/src/androidTest/java/org/kabiri/android/usbterminal/MainActivityAndroidTest.kt @@ -15,17 +15,15 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith - @RunWith(AndroidJUnit4::class) internal class MainActivityAndroidTest { - @get:Rule var rule = activityScenarioRule() private fun ensureMenuIsAccessible( menuItemId: Int, onVisible: () -> Unit, - onOverflow: () -> Unit + onOverflow: () -> Unit, ) { try { // Try to find the menu item first @@ -49,49 +47,46 @@ internal class MainActivityAndroidTest { } @Test - fun checkActionMenuItemSettingsIsDisplayed() = ensureMenuIsAccessible( - menuItemId = R.id.actionSettings, - onVisible = { - - // assert - onView(withId(R.id.actionSettings)).check(matches(isDisplayed())) - }, - onOverflow = { - - // assert - onView(withText(R.string.title_settings)).check(matches(isDisplayed())) - } - ) + fun checkActionMenuItemSettingsIsDisplayed() = + ensureMenuIsAccessible( + menuItemId = R.id.actionSettings, + onVisible = { + // assert + onView(withId(R.id.actionSettings)).check(matches(isDisplayed())) + }, + onOverflow = { + // assert + onView(withText(R.string.title_settings)).check(matches(isDisplayed())) + }, + ) @Test - fun checkActionMenuItemConnectIsDisplayed() = ensureMenuIsAccessible( - menuItemId = R.id.actionSettings, - onVisible = { - - // assert - onView(withId(R.id.actionConnect)).check(matches(isDisplayed())) - }, - onOverflow = { - - // assert - onView(withText(R.string.title_connect)).check(matches(isDisplayed())) - } - ) + fun checkActionMenuItemConnectIsDisplayed() = + ensureMenuIsAccessible( + menuItemId = R.id.actionSettings, + onVisible = { + // assert + onView(withId(R.id.actionConnect)).check(matches(isDisplayed())) + }, + onOverflow = { + // assert + onView(withText(R.string.title_connect)).check(matches(isDisplayed())) + }, + ) @Test - fun checkActionMenuItemDisconnectIsDisplayed() = ensureMenuIsAccessible( - menuItemId = R.id.actionSettings, - onVisible = { - - // assert - onView(withId(R.id.actionDisconnect)).check(matches(isDisplayed())) - }, - onOverflow = { - - // assert - onView(withText(R.string.title_disconnect)).check(matches(isDisplayed())) - } - ) + fun checkActionMenuItemDisconnectIsDisplayed() = + ensureMenuIsAccessible( + menuItemId = R.id.actionSettings, + onVisible = { + // assert + onView(withId(R.id.actionDisconnect)).check(matches(isDisplayed())) + }, + onOverflow = { + // assert + onView(withText(R.string.title_disconnect)).check(matches(isDisplayed())) + }, + ) @Test fun clickingSettingsOpensSettingsBottomSheet() { @@ -99,7 +94,6 @@ internal class MainActivityAndroidTest { ensureMenuIsAccessible( menuItemId = R.id.actionSettings, onVisible = { - // act onView(withId(R.id.actionSettings)).perform(click()) @@ -112,7 +106,7 @@ internal class MainActivityAndroidTest { // assert onView(withId(R.id.composeViewSettingContent)).check(matches(isDisplayed())) - } + }, ) } } diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt deleted file mode 100644 index 141f6b8e..00000000 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.kabiri.android.usbterminal.common - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository -import org.kabiri.android.usbterminal.model.UserSettingPreferences - -internal fun getFakeUserSettingRepository( - onSetBaudRate: () -> Unit = {}, - fakeUserSetting: UserSettingPreferences = UserSettingPreferences(baudRate = 123) -): IUserSettingRepository { - return object: IUserSettingRepository { - override val preferenceFlow: Flow - get() = flowOf(fakeUserSetting) - - override suspend fun setBaudRate(baudRate: Int) { - onSetBaudRate() - } - - override suspend fun clear() {} - - override suspend fun fetchInitialPreferences(): UserSettingPreferences { - return fakeUserSetting - } - - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryAndroidTest.kt deleted file mode 100644 index 9057fbe1..00000000 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryAndroidTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.kabiri.android.usbterminal.data.repository - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.kabiri.android.usbterminal.model.UserSettingPreferences - -private const val TEST_DATA_STORE_NAME = "test_data_store" -@RunWith(AndroidJUnit4::class) -internal class UserSettingRepositoryAndroidTest { - - private val testCoroutineDispatcher: TestDispatcher = StandardTestDispatcher() - private val testCoroutineScope = TestScope(testCoroutineDispatcher + Job()) - private val testContext = ApplicationProvider.getApplicationContext() - - private val testDataStore: DataStore = - PreferenceDataStoreFactory.create( - scope = testCoroutineScope, - produceFile = { testContext.preferencesDataStoreFile(TEST_DATA_STORE_NAME) } - ) - - private val repository: UserSettingRepository = UserSettingRepository(testDataStore) - - @Test - fun testFetchInitialPreferences() { - - // arrange - val expected = UserSettingPreferences() - var actual: UserSettingPreferences? = null - - // act - testCoroutineScope.runTest { - actual = repository.fetchInitialPreferences() - } - - // assert - assertThat(actual).isEqualTo(expected) - } - - @Test - fun testWriteBaudRate() { - - // arrange - val customBaudRate = 123 - val expected = UserSettingPreferences( - baudRate = customBaudRate - ) - var actual: UserSettingPreferences? = null - - // act - testCoroutineScope.runTest { - repository.setBaudRate(customBaudRate) - actual = repository.preferenceFlow.first() - } - - // assert - assertThat(actual).isEqualTo(expected) - } - - @Test - fun testClearResetsValuesToDefaults() { - - // arrange - val customBaudRate = 123 - val expected = UserSettingPreferences() - var actual: UserSettingPreferences? = null - - // act - testCoroutineScope.runTest { - repository.setBaudRate(customBaudRate) - repository.clear() - actual = repository.preferenceFlow.first() - } - - // assert - assertThat(actual).isEqualTo(expected) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseAndroidTest.kt deleted file mode 100644 index 82a33ac1..00000000 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseAndroidTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.kabiri.android.usbterminal.domain - -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.kabiri.android.usbterminal.common.getFakeUserSettingRepository -import org.kabiri.android.usbterminal.model.UserSettingPreferences - -internal class GetCustomBaudRateUseCaseAndroidTest { - - @Test - fun testGetCustomBaudRateUseCaseAndroidTestReturns() = runTest { - - // arrange - val expectedBaudRate = 1234 - val fakeUserSettings = UserSettingPreferences(baudRate = expectedBaudRate) - val fakeUserSettingRepository = - getFakeUserSettingRepository(fakeUserSetting = fakeUserSettings) - val sut = GetCustomBaudRateUseCase( - userSettingRepository = fakeUserSettingRepository - ) - - // act - val actualBaudRate = sut().first() - - // assert - assertThat(actualBaudRate).isEqualTo(expectedBaudRate) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseAndroidTest.kt deleted file mode 100644 index 6bfb8143..00000000 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseAndroidTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.kabiri.android.usbterminal.domain - -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.kabiri.android.usbterminal.common.getFakeUserSettingRepository - -@OptIn(ExperimentalCoroutinesApi::class) -internal class SetCustomBaudRateUseCaseAndroidTest { - - @Test - fun testSetCustomBaudRateUseCaseAndroidTestCallsSetBaudRateOnRepository() = runTest { - - // arrange - var isCalledSetBaudRate = false - val fakeUserSettingRepository = getFakeUserSettingRepository(onSetBaudRate = { - isCalledSetBaudRate = true - }) - val sut = SetCustomBaudRateUseCase( - userSettingRepository = fakeUserSettingRepository, - scope = this, - ) - - // act - sut.invoke(123) - advanceUntilIdle() - - // assert - assertThat(isCalledSetBaudRate).isTrue() - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingContentAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingContentAndroidTest.kt new file mode 100644 index 00000000..4bda8bc9 --- /dev/null +++ b/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingContentAndroidTest.kt @@ -0,0 +1,76 @@ +package org.kabiri.android.usbterminal.ui.setting + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.hamcrest.CoreMatchers.notNullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.kabiri.android.usbterminal.R +import org.kabiri.android.usbterminal.ui.theme.UsbTerminalTheme + +@RunWith(AndroidJUnit4::class) +class SettingContentAndroidTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun showContet(viewModel: SettingViewModel) { + composeRule.setContent { + UsbTerminalTheme { + SettingContent( + settingViewModel = viewModel, + onDismiss = {}, + ) + } + } + } + + @Test + fun settingContent_displaysExpectedTexts() { + // arrange + val context = composeRule.activity + assertThat(context, notNullValue()) + val viewModel = mockk(relaxed = true) + every { viewModel.currentBaudRate } returns flowOf(9600) + every { viewModel.currentAutoScroll } returns flowOf(true) + + // act + showContet(viewModel) + + // assert + composeRule.onNodeWithText(context.getString(R.string.settings_title)).assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.settings_subtitle)) + .assertIsDisplayed() + + // Baud rate label (ensure unmerged tree so label is found) + composeRule + .onNodeWithText( + context.getString(R.string.settings_label_baud_rate), + useUnmergedTree = true, + ).assertIsDisplayed() + + // Buttons and bottom text + composeRule + .onNodeWithText(context.getString(R.string.settings_bt_reset_default)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.settings_bt_dismiss_sheet)) + .assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.settings_bottom_text)) + .assertIsDisplayed() + + // Auto-scroll label + composeRule + .onNodeWithText(context.getString(R.string.settings_label_auto_scroll)) + .assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItemAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItemAndroidTest.kt new file mode 100644 index 00000000..2a95638b --- /dev/null +++ b/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItemAndroidTest.kt @@ -0,0 +1,85 @@ +package org.kabiri.android.usbterminal.ui.setting + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.hasAnySibling +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.kabiri.android.usbterminal.R +import org.kabiri.android.usbterminal.ui.theme.UsbTerminalTheme + +@RunWith(AndroidJUnit4::class) +class SettingSwitchItemAndroidTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun showContet(enabled: Boolean = true) { + composeRule.setContent { + UsbTerminalTheme { + SettingSwitchItem( + enabled = enabled, + onToggle = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + @Test + fun autoScrollSwitch_displaysLabel() { + // arrange + val context = composeRule.activity + + // act + showContet() + + // assert + composeRule + .onNodeWithText(context.getString(R.string.settings_label_auto_scroll)) + .assertIsDisplayed() + } + + @Test + fun autoScrollSwitch_isOn_whenEnabledTrue() { + // arrange + val context = composeRule.activity + + // act + showContet(enabled = true) + + // assert + composeRule + .onNode( + hasAnySibling( + hasText(context.getString(R.string.settings_label_auto_scroll)), + ).and(isToggleable()), + ).assertIsOn() + } + + @Test + fun autoScrollSwitch_isOff_whenEnabledFalse() { + // arrange + val context = composeRule.activity + + // act + showContet(enabled = false) + + // assert + composeRule + .onNode( + hasAnySibling( + hasText(context.getString(R.string.settings_label_auto_scroll)), + ).and(isToggleable()), + ).assertIsOff() + } +} diff --git a/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt b/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt index a0d5902f..c722126a 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt @@ -19,23 +19,21 @@ import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import org.kabiri.android.usbterminal.util.scrollToLastLine import org.kabiri.android.usbterminal.ui.setting.SettingModalBottomSheet import org.kabiri.android.usbterminal.ui.setting.SettingViewModel +import org.kabiri.android.usbterminal.util.scrollToLastLine import org.kabiri.android.usbterminal.viewmodel.MainActivityViewModel +private const val TAG = "MainActivity" + @AndroidEntryPoint class MainActivity : AppCompatActivity() { - - companion object { - private const val TAG = "MainActivity" - } - private val viewModel by viewModels() private val settingViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + viewModel.startObservingUsbDevice() setContentView(R.layout.activity_main) // avoid system navbar or soft keyboard overlapping the content. @@ -54,12 +52,19 @@ class MainActivity : AppCompatActivity() { // make the text view scrollable: tvOutput.movementMethod = ScrollingMovementMethod() + var autoScrollEnabled = true + lifecycleScope.launch { + settingViewModel.currentAutoScroll.collect { enabled -> + autoScrollEnabled = enabled + } + } + lifecycleScope.launch { viewModel.getLiveOutput() viewModel.output.collect { tvOutput.apply { text = it - scrollToLastLine() + if (autoScrollEnabled) scrollToLastLine() } } } @@ -67,9 +72,11 @@ class MainActivity : AppCompatActivity() { fun sendCommand() { val input = etInput.text.toString() // append the input to console - if (viewModel.serialWrite(input)) - etInput.setText("") // clear the terminal input. - else Log.e(TAG, "The message was not sent to Arduino") + if (viewModel.serialWrite(input)) { + etInput.setText("") + } else { + Log.e(TAG, "The message was not sent to Arduino") + } } // send the command to device when the button is clicked. @@ -83,7 +90,9 @@ class MainActivity : AppCompatActivity() { event.action == KeyEvent.ACTION_DOWN)) { sendCommand() true - } else false + } else { + false + } } } diff --git a/app/src/main/java/org/kabiri/android/usbterminal/data/repository/ArduinoRepository.kt b/app/src/main/java/org/kabiri/android/usbterminal/data/repository/ArduinoRepository.kt index 70ddf555..d05ec02e 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/data/repository/ArduinoRepository.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/data/repository/ArduinoRepository.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.kabiri.android.usbterminal.R import org.kabiri.android.usbterminal.domain.IGetCustomBaudRateUseCase -import org.kabiri.android.usbterminal.model.defaultBaudRate +import org.kabiri.android.usbterminal.model.DEFAULT_BAUD_RATE import javax.inject.Inject /** @@ -44,7 +44,7 @@ internal class ArduinoRepository private val getBaudRate: IGetCustomBaudRateUseCase, ): IArduinoRepository { - private var currentBaudRate = defaultBaudRate // Default value + private var currentBaudRate = DEFAULT_BAUD_RATE // Default value private val _messageFlow = MutableStateFlow("") override val messageFlow: Flow diff --git a/app/src/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt b/app/src/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt index 680e0bbf..57fd6cb4 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt @@ -3,6 +3,7 @@ package org.kabiri.android.usbterminal.data.repository import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey @@ -11,7 +12,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import org.kabiri.android.usbterminal.model.UserSettingPreferences -import org.kabiri.android.usbterminal.model.defaultBaudRate +import org.kabiri.android.usbterminal.model.DEFAULT_BAUD_RATE import java.io.IOException import javax.inject.Inject @@ -20,52 +21,67 @@ internal const val USER_SETTING_PREFERENCES_NAME = "user_setting_repository" private object PreferencesKeys { val BAUD_RATE = stringPreferencesKey("baud_rate") + val AUTO_SCROLL = booleanPreferencesKey("auto_scroll") } internal interface IUserSettingRepository { val preferenceFlow: Flow + suspend fun setBaudRate(baudRate: Int) + + suspend fun setAutoScroll(enabled: Boolean) + suspend fun clear() + suspend fun fetchInitialPreferences(): UserSettingPreferences } internal class UserSettingRepository -@Inject constructor( - private val dataStore: DataStore -): IUserSettingRepository { - override val preferenceFlow: Flow = dataStore.data - .catch { exception -> - // dataStore.data throws an IOException when an error is encountered when reading data - if (exception is IOException) { - Log.e(TAG, "Error reading preferences.", exception) - emit(emptyPreferences()) - } else { - throw exception + @Inject + constructor( + private val dataStore: DataStore, + ) : IUserSettingRepository { + override val preferenceFlow: Flow = + dataStore.data + .catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + Log.e(TAG, "Error reading preferences.", exception) + emit(emptyPreferences()) + } else { + throw exception + } + }.map { preferences -> + mapPreferences(preferences) + } + + override suspend fun setBaudRate(baudRate: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.BAUD_RATE] = baudRate.toString() + } + } + + override suspend fun setAutoScroll(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.AUTO_SCROLL] = enabled } - }.map { preferences -> - mapPreferences(preferences) } - override suspend fun setBaudRate(baudRate: Int) { - dataStore.edit { preferences -> - preferences[PreferencesKeys.BAUD_RATE] = baudRate.toString() + override suspend fun clear() { + dataStore.edit { preferences -> + preferences[PreferencesKeys.BAUD_RATE] = DEFAULT_BAUD_RATE.toString() + preferences[PreferencesKeys.AUTO_SCROLL] = true + } } - } - override suspend fun clear() { - dataStore.edit { preferences -> - preferences[PreferencesKeys.BAUD_RATE] = defaultBaudRate.toString() + private fun mapPreferences(preferences: Preferences): UserSettingPreferences { + // Get the user settings from preferences + // and convert it to a [UserSettingPreferences] object + return UserSettingPreferences( + baudRate = preferences[PreferencesKeys.BAUD_RATE]?.toIntOrNull() ?: DEFAULT_BAUD_RATE, + autoScroll = preferences[PreferencesKeys.AUTO_SCROLL] ?: true, + ) } - } - private fun mapPreferences(preferences: Preferences): UserSettingPreferences { - // Get the user settings from preferences - // and convert it to a [UserSettingPreferences] object - return UserSettingPreferences( - baudRate = preferences[PreferencesKeys.BAUD_RATE]?.toIntOrNull() ?: defaultBaudRate - ) + override suspend fun fetchInitialPreferences(): UserSettingPreferences = mapPreferences(dataStore.data.first().toPreferences()) } - - override suspend fun fetchInitialPreferences(): UserSettingPreferences = - mapPreferences(dataStore.data.first().toPreferences()) -} \ No newline at end of file diff --git a/app/src/main/java/org/kabiri/android/usbterminal/di/AppModule.kt b/app/src/main/java/org/kabiri/android/usbterminal/di/AppModule.kt index 766af909..4833d1c6 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/di/AppModule.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/di/AppModule.kt @@ -23,11 +23,15 @@ import org.kabiri.android.usbterminal.data.repository.USER_SETTING_PREFERENCES_N import org.kabiri.android.usbterminal.data.repository.UsbRepository import org.kabiri.android.usbterminal.data.repository.UserSettingRepository import org.kabiri.android.usbterminal.domain.ArduinoUseCase +import org.kabiri.android.usbterminal.domain.GetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.GetCustomBaudRateUseCase import org.kabiri.android.usbterminal.domain.IArduinoUseCase +import org.kabiri.android.usbterminal.domain.IGetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.IGetCustomBaudRateUseCase +import org.kabiri.android.usbterminal.domain.ISetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.ISetCustomBaudRateUseCase import org.kabiri.android.usbterminal.domain.IUsbUseCase +import org.kabiri.android.usbterminal.domain.SetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.SetCustomBaudRateUseCase import org.kabiri.android.usbterminal.domain.UsbUseCase import org.kabiri.android.usbterminal.util.IResourceProvider @@ -131,4 +135,18 @@ internal class AppModule { ): ISetCustomBaudRateUseCase { return SetCustomBaudRateUseCase(userSettingRepository = userSettingRepository) } -} \ No newline at end of file + + @Provides + fun provideGetAutoScrollUseCase( + userSettingRepository: IUserSettingRepository, + ): IGetAutoScrollUseCase { + return GetAutoScrollUseCase(userSettingRepository = userSettingRepository) + } + + @Provides + fun provideSetAutoScrollUseCase( + userSettingRepository: IUserSettingRepository, + ): ISetAutoScrollUseCase { + return SetAutoScrollUseCase(userSettingRepository = userSettingRepository) + } +} diff --git a/app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt b/app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt new file mode 100644 index 00000000..e8a3366e --- /dev/null +++ b/app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt @@ -0,0 +1,21 @@ +package org.kabiri.android.usbterminal.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository +import javax.inject.Inject + +internal fun interface IGetAutoScrollUseCase { + operator fun invoke(): Flow +} + +internal class GetAutoScrollUseCase + @Inject + constructor( + private val userSettingRepository: IUserSettingRepository, + ) : IGetAutoScrollUseCase { + override fun invoke(): Flow { + val userSettingFlow = userSettingRepository.preferenceFlow + return userSettingFlow.map { it.autoScroll } + } + } diff --git a/app/src/main/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCase.kt b/app/src/main/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCase.kt new file mode 100644 index 00000000..cab8a82b --- /dev/null +++ b/app/src/main/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCase.kt @@ -0,0 +1,24 @@ +package org.kabiri.android.usbterminal.domain + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository +import javax.inject.Inject + +internal fun interface ISetAutoScrollUseCase { + operator fun invoke(enabled: Boolean) +} + +internal class SetAutoScrollUseCase + @Inject + constructor( + private val userSettingRepository: IUserSettingRepository, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + ) : ISetAutoScrollUseCase { + override fun invoke(enabled: Boolean) { + scope.launch { + userSettingRepository.setAutoScroll(enabled) + } + } + } diff --git a/app/src/main/java/org/kabiri/android/usbterminal/model/UserSettingPreferences.kt b/app/src/main/java/org/kabiri/android/usbterminal/model/UserSettingPreferences.kt index 75637ea4..43403ffa 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/model/UserSettingPreferences.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/model/UserSettingPreferences.kt @@ -1,7 +1,9 @@ package org.kabiri.android.usbterminal.model -internal const val defaultBaudRate: Int = 9600 +internal const val DEFAULT_BAUD_RATE: Int = 9600 +internal const val DEFAULT_AUTO_SCROLL: Boolean = true data class UserSettingPreferences( - val baudRate: Int = defaultBaudRate // Arduino default -) \ No newline at end of file + val baudRate: Int = DEFAULT_BAUD_RATE, // Arduino default + val autoScroll: Boolean = DEFAULT_AUTO_SCROLL, +) diff --git a/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingContent.kt b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingContent.kt index 94de228f..1c5bdb91 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingContent.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingContent.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.kabiri.android.usbterminal.model.defaultBaudRate +import org.kabiri.android.usbterminal.model.DEFAULT_BAUD_RATE @Composable internal fun SettingContent( @@ -16,17 +16,23 @@ internal fun SettingContent( settingViewModel: SettingViewModel, onDismiss: () -> Unit, ) { - val currentBaudRate by settingViewModel.currentBaudRate.collectAsState(defaultBaudRate) + val currentBaudRate by settingViewModel.currentBaudRate.collectAsState(DEFAULT_BAUD_RATE) + val autoScrollEnabled by settingViewModel.currentAutoScroll.collectAsState(true) Column(modifier = modifier) { - // Settings Header SettingsHeader() + // Auto-scroll Setting + SettingSwitchItem( + enabled = autoScrollEnabled, + onToggle = settingViewModel::setAutoScrollEnabled, + ) + // Baud Rate Setting SettingValueItem( currentValue = currentBaudRate, - onNewValue = settingViewModel::setNewBaudRate + onNewValue = settingViewModel::setNewBaudRate, ) // Reset Default Button @@ -35,10 +41,9 @@ internal fun SettingContent( settingViewModel.resetDefault() onDismiss() }, - onClickDismiss = onDismiss + onClickDismiss = onDismiss, ) Spacer(modifier = Modifier.height(8.dp)) } } - diff --git a/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt new file mode 100644 index 00000000..604a7224 --- /dev/null +++ b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt @@ -0,0 +1,37 @@ +package org.kabiri.android.usbterminal.ui.setting + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.kabiri.android.usbterminal.R + +@Composable +internal fun SettingSwitchItem( + enabled: Boolean, + onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.settings_label_auto_scroll), + color = MaterialTheme.colorScheme.onPrimary, + ) + Switch(checked = enabled, onCheckedChange = onToggle) + } +} diff --git a/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingViewModel.kt b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingViewModel.kt index 937b09b7..346734d5 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingViewModel.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingViewModel.kt @@ -3,9 +3,12 @@ package org.kabiri.android.usbterminal.ui.setting import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import org.kabiri.android.usbterminal.domain.IGetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.IGetCustomBaudRateUseCase +import org.kabiri.android.usbterminal.domain.ISetAutoScrollUseCase import org.kabiri.android.usbterminal.domain.ISetCustomBaudRateUseCase -import org.kabiri.android.usbterminal.model.defaultBaudRate +import org.kabiri.android.usbterminal.model.DEFAULT_AUTO_SCROLL +import org.kabiri.android.usbterminal.model.DEFAULT_BAUD_RATE import javax.inject.Inject /** @@ -13,17 +16,25 @@ import javax.inject.Inject */ @HiltViewModel internal class SettingViewModel -@Inject constructor( - private val getBaudRate: IGetCustomBaudRateUseCase, - private val setBaudRate: ISetCustomBaudRateUseCase, -): ViewModel() { + @Inject + constructor( + private val getBaudRate: IGetCustomBaudRateUseCase, + private val setBaudRate: ISetCustomBaudRateUseCase, + private val getAutoScroll: IGetAutoScrollUseCase, + private val setAutoScroll: ISetAutoScrollUseCase, + ) : ViewModel() { + val currentBaudRate: Flow + get() = getBaudRate() - val currentBaudRate: Flow - get() = getBaudRate() + fun setNewBaudRate(baudRate: Int) = setBaudRate(baudRate) - fun setNewBaudRate(baudRate: Int) = setBaudRate(baudRate) + val currentAutoScroll: Flow + get() = getAutoScroll() - fun resetDefault() { - setBaudRate(defaultBaudRate) + fun setAutoScrollEnabled(enabled: Boolean) = setAutoScroll(enabled) + + fun resetDefault() { + setBaudRate(DEFAULT_BAUD_RATE) + setAutoScroll(DEFAULT_AUTO_SCROLL) + } } -} \ No newline at end of file diff --git a/app/src/main/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModel.kt b/app/src/main/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModel.kt index 10041c9d..1982bdf9 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModel.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModel.kt @@ -27,159 +27,168 @@ import javax.inject.Inject /** * Created by Ali Kabiri on 12.04.20. */ +@Suppress("ktlint:standard:backing-property-naming") @HiltViewModel internal class MainActivityViewModel -@Inject constructor( - private val arduinoUseCase: IArduinoUseCase, - private val usbUseCase: IUsbUseCase, - private val resourceProvider: IResourceProvider, -): ViewModel() { - - private val _infoMessageFlow = MutableStateFlow("") - val infoMessage: StateFlow - get() = _infoMessageFlow - - private val _errorMessageFlow = MutableStateFlow("") - val errorMessage: StateFlow - get() = _errorMessageFlow - - private val _outputLive = MutableStateFlow("") - val output: StateFlow - get() = _outputLive - - val output2 = SnapshotStateList() - - init { - // Subscribe to USB device changes. - viewModelScope.launch { - usbUseCase.usbDevice.collect { device -> - _infoMessageFlow.value = "device discovered: ${device?.vendorId}\n" - device?.let { openDeviceAndPort(it) } + @Inject + constructor( + private val arduinoUseCase: IArduinoUseCase, + private val usbUseCase: IUsbUseCase, + private val resourceProvider: IResourceProvider, + ) : ViewModel() { + private val _infoMessageFlow = MutableStateFlow("") + val infoMessage: StateFlow + get() = _infoMessageFlow + + private val _errorMessageFlow = MutableStateFlow("") + val errorMessage: StateFlow + get() = _errorMessageFlow + + private val _outputLive = MutableStateFlow("") + val output: StateFlow + get() = _outputLive + + val output2 = SnapshotStateList() + + internal fun startObservingUsbDevice() { + // Subscribe to USB device changes. + viewModelScope.launch { + usbUseCase.usbDevice.collect { device -> + _infoMessageFlow.value = "device discovered: ${device?.vendorId}\n" + // TODO: DROID-17 - check if this line is required after DROID-17 is done + device?.let { openDeviceAndPort(it) } + } } } - } - fun connect() { - val usbDeviceList = usbUseCase.scanForUsbDevices() - if (usbDeviceList.isEmpty()) { - _errorMessageFlow.value = - resourceProvider.getString(R.string.helper_error_usb_devices_not_attached) - return // no usb devices found - } - val device = usbDeviceList.firstOrNull { it.isOfficialArduinoBoard() || it.isCloneArduinoBoard() } - if (device == null) { - _errorMessageFlow.value = - resourceProvider.getString(R.string.helper_error_arduino_device_not_found) - _infoMessageFlow.value = - resourceProvider.getString(R.string.helper_error_connecting_anyway) - - // request permission for the unknown device anyways - return usbUseCase.requestPermission(usbDeviceList.first()) - } - when (device.getArduinoType()) { - OFFICIAL -> { - usbUseCase.requestPermission(device) + fun connect() { + val usbDeviceList = usbUseCase.scanForUsbDevices() + if (usbDeviceList.isEmpty()) { + _errorMessageFlow.value = + resourceProvider.getString(R.string.helper_error_usb_devices_not_attached) + return // no usb devices found } - - else -> { + val device = usbDeviceList.firstOrNull { it.isOfficialArduinoBoard() || it.isCloneArduinoBoard() } + if (device == null) { + _errorMessageFlow.value = + resourceProvider.getString(R.string.helper_error_arduino_device_not_found) _infoMessageFlow.value = resourceProvider.getString(R.string.helper_error_connecting_anyway) - usbUseCase.requestPermission(device) - } - } - } - - fun connectIfAlreadyHasPermission() = viewModelScope.launch { - val usbDevice = usbUseCase.usbDevice.firstOrNull() ?: return@launch - usbUseCase.hasPermission(usbDevice) - openDeviceAndPort(usbDevice) - } - - fun disconnect() { - usbUseCase.disconnect() - arduinoUseCase.disconnect() - } - - fun openDeviceAndPort(device: UsbDevice) = viewModelScope.launch { - arduinoUseCase.openDeviceAndPort(device) - } - fun serialWrite(command: String): Boolean { - _outputLive.value = "${output.value}\n$command\n" - return arduinoUseCase.serialWrite(command) - } - - /** - * Transforms the outputs from ArduinoHelper into spannable text - * and merges them in one single flow - */ - suspend fun getLiveOutput(): StateFlow { - - val infoOutput: Flow = infoMessage.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) - output2.add(outputText) - return@map outputText - } - - val errorOutput: Flow = errorMessage.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_ERROR) - output2.add(outputText) - return@map outputText + // request permission for the unknown device anyways + return usbUseCase.requestPermission(usbDeviceList.first()) + } + when (device.getArduinoType()) { + OFFICIAL -> { + usbUseCase.requestPermission(device) + } + + else -> { + _infoMessageFlow.value = + resourceProvider.getString(R.string.helper_error_connecting_anyway) + usbUseCase.requestPermission(device) + } + } } - val usbInfoOutput: Flow = usbUseCase.infoMessageFlow.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) - output2.add(outputText) - return@map outputText - } + fun connectIfAlreadyHasPermission() = + viewModelScope.launch { + val usbDevice = usbUseCase.usbDevice.firstOrNull() ?: return@launch + usbUseCase.hasPermission(usbDevice) + // TODO: DROID-17 - Fix hasPermission return value not being used here. + openDeviceAndPort(usbDevice) + } - val arduinoDefaultOutput: Flow = arduinoUseCase.messageFlow.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_NORMAL) - output2.add(outputText) - return@map outputText + fun disconnect() { + usbUseCase.disconnect() + arduinoUseCase.disconnect() } - val arduinoInfoOutput: Flow = arduinoUseCase.infoMessageFlow.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) - output2.add(outputText) - return@map outputText - } + private fun openDeviceAndPort(device: UsbDevice) = + viewModelScope.launch { + arduinoUseCase.openDeviceAndPort(device) + } - val arduinoErrorOutput: Flow = arduinoUseCase.errorMessageFlow.map { - _outputLive.value = _outputLive.value + it - val outputText = OutputText(it, OutputText.OutputType.TYPE_ERROR) - output2.add(outputText) - return@map outputText + fun serialWrite(command: String): Boolean { + _outputLive.value = "${output.value}\n$command\n" + return arduinoUseCase.serialWrite(command) } - return combine( - infoOutput, - errorOutput, - arduinoDefaultOutput, - arduinoInfoOutput, - arduinoErrorOutput, - ) { info, error, arduinoDefault, arduinoInfo, arduinoError -> - // Prioritize error output over info, then normal. - when { - error.text.isNotEmpty() -> error - info.text.isNotEmpty() -> info - arduinoError.text.isNotEmpty() -> arduinoError - arduinoInfo.text.isNotEmpty() -> arduinoInfo - else -> arduinoDefault - } - }.combine(usbInfoOutput) { outputText, usbInfo -> - // Prioritize USB info output over the rest. - if (usbInfo.text.isNotEmpty()) { - usbInfo - } else { - outputText - } + /** + * Transforms the outputs from ArduinoHelper into spannable text + * and merges them in one single flow + */ + suspend fun getLiveOutput(): StateFlow { + val infoOutput: Flow = + infoMessage.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) + output2.add(outputText) + return@map outputText + } + + val errorOutput: Flow = + errorMessage.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_ERROR) + output2.add(outputText) + return@map outputText + } + + val usbInfoOutput: Flow = + usbUseCase.infoMessageFlow.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) + output2.add(outputText) + return@map outputText + } + + val arduinoDefaultOutput: Flow = + arduinoUseCase.messageFlow.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_NORMAL) + output2.add(outputText) + return@map outputText + } + + val arduinoInfoOutput: Flow = + arduinoUseCase.infoMessageFlow.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_INFO) + output2.add(outputText) + return@map outputText + } + + val arduinoErrorOutput: Flow = + arduinoUseCase.errorMessageFlow.map { + _outputLive.value = _outputLive.value + it + val outputText = OutputText(it, OutputText.OutputType.TYPE_ERROR) + output2.add(outputText) + return@map outputText + } + + return combine( + infoOutput, + errorOutput, + arduinoDefaultOutput, + arduinoInfoOutput, + arduinoErrorOutput, + ) { info, error, arduinoDefault, arduinoInfo, arduinoError -> + // Prioritize error output over info, then normal. + when { + error.text.isNotEmpty() -> error + info.text.isNotEmpty() -> info + arduinoError.text.isNotEmpty() -> arduinoError + arduinoInfo.text.isNotEmpty() -> arduinoInfo + else -> arduinoDefault + } + }.combine(usbInfoOutput) { outputText, usbInfo -> + // Prioritize USB info output over the rest. + if (usbInfo.text.isNotEmpty()) { + usbInfo + } else { + outputText + } + }.stateIn(viewModelScope) } - .stateIn(viewModelScope) } -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3477914f..2b64ea5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Baud Rate Saved Enter a Number + Auto-scroll terminal output Go Forward diff --git a/app/src/test/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryTest.kt new file mode 100644 index 00000000..210c1bc3 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryTest.kt @@ -0,0 +1,112 @@ +package org.kabiri.android.usbterminal.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.kabiri.android.usbterminal.model.UserSettingPreferences + +private const val TEST_DATA_STORE_NAME = "test_data_store.preferences_pb" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class UserSettingRepositoryTest { + private val dispatcher: CoroutineDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val tempDir = TemporaryFolder() + + private lateinit var testDataStore: DataStore + + private lateinit var repository: UserSettingRepository + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + tempDir.create() + testDataStore = + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.newFile(TEST_DATA_STORE_NAME) }, + ) + repository = UserSettingRepository(testDataStore) + } + + @After + fun cleanUp() { + tempDir.delete() + } + + @Test + fun testFetchInitialPreferences() = + runTest { + // arrange + val expected = UserSettingPreferences() + + // act + val actual = repository.fetchInitialPreferences() + + // assert + assertThat(actual).isEqualTo(expected) + } + + @Test + fun testWriteBaudRate() = + runTest { + // arrange + val customBaudRate = 123 + val expected = + UserSettingPreferences( + baudRate = customBaudRate, + ) + + // act + repository.setBaudRate(customBaudRate) + + // assert + val actual = repository.preferenceFlow.first() + assertThat(actual).isEqualTo(expected) + } + + @Test + fun testSetAutoScrollUpdatesPreferences() = + runTest { + // arrange + val expected = false + + // act + repository.setAutoScroll(false) + + // assert + val actual = repository.preferenceFlow.first().autoScroll + assertThat(actual).isEqualTo(expected) + } + + @Test + fun testClearResetsValuesToDefaults() = + runTest { + // arrange + val customBaudRate = 123 + val expected = UserSettingPreferences() + + // act + repository.setBaudRate(customBaudRate) + repository.clear() + + // assert + val actual = repository.preferenceFlow.first() + assertThat(actual).isEqualTo(expected) + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt new file mode 100644 index 00000000..89b99093 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt @@ -0,0 +1,71 @@ +package org.kabiri.android.usbterminal.domain + +import com.google.common.truth.Truth.assertThat +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository +import org.kabiri.android.usbterminal.model.UserSettingPreferences + +internal class GetAutoScrollUseCaseTest { + private lateinit var sut: GetAutoScrollUseCase + + private val mockUserSettingRepository: IUserSettingRepository = mockk() + + @Before + internal fun setup() { + sut = + GetAutoScrollUseCase( + userSettingRepository = mockUserSettingRepository, + ) + } + + @After + internal fun cleanUp() { + clearAllMocks() + } + + @Test + fun `test getAutoScrollUseCase returns true`() = + runTest { + // arrange + val expected = true + mockUserSettingRepository.apply { + coEvery { preferenceFlow } returns + flowOf( + UserSettingPreferences(autoScroll = expected), + ) + } + + // act + val actual = sut().first() + + // assert + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `test getAutoScrollUseCase returns false`() = + runTest { + // arrange + val expected = false + mockUserSettingRepository.apply { + coEvery { preferenceFlow } returns + flowOf( + UserSettingPreferences(autoScroll = expected), + ) + } + + // act + val actual = sut().first() + + // assert + assertThat(actual).isEqualTo(expected) + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseTest.kt new file mode 100644 index 00000000..a4563dd8 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseTest.kt @@ -0,0 +1,52 @@ +package org.kabiri.android.usbterminal.domain + +import com.google.common.truth.Truth.assertThat +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository +import org.kabiri.android.usbterminal.model.UserSettingPreferences + +internal class GetCustomBaudRateUseCaseTest { + private lateinit var sut: GetCustomBaudRateUseCase + + private val mockUserSettingRepository: IUserSettingRepository = mockk() + + @Before + internal fun setup() { + sut = + GetCustomBaudRateUseCase( + userSettingRepository = mockUserSettingRepository, + ) + } + + @After + internal fun cleanUp() { + clearAllMocks() + } + + @Test + fun `test invoke returns expected baudRate`() = + runTest { + // arrange + val expected = 1234 + mockUserSettingRepository.apply { + coEvery { preferenceFlow } returns + flowOf( + UserSettingPreferences(baudRate = expected), + ) + } + + // act + val actualBaudRate = sut().first() + + // assert + assertThat(actualBaudRate).isEqualTo(expected) + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt new file mode 100644 index 00000000..afcc431a --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt @@ -0,0 +1,66 @@ +package org.kabiri.android.usbterminal.domain + +import com.google.common.truth.Truth.assertThat +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository +import org.kabiri.android.usbterminal.model.UserSettingPreferences + +internal class SetAutoScrollUseCaseTest { + private lateinit var sut: SetAutoScrollUseCase + + private val mockUserSettingRepository: IUserSettingRepository = mockk() + + @Before + internal fun setup() { + sut = + SetAutoScrollUseCase( + userSettingRepository = mockUserSettingRepository, + ) + } + + @After + internal fun cleanUp() { + clearAllMocks() + } + + @Test + fun `test setAutoScrollUseCase calls setAutoScroll with true on repository`() = + runTest { + // arrange + val expected = true + mockUserSettingRepository.apply { + coEvery { setAutoScroll(expected) } returns Unit + } + + // act + sut(expected) + + // assert + coVerify(exactly = 1) { mockUserSettingRepository.setAutoScroll(expected) } + } + + @Test + fun `test setAutoScrollUseCase calls setAutoScroll with false on repository`() = + runTest { + // arrange + val expected = false + mockUserSettingRepository.apply { + coEvery { setAutoScroll(expected) } returns Unit + } + + // act + sut(expected) + + // assert + coVerify(exactly = 1) { mockUserSettingRepository.setAutoScroll(expected) } + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseTest.kt new file mode 100644 index 00000000..a3d409d0 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseTest.kt @@ -0,0 +1,46 @@ +package org.kabiri.android.usbterminal.domain + +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository + +internal class SetCustomBaudRateUseCaseTest { + private lateinit var sut: SetCustomBaudRateUseCase + + private val mockUserSettingRepository: IUserSettingRepository = mockk() + + @Before + internal fun setup() { + sut = + SetCustomBaudRateUseCase( + userSettingRepository = mockUserSettingRepository, + ) + } + + @After + internal fun cleanUp() { + clearAllMocks() + } + + @Test + fun `test invoke calls setBaudRate with expected value on repository`() = + runTest { + // arrange + val expected = 1234 + mockUserSettingRepository.apply { + coEvery { setBaudRate(expected) } returns Unit + } + + // act + sut(expected) + + // assert + coVerify(exactly = 1) { mockUserSettingRepository.setBaudRate(expected) } + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt new file mode 100644 index 00000000..a4482fea --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -0,0 +1,312 @@ +package org.kabiri.android.usbterminal.viewmodel + +import android.hardware.usb.UsbDevice +import com.google.common.truth.Truth.assertThat +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.R +import org.kabiri.android.usbterminal.domain.IArduinoUseCase +import org.kabiri.android.usbterminal.domain.IUsbUseCase +import org.kabiri.android.usbterminal.util.IResourceProvider +import org.kabiri.android.usbterminal.util.isCloneArduinoBoard +import org.kabiri.android.usbterminal.util.isOfficialArduinoBoard + +private const val OFFICIAL_VENDOR_ID = 0x2341 +private const val OFFICIAL_PRODUCT_ID = 0x0043 + +private const val CLONE_VENDOR_ID = 0x1A86 +private const val CLONE_PRODUCT_ID = 0x7523 + +private const val OTHER_VENDOR_ID = 0x1234 // random id +private const val OTHER_PRODUCT_ID = 0x1234 // random id + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MainActivityViewModelTest { + private val testDispatcher: TestDispatcher = StandardTestDispatcher() + + val mockArduinoUsecase: IArduinoUseCase = mockk(relaxed = true) + val mockUsbUseCase: IUsbUseCase = mockk(relaxed = true) + val mockResourceProvider: IResourceProvider = mockk(relaxed = true) + + private lateinit var sut: MainActivityViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + sut = + MainActivityViewModel( + arduinoUseCase = mockArduinoUsecase, + usbUseCase = mockUsbUseCase, + resourceProvider = mockResourceProvider, + ) + } + + @After + fun cleanUp() { + Dispatchers.resetMain() + unmockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + clearAllMocks() + } + + @Test + fun `startObservingUsbDevice updates infoMessage and calls openDeviceAndPort when device is emitted`() = + runTest { + // arrange + val expected = OFFICIAL_VENDOR_ID + val deviceFlow = MutableStateFlow(null) + val mockDevice: UsbDevice = mockk(relaxed = true) + every { mockDevice.vendorId } returns expected + every { mockUsbUseCase.usbDevice } returns deviceFlow + every { mockArduinoUsecase.openDeviceAndPort(mockDevice) } returns Unit + + // act + sut.startObservingUsbDevice() + deviceFlow.value = mockDevice + advanceUntilIdle() + + // assert + verify(exactly = 1) { mockArduinoUsecase.openDeviceAndPort(mockDevice) } + assertThat(sut.infoMessage.value).contains(expected.toString()) + } + + @Test + fun `startObservingUsbDevice does not call openDeviceAndPort when device is null`() = + runTest { + // arrange + val expected = null + val deviceFlow = MutableStateFlow(expected) + every { mockUsbUseCase.usbDevice } returns deviceFlow + + // act + sut.startObservingUsbDevice() + deviceFlow.value = expected + advanceUntilIdle() + + // assert + verify(exactly = 0) { mockArduinoUsecase.openDeviceAndPort(any()) } + assertThat(sut.infoMessage.value).contains(expected.toString()) + } + + @Test + fun `connect emits expected message when device list is empty`() = + runTest { + // arrange + val expected = "this is an error" + every { mockUsbUseCase.scanForUsbDevices() } returns emptyList() + every { mockResourceProvider.getString(R.string.helper_error_usb_devices_not_attached) } returns expected + + // act + sut.connect() + + // assert + assertThat(sut.errorMessage.value).isEqualTo(expected) + } + + @Test + fun `connect emits expected message and calls requestPermission anyways when the device is unknown`() = + runTest { + // arrange + val expectedError = "device not found" + val expectedInfo = "connecting anyways" + + val fakeDevice: UsbDevice = mockk(relaxed = true) + every { fakeDevice.vendorId } returns OTHER_VENDOR_ID + every { fakeDevice.productId } returns OTHER_PRODUCT_ID + + every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) + every { mockResourceProvider.getString(R.string.helper_error_arduino_device_not_found) } returns expectedError + every { mockResourceProvider.getString(R.string.helper_error_connecting_anyway) } returns expectedInfo + + // act + sut.connect() + + // assert + assertThat(sut.errorMessage.value).isEqualTo(expectedError) + assertThat(sut.infoMessage.value).isEqualTo(expectedInfo) + verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } + } + + @Test + fun `connect emits expected message and calls requestPermission when the device is a clone`() = + runTest { + // arrange + val expected = "connecting anyways" + + val fakeDevice: UsbDevice = mockk(relaxed = true) + every { fakeDevice.vendorId } returns CLONE_VENDOR_ID + every { fakeDevice.productId } returns CLONE_PRODUCT_ID + + every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) + every { mockResourceProvider.getString(R.string.helper_error_connecting_anyway) } returns expected + + // act + sut.connect() + + // assert + assertThat(sut.infoMessage.value).isEqualTo(expected) + verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } + } + + @Test + fun `connect calls requestPermission when the device is official`() = + runTest { + // arrange + val fakeDevice: UsbDevice = mockk(relaxed = true) + every { fakeDevice.vendorId } returns OFFICIAL_VENDOR_ID + every { fakeDevice.productId } returns OFFICIAL_PRODUCT_ID + + every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) + + // act + sut.connect() + + // assert + verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } + assertThat(sut.infoMessage.value).isEqualTo("") + assertThat(sut.errorMessage.value).isEqualTo("") + assertThat(sut.output.value).isEqualTo("") + } + + @Test + fun `connectIfAlreadyHasPermission does nothing when no usb device exists`() = + runTest { + // arrange + every { mockUsbUseCase.usbDevice } returns MutableStateFlow(null) + + // act + sut.connectIfAlreadyHasPermission() + + // assert + verify(exactly = 0) { mockUsbUseCase.hasPermission(any()) } + verify(exactly = 0) { mockArduinoUsecase.openDeviceAndPort(any()) } + } + + @Test + fun `connectIfAlreadyHasPermission calls hasPermission and openDeviceAndPort when usb device exists`() = + runTest { + // arrange + val mockDevice: UsbDevice = mockk(relaxed = true) + val fakeVendorId = OFFICIAL_VENDOR_ID + every { mockDevice.vendorId } returns fakeVendorId + every { mockUsbUseCase.usbDevice } returns MutableStateFlow(mockDevice) + every { mockUsbUseCase.hasPermission(mockDevice) } returns true + every { mockArduinoUsecase.openDeviceAndPort(any()) } returns Unit + + // act + sut.connectIfAlreadyHasPermission() + advanceUntilIdle() + + // assert + verify { mockUsbUseCase.hasPermission(mockDevice) } + verify { mockArduinoUsecase.openDeviceAndPort(mockDevice) } + } + + @Test + fun `disconnect calls disconnect on both usbUseCase and arduinoUseCase`() = + runTest { + // arrange + // act + sut.disconnect() + + // assert + verify(exactly = 1) { mockUsbUseCase.disconnect() } + verify(exactly = 1) { mockArduinoUsecase.disconnect() } + } + + @Test + fun `serialWrite updates output and returns result from arduinoUseCase`() = + runTest { + // arrange + val expected = "TEST_COMMAND" + every { mockArduinoUsecase.serialWrite(expected) } returns true + + // act + val result = sut.serialWrite(expected) + + // assert + verify { mockArduinoUsecase.serialWrite(expected) } + assertThat(result).isTrue() + assertThat(sut.output.value).contains(expected) + } + + @Test + fun `getLiveOutput emits arduinoInfo when only arduinoInfo is not empty`() = + runTest { + // arrange + val arduinoDefaultFlow = MutableStateFlow("") + val arduinoInfoFlow = MutableStateFlow("arduino info message") + val arduinoErrorFlow = MutableStateFlow("") + val usbInfoFlow = MutableStateFlow("") + + every { mockArduinoUsecase.messageFlow } returns arduinoDefaultFlow + every { mockArduinoUsecase.infoMessageFlow } returns arduinoInfoFlow + every { mockArduinoUsecase.errorMessageFlow } returns arduinoErrorFlow + every { mockUsbUseCase.infoMessageFlow } returns usbInfoFlow + every { mockResourceProvider.getString(any()) } returns "" + + // inject flows into ViewModel + sut = + MainActivityViewModel( + arduinoUseCase = mockArduinoUsecase, + usbUseCase = mockUsbUseCase, + resourceProvider = mockResourceProvider, + ) + + // act + val outputFlow = sut.getLiveOutput() + advanceUntilIdle() + val output = outputFlow.value + + // assert + assertThat(output.text).isEqualTo("arduino info message") + assertThat(output.type).isEqualTo(org.kabiri.android.usbterminal.model.OutputText.OutputType.TYPE_INFO) + } + + @Test + fun `getLiveOutput emits arduinoDefault when all outputs are empty`() = + runTest { + // arrange + val arduinoDefaultFlow = MutableStateFlow("default message") + val arduinoInfoFlow = MutableStateFlow("") + val arduinoErrorFlow = MutableStateFlow("") + val usbInfoFlow = MutableStateFlow("") + + every { mockArduinoUsecase.messageFlow } returns arduinoDefaultFlow + every { mockArduinoUsecase.infoMessageFlow } returns arduinoInfoFlow + every { mockArduinoUsecase.errorMessageFlow } returns arduinoErrorFlow + every { mockUsbUseCase.infoMessageFlow } returns usbInfoFlow + every { mockResourceProvider.getString(any()) } returns "" + + // inject flows into ViewModel + sut = + MainActivityViewModel( + arduinoUseCase = mockArduinoUsecase, + usbUseCase = mockUsbUseCase, + resourceProvider = mockResourceProvider, + ) + + // act + val outputFlow = sut.getLiveOutput() + advanceUntilIdle() + val output = outputFlow.value + + // assert + assertThat(output.text).isEqualTo("default message") + assertThat(output.type).isEqualTo(org.kabiri.android.usbterminal.model.OutputText.OutputType.TYPE_NORMAL) + } +} diff --git a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt new file mode 100644 index 00000000..360f0b68 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt @@ -0,0 +1,121 @@ +package org.kabiri.android.usbterminal.viewmodel + +import com.google.common.truth.Truth.assertThat +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.kabiri.android.usbterminal.domain.IGetAutoScrollUseCase +import org.kabiri.android.usbterminal.domain.IGetCustomBaudRateUseCase +import org.kabiri.android.usbterminal.domain.ISetAutoScrollUseCase +import org.kabiri.android.usbterminal.domain.ISetCustomBaudRateUseCase +import org.kabiri.android.usbterminal.ui.setting.SettingViewModel + +internal class SettingViewModelTest { + private val mockGetBaudRate: IGetCustomBaudRateUseCase = mockk() + private val mockSetBaudRate: ISetCustomBaudRateUseCase = mockk() + private val mockGetAutoScroll: IGetAutoScrollUseCase = mockk() + private val mockSetAutoScroll: ISetAutoScrollUseCase = mockk() + + private lateinit var sut: SettingViewModel + + @Before + internal fun setup() { + sut = + SettingViewModel( + getBaudRate = mockGetBaudRate, + setBaudRate = mockSetBaudRate, + getAutoScroll = mockGetAutoScroll, + setAutoScroll = mockSetAutoScroll, + ) + } + + @After + internal fun cleanUp() { + clearAllMocks() + } + + @Test + internal fun `test currentBaudRate calls getBaudRate`() = + runTest { + // arrange + val expected = 123 + every { mockGetBaudRate() } returns flowOf(expected) + + // act + val actual = sut.currentBaudRate.first() + + // assert + assertThat(actual).isEqualTo(expected) + verify(exactly = 1) { + @Suppress("UnusedFlow") + mockGetBaudRate() + } + } + + @Test + internal fun `test setNewBaudRate calls setBaudRate`() { + // arrange + val expected = 123 + every { mockSetBaudRate(expected) } returns Unit + + // act + sut.setNewBaudRate(expected) + + // assert + verify(exactly = 1) { mockSetBaudRate(expected) } + } + + @Test + internal fun `test currentAutoScroll calls getAutoScroll`() = + runTest { + // arrange + val expected = true + every { mockGetAutoScroll() } returns flowOf(expected) + + // act + val actual = sut.currentAutoScroll.first() + + // assert + assertThat(actual).isEqualTo(expected) + verify(exactly = 1) { + @Suppress("UnusedFlow") + mockGetAutoScroll() + } + } + + @Test + internal fun `test setAutoScrollEnabled calls setAutoScroll`() { + // arrange + val expected = true + every { mockSetAutoScroll(expected) } returns Unit + + // act + sut.setAutoScrollEnabled(expected) + + // assert + verify(exactly = 1) { mockSetAutoScroll(expected) } + } + + @Test + internal fun `test resetDefault calls setBaudRate and setAutoScroll`() { + // arrange + val expectedBaudRate = 9600 + val expectedAutoScroll = true + every { mockSetBaudRate(expectedBaudRate) } returns Unit + every { mockSetAutoScroll(expectedAutoScroll) } returns Unit + + // act + sut.resetDefault() + + // assert + verify(exactly = 1) { mockSetBaudRate(expectedBaudRate) } + verify(exactly = 1) { mockSetAutoScroll(expectedAutoScroll) } + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8cf3ac62..620c3601 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -33,11 +33,6 @@ platform :android do ]) end - desc "Runs lint" - lane :lint do - gradle(task: "lintDebug") - end - desc "Deploy to Firebase AppTester Dev channel" lane :distDev do gradle(tasks: [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61b952fa..00d337e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Build tools & plugins -gradle = "8.10.1" +gradle = "8.11.1" kotlin = "2.1.20" jacoco = "0.8.8" @@ -8,7 +8,7 @@ jacoco = "0.8.8" appcompat = "1.7.1" coreKtx = "1.16.0" constraintlayout = "2.2.1" -lifecycleRuntimeKtx = "2.9.1" +lifecycleRuntimeKtx = "2.9.2" lifecycleViewmodelKtx = "2.9.1" lifecycleExtensions = "2.2.0" activityCompose = "1.10.1" @@ -35,7 +35,7 @@ usbSerial = "6.1.1" # Testing junit = "4.13.2" -mockk = "1.14.2" +mockk = "1.14.5" testCore = "1.6.1" testExtJunit = "1.2.1" testExtJunitKtx = "1.2.1" @@ -64,13 +64,14 @@ compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material3 = { module = "androidx.compose.material3:material3" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } # --- Google/Material --- material = { module = "com.google.android.material:material", version.ref = "material" } # --- Firebase --- firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } -firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx" } firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx" } # --- Dagger/Hilt --- diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c82..37f853b1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME