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