From 676b589191131f22ed50dc45b992fecc34b6d1bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Jul 2025 00:47:43 +0000 Subject: [PATCH 01/60] chore(deps): bump com.android.application from 8.10.1 to 8.11.1 Bumps com.android.application from 8.10.1 to 8.11.1. --- updated-dependencies: - dependency-name: com.android.application dependency-version: 8.11.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61b952f..3415d2c 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" From 9f3ca9a9fd5200f810252fe741a8f3581bc78d8b Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 06:20:48 +0200 Subject: [PATCH 02/60] deps: update Gradle to 8.13 and bump version in libs.versions.toml --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61b952f..3415d2c 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" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..37f853b 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 From 437b0fe17af6edd19455834382de00949ce50544 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:27:58 +0200 Subject: [PATCH 03/60] ci:run distribute-internal-testing only after successful tests --- .circleci/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b84cfb9..f69600c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,4 +108,7 @@ workflows: - android-test: context: SonarCloud - distribute-dev - - distribute-internal-testing + - distribute-internal-testing: + requires: + - android-test + From 2840e0718051cd582e73720bc42a963df1e64c42 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:39:41 +0200 Subject: [PATCH 04/60] ci:make android_setup reusable --- .circleci/config.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f69600c..2deddb6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,7 @@ 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: @@ -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,6 +34,14 @@ commands: - vendor/bundle key: v1-bundler-cache-{{ checksum "Gemfile.lock" }} + setup_android: + steps: + - checkout + - restore_gradle_cache + - restore_bundler_cache + - ruby/install-deps: + with-cache: true + executors: android-machine: machine: @@ -45,11 +55,7 @@ jobs: executor: android-machine resource_class: large steps: - - checkout - - restore_gradle_cache - - restore_bundler_cache - - ruby/install-deps: - with-cache: true + - setup_android - run: name: Fastlane - run all tests with coverage report command: | From 468bb3bcefe49ead12e9162008842e4aab0ea7c8 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:43:34 +0200 Subject: [PATCH 05/60] ci:remove unncessary lint lane and use the gradlew command directly --- .circleci/config.yml | 4 ++-- fastlane/Fastfile | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2deddb6..43f4103 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -60,12 +60,12 @@ jobs: 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: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8cf3ac6..620c360 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: [ From fe5b8ab73c92bad89d1da4a4f686b6ef2b525e64 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:47:59 +0200 Subject: [PATCH 06/60] ci: hopefully add caching to jobs that run on docker --- .circleci/config.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 43f4103..23248e6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,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 @@ -93,8 +94,7 @@ jobs: executor: android-docker resource_class: large steps: - - checkout - - restore_gradle_cache + - android_setup - run: name: Prepare Fastlane command: sudo bundle update @@ -104,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 From ce84bd4bb3e0f6102b2f0b2abfb34f291f3fbe69 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:49:19 +0200 Subject: [PATCH 07/60] fix: fix incorrect jobname in the config --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 23248e6..19253e7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -94,7 +94,7 @@ jobs: executor: android-docker resource_class: large steps: - - android_setup + - setup_android - run: name: Prepare Fastlane command: sudo bundle update From 47141c3dc1bd3ec4f1aed2d07009fa5ed67c108b Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:54:58 +0200 Subject: [PATCH 08/60] chore: update bundlder dependencies --- Gemfile.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4ec9b03..9b2175e 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) From 33df2926abd6950a4a140de96f5459187730973d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 07:58:33 +0200 Subject: [PATCH 09/60] ci: include architecture in bundler cache key --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 19253e7..fb75457 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ commands: 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: From 7c8ee5173a9c55b46341379317295b6d5121a83c Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 08:06:57 +0200 Subject: [PATCH 10/60] ci: update ruby orb --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fb75457..c017251 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: From 2bd8f85b76e53c5ec4f9ba98c5712d21abf2ecfe Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 08:10:28 +0200 Subject: [PATCH 11/60] ci: update circleci android-machine and android-docker --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c017251..8b156de 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,10 +45,10 @@ commands: 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: From 4c903877dfdc74ffbe1db8f75c505f4f0689410d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 08:17:53 +0200 Subject: [PATCH 12/60] ci: avoid manually installing ruby in docker images that already provide it --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b156de..2bb8aa1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,8 +39,6 @@ commands: - checkout - restore_gradle_cache - restore_bundler_cache - - ruby/install-deps: - with-cache: true executors: android-machine: @@ -56,6 +54,8 @@ jobs: resource_class: large steps: - setup_android + - ruby/install-deps: + with-cache: true - run: name: Fastlane - run all tests with coverage report command: | From e847168e48789d75a2803dee97cd79e6542cd354 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 09:44:44 +0200 Subject: [PATCH 13/60] tests: add unit tests for MainActivityViewModel --- .../viewmodel/MainActivityViewModelTest.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt 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 0000000..768e712 --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -0,0 +1,65 @@ +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.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +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 + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MainActivityViewModelTest { + + private val testDispatcher: TestDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + 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("org.kabiri.android.usbterminal.util.UsbDeviceExtensionsKt") + clearAllMocks() + } + + @Test + fun `connect emits expected message when device list is empty`() { + // 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) + } +} \ No newline at end of file From 73d0925c9608728500ef9f44a49d27e1f9e9727f Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 10:45:19 +0200 Subject: [PATCH 14/60] docs: document a TODO for fixing the unused value of hasPermission. --- .../android/usbterminal/viewmodel/MainActivityViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 10041c9..e7651ec 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 @@ -92,6 +92,7 @@ internal class MainActivityViewModel fun connectIfAlreadyHasPermission() = viewModelScope.launch { val usbDevice = usbUseCase.usbDevice.firstOrNull() ?: return@launch usbUseCase.hasPermission(usbDevice) + // TODO : Fix hasPermission return value not being used here. openDeviceAndPort(usbDevice) } From 52a991857625d7b19b7668c10d6e367d8559d752 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 11:25:53 +0200 Subject: [PATCH 15/60] tests: improve MainActivityViewModelTest with additional connection scenarios --- .../viewmodel/MainActivityViewModelTest.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) 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 index 768e712..afcf5c8 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -1,10 +1,13 @@ 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.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -19,6 +22,8 @@ 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 @OptIn(ExperimentalCoroutinesApi::class) internal class MainActivityViewModelTest { @@ -62,4 +67,69 @@ internal class MainActivityViewModelTest { // assert assertThat(sut.errorMessage.value).isEqualTo(expected) } + + @Test + fun `connect emits expected message and calls requestPermission anyways when the device is unknown`() { + // arrange + val expectedError = "device not found" + val expectedInfo = "connecting anyways" + + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns false + every { fakeDevice.isCloneArduinoBoard() } returns false + + 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`() { + // arrange + val expected = "connecting anyways" + + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns false + every { fakeDevice.isCloneArduinoBoard() } returns true + + 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`() { + // arrange + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns true + every { fakeDevice.isCloneArduinoBoard() } returns false + + 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("") + } } \ No newline at end of file From 3fbca75fa8f0d5787da916384ae4abc95e753195 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 11:26:15 +0200 Subject: [PATCH 16/60] nitpick: change openDeviceAndPort to private in MainActivityViewModel --- .../android/usbterminal/viewmodel/MainActivityViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e7651ec..477c59c 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 @@ -101,7 +101,7 @@ internal class MainActivityViewModel arduinoUseCase.disconnect() } - fun openDeviceAndPort(device: UsbDevice) = viewModelScope.launch { + private fun openDeviceAndPort(device: UsbDevice) = viewModelScope.launch { arduinoUseCase.openDeviceAndPort(device) } From 17e45322d4759b59059db4df1b55390bfbcd879d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 11:33:25 +0200 Subject: [PATCH 17/60] docs: add TODO comments for DROID-17 in MainActivityViewModel --- .../android/usbterminal/viewmodel/MainActivityViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 477c59c..3db4ca3 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 @@ -54,6 +54,7 @@ internal class MainActivityViewModel 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) } } } @@ -92,7 +93,7 @@ internal class MainActivityViewModel fun connectIfAlreadyHasPermission() = viewModelScope.launch { val usbDevice = usbUseCase.usbDevice.firstOrNull() ?: return@launch usbUseCase.hasPermission(usbDevice) - // TODO : Fix hasPermission return value not being used here. + // TODO: DROID-17 - Fix hasPermission return value not being used here. openDeviceAndPort(usbDevice) } From 745467c03f3fe1db6fc2205d4f93b7f9dff007f5 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 12:01:58 +0200 Subject: [PATCH 18/60] tests: refactor MainActivityViewModelTest setup and cleanup methods --- .../viewmodel/MainActivityViewModelTest.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index afcf5c8..4dd0c8d 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -27,7 +27,6 @@ import org.kabiri.android.usbterminal.util.isOfficialArduinoBoard @OptIn(ExperimentalCoroutinesApi::class) internal class MainActivityViewModelTest { - private val testDispatcher: TestDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -40,17 +39,18 @@ internal class MainActivityViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - sut = MainActivityViewModel( - arduinoUseCase = mockArduinoUsecase, - usbUseCase = mockUsbUseCase, - resourceProvider = mockResourceProvider, - ) + sut = + MainActivityViewModel( + arduinoUseCase = mockArduinoUsecase, + usbUseCase = mockUsbUseCase, + resourceProvider = mockResourceProvider, + ) } @After fun cleanUp() { Dispatchers.resetMain() - unmockkStatic("org.kabiri.android.usbterminal.util.UsbDeviceExtensionsKt") + unmockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) clearAllMocks() } @@ -132,4 +132,4 @@ internal class MainActivityViewModelTest { assertThat(sut.errorMessage.value).isEqualTo("") assertThat(sut.output.value).isEqualTo("") } -} \ No newline at end of file +} From 8e985a7ca27d8b8b57168635f45a65e97dc35895 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 18:43:44 +0200 Subject: [PATCH 19/60] refactor: move USB device observation out of the init method --- .../main/java/org/kabiri/android/usbterminal/MainActivity.kt | 3 +++ .../android/usbterminal/viewmodel/MainActivityViewModel.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 a0d5902..41b2725 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt @@ -16,7 +16,9 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.kabiri.android.usbterminal.util.scrollToLastLine @@ -36,6 +38,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + viewModel.startObservingUsbDevice() setContentView(R.layout.activity_main) // avoid system navbar or soft keyboard overlapping the content. 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 3db4ca3..1a8069f 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 @@ -49,7 +49,7 @@ internal class MainActivityViewModel val output2 = SnapshotStateList() - init { + internal fun startObservingUsbDevice() { // Subscribe to USB device changes. viewModelScope.launch { usbUseCase.usbDevice.collect { device -> From 9416aa14b345ef5f18bc554d1f20e7ae56bb5b4e Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 18:46:08 +0200 Subject: [PATCH 20/60] refactor(tests): use runTest block --- .../viewmodel/MainActivityViewModelTest.kt | 137 +++++++++--------- 1 file changed, 70 insertions(+), 67 deletions(-) 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 index 4dd0c8d..f6bde89 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -28,7 +28,6 @@ import org.kabiri.android.usbterminal.util.isOfficialArduinoBoard @OptIn(ExperimentalCoroutinesApi::class) internal class MainActivityViewModelTest { private val testDispatcher: TestDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) val mockArduinoUsecase: IArduinoUseCase = mockk(relaxed = true) val mockUsbUseCase: IUsbUseCase = mockk(relaxed = true) @@ -55,81 +54,85 @@ internal class MainActivityViewModelTest { } @Test - fun `connect emits expected message when device list is empty`() { - // 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 + 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() + // act + sut.connect() - // assert - assertThat(sut.errorMessage.value).isEqualTo(expected) - } + // assert + assertThat(sut.errorMessage.value).isEqualTo(expected) + } @Test - fun `connect emits expected message and calls requestPermission anyways when the device is unknown`() { - // arrange - val expectedError = "device not found" - val expectedInfo = "connecting anyways" - - val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns false - every { fakeDevice.isCloneArduinoBoard() } returns false - - 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) } - } + 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) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns false + every { fakeDevice.isCloneArduinoBoard() } returns false + + 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`() { - // arrange - val expected = "connecting anyways" + 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) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns false - every { fakeDevice.isCloneArduinoBoard() } returns true + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns false + every { fakeDevice.isCloneArduinoBoard() } returns true - every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) - every { mockResourceProvider.getString(R.string.helper_error_connecting_anyway) } returns expected + every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) + every { mockResourceProvider.getString(R.string.helper_error_connecting_anyway) } returns expected - // act - sut.connect() + // act + sut.connect() - // assert - assertThat(sut.infoMessage.value).isEqualTo(expected) - verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } - } + // assert + assertThat(sut.infoMessage.value).isEqualTo(expected) + verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } + } @Test - fun `connect calls requestPermission when the device is official`() { - // arrange - val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns true - every { fakeDevice.isCloneArduinoBoard() } returns false - - 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("") - } + fun `connect calls requestPermission when the device is official`() = + runTest { + // arrange + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns true + every { fakeDevice.isCloneArduinoBoard() } returns false + + 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("") + } } From 0ed880c09c3361f55cabc2fc2400c508ab380c14 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 18:48:12 +0200 Subject: [PATCH 21/60] tests: make sure connectIfAlreadyHasPermission works as expected --- .../viewmodel/MainActivityViewModelTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index f6bde89..2fe73bb 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -10,8 +10,10 @@ 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 @@ -135,4 +137,38 @@ internal class MainActivityViewModelTest { 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 = 0x0043 + 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) } + } } From 75380829ef2932ae8974d07f287c2182b15a880d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:00:25 +0200 Subject: [PATCH 22/60] tests: make sure startObservingUsbDevice works as expected --- .../viewmodel/MainActivityViewModelTest.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index 2fe73bb..4620264 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -55,6 +55,27 @@ internal class MainActivityViewModelTest { clearAllMocks() } + @Test + fun `startObservingUsbDevice updates infoMessage and calls openDeviceAndPort when device is emitted`() = + runTest { + // arrange + val expected = 0x0043 + 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 `connect emits expected message when device list is empty`() = runTest { From 1416d96f7545c66448188c2b67e88e50dbe67dbb Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:03:52 +0200 Subject: [PATCH 23/60] tests: make sure startObservingUsbDevice works as expected when the device is null --- .../viewmodel/MainActivityViewModelTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 4620264..6335695 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -76,6 +76,23 @@ internal class MainActivityViewModelTest { 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 { From 2e7737a79d486c7ed8d7cb327f521d2190428989 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:05:06 +0200 Subject: [PATCH 24/60] tests(nitpick): standardize comment casing to match the rest of the test names --- .../usbterminal/viewmodel/MainActivityViewModelTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 6335695..ca4feac 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -78,17 +78,17 @@ internal class MainActivityViewModelTest { @Test fun `startObservingUsbDevice does not call openDeviceAndPort when device is null`() = runTest { - // Arrange + // arrange val expected = null val deviceFlow = MutableStateFlow(expected) every { mockUsbUseCase.usbDevice } returns deviceFlow - // Act + // act sut.startObservingUsbDevice() deviceFlow.value = expected advanceUntilIdle() - // Assert + // assert verify(exactly = 0) { mockArduinoUsecase.openDeviceAndPort(any()) } assertThat(sut.infoMessage.value).contains(expected.toString()) } From 2263ad6009096aeae8d143aefdef7b7cbddcbcf5 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:09:30 +0200 Subject: [PATCH 25/60] tests: make sure disconnect and serialWrite methods work as expected --- .../viewmodel/MainActivityViewModelTest.kt | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) 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 index ca4feac..2ee113b 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -77,21 +77,22 @@ internal class MainActivityViewModelTest { } @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 + 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() + // act + sut.startObservingUsbDevice() + deviceFlow.value = expected + advanceUntilIdle() - // assert - verify(exactly = 0) { mockArduinoUsecase.openDeviceAndPort(any()) } - assertThat(sut.infoMessage.value).contains(expected.toString()) - } + // 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`() = @@ -209,4 +210,31 @@ internal class MainActivityViewModelTest { 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) + } } From 8cf9bfc0f0a69b3b8b3f2ac68581d146826ea845 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:37:03 +0200 Subject: [PATCH 26/60] nitpick: reformat the file --- .../usbterminal/MainActivityAndroidTest.kt | 82 +++++++++---------- 1 file changed, 38 insertions(+), 44 deletions(-) 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 bda9688..1100bac 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())) - } + }, ) } } From 24dab40339639f736abcf1d6a20979a850e1fca3 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sun, 20 Jul 2025 19:44:23 +0200 Subject: [PATCH 27/60] nitpick: reformat MainActivity; no changes --- .../android/usbterminal/MainActivity.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) 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 41b2725..2b3f1a4 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt @@ -16,23 +16,18 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle 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() @@ -70,9 +65,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. @@ -86,7 +83,9 @@ class MainActivity : AppCompatActivity() { event.action == KeyEvent.ACTION_DOWN)) { sendCommand() true - } else false + } else { + false + } } } From d0c708799ee9fc618bee2f318542d4131c370c38 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 11:02:46 +0200 Subject: [PATCH 28/60] nitpick(refactor): avoid wildcard imports --- app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 306f01e..0b985ed 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") From 06b4eff5d62381ed777a8b35e5aaa12a8eb5e871 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 11:27:54 +0200 Subject: [PATCH 29/60] chore: hopefully fix jacoco --- app/build.gradle.kts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b985ed..95c73c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -124,7 +124,35 @@ tasks.register("jacocoTestReport") { classDirectories.from(files(javaDebugTree, 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/**/*.*", + "**/Dagger*.*", "**/*_Hilt*.*", "**/*Hilt*.*", + ) + val javaDebugTree = fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) { exclude(fileFilter) } + val kotlinDebugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(fileFilter) } + val mainJavaSrc = layout.projectDirectory.dir("src/main/java") + val mainKotlinSrc = layout.projectDirectory.dir("src/main/kotlin") + sourceDirectories.from(files(mainJavaSrc, mainKotlinSrc)) + classDirectories.from(files(javaDebugTree, kotlinDebugTree)) + executionData.from(fileTree(layout.buildDirectory) { + include( "outputs/managed_device_code_coverage/**/*.ec", "outputs/managed_device_code_coverage/**/*.exec" ) From ce5e28d363d3080bfb4ace7f427b764d2547d4fd Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 12:56:34 +0200 Subject: [PATCH 30/60] chore: exclude MainActivity file from jacoco because it was being shown twice in the test results --- app/build.gradle.kts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95c73c3..af5bcb6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,16 +112,16 @@ tasks.register("jacocoTestReport") { } val fileFilter = listOf( - "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", + "**/R.class", + "**/MainActivity.*", + "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", "**/Dagger*.*", "**/*_Hilt*.*", "**/*Hilt*.*", ) - val javaDebugTree = fileTree(layout.buildDirectory.dir("intermediates/javac/debug/classes")) { exclude(fileFilter) } val kotlinDebugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(fileFilter) } - val mainJavaSrc = layout.projectDirectory.dir("src/main/java") val mainKotlinSrc = layout.projectDirectory.dir("src/main/kotlin") - sourceDirectories.from(files(mainJavaSrc, mainKotlinSrc)) - classDirectories.from(files(javaDebugTree, kotlinDebugTree)) + sourceDirectories.from(files(mainKotlinSrc)) + classDirectories.from(files(kotlinDebugTree)) executionData.from(fileTree(layout.buildDirectory) { include( "outputs/managed_device_code_coverage/**/*.ec", From cc2197c2abbce96fc4a0ad093cfbad5768a06a17 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 13:10:46 +0200 Subject: [PATCH 31/60] tests: make sure all branches are covered for getArduinoType --- .../viewmodel/MainActivityViewModelTest.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 index 2ee113b..78ebcfd 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -27,6 +27,8 @@ 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 = 0x0043 + @OptIn(ExperimentalCoroutinesApi::class) internal class MainActivityViewModelTest { private val testDispatcher: TestDispatcher = StandardTestDispatcher() @@ -59,7 +61,7 @@ internal class MainActivityViewModelTest { fun `startObservingUsbDevice updates infoMessage and calls openDeviceAndPort when device is emitted`() = runTest { // arrange - val expected = 0x0043 + val expected = OFFICIAL_VENDOR_ID val deviceFlow = MutableStateFlow(null) val mockDevice: UsbDevice = mockk(relaxed = true) every { mockDevice.vendorId } returns expected @@ -134,6 +136,28 @@ internal class MainActivityViewModelTest { verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } } + @Test + fun `connect emits expected message and calls requestPermission when the device is neither official nor a clone`() = + runTest { + // arrange + val expected = "connecting anyways" + + val fakeDevice: UsbDevice = mockk(relaxed = true) + mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) + every { fakeDevice.isOfficialArduinoBoard() } returns false + every { fakeDevice.isCloneArduinoBoard() } returns false + + 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 emits expected message and calls requestPermission when the device is a clone`() = runTest { @@ -196,7 +220,7 @@ internal class MainActivityViewModelTest { runTest { // arrange val mockDevice: UsbDevice = mockk(relaxed = true) - val fakeVendorId = 0x0043 + val fakeVendorId = OFFICIAL_VENDOR_ID every { mockDevice.vendorId } returns fakeVendorId every { mockUsbUseCase.usbDevice } returns MutableStateFlow(mockDevice) every { mockUsbUseCase.hasPermission(mockDevice) } returns true From 6bab0ead40ec02cdc2ccd58453b2be2324092647 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 15:06:01 +0200 Subject: [PATCH 32/60] tests: avoid using mockkstatic and return vendorId and productId --- .../viewmodel/MainActivityViewModelTest.kt | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) 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 index 78ebcfd..9746e61 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -27,7 +27,15 @@ 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 = 0x0043 +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 { @@ -119,9 +127,8 @@ internal class MainActivityViewModelTest { val expectedInfo = "connecting anyways" val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns false - every { fakeDevice.isCloneArduinoBoard() } returns false + 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 @@ -136,28 +143,6 @@ internal class MainActivityViewModelTest { verify(exactly = 1) { mockUsbUseCase.requestPermission(fakeDevice) } } - @Test - fun `connect emits expected message and calls requestPermission when the device is neither official nor a clone`() = - runTest { - // arrange - val expected = "connecting anyways" - - val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns false - every { fakeDevice.isCloneArduinoBoard() } returns false - - 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 emits expected message and calls requestPermission when the device is a clone`() = runTest { @@ -165,9 +150,8 @@ internal class MainActivityViewModelTest { val expected = "connecting anyways" val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns false - every { fakeDevice.isCloneArduinoBoard() } returns 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 @@ -185,9 +169,8 @@ internal class MainActivityViewModelTest { runTest { // arrange val fakeDevice: UsbDevice = mockk(relaxed = true) - mockkStatic(UsbDevice::isOfficialArduinoBoard, UsbDevice::isCloneArduinoBoard) - every { fakeDevice.isOfficialArduinoBoard() } returns true - every { fakeDevice.isCloneArduinoBoard() } returns false + every { fakeDevice.vendorId } returns OFFICIAL_VENDOR_ID + every { fakeDevice.productId } returns OFFICIAL_PRODUCT_ID every { mockUsbUseCase.scanForUsbDevices() } returns listOf(fakeDevice) From 3aa15b939fec8e8f19f69a54b59da9c6bc06d761 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 15:15:27 +0200 Subject: [PATCH 33/60] tests: avoid using mockkstatic and return vendorId and productId --- .../viewmodel/MainActivityViewModelTest.kt | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) 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 index 9746e61..a4482fe 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/MainActivityViewModelTest.kt @@ -5,7 +5,6 @@ import com.google.common.truth.Truth.assertThat import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.Dispatchers @@ -33,7 +32,6 @@ 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 @@ -231,17 +229,84 @@ internal class MainActivityViewModelTest { } @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) - } + 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) + } } From 4dce80668a2433a88b7f58b5cd4f8b6b9d9903ce Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 7 Aug 2025 15:47:00 +0200 Subject: [PATCH 34/60] nitpick: fix formatting to match ktlint rules --- .../viewmodel/MainActivityViewModel.kt | 285 +++++++++--------- 1 file changed, 146 insertions(+), 139 deletions(-) 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 1a8069f..1982bdf 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,161 +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() - - 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) } + @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) - // TODO: DROID-17 - Fix hasPermission return value not being used here. - openDeviceAndPort(usbDevice) - } - - fun disconnect() { - usbUseCase.disconnect() - arduinoUseCase.disconnect() - } - - private 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 From 9a4f772e65869fc9624d84c0a60e44c4ff5a87aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:04:59 +0000 Subject: [PATCH 35/60] chore(deps): bump mockk from 1.14.2 to 1.14.5 --- updated-dependencies: - dependency-name: io.mockk:mockk-android dependency-version: 1.14.5 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.mockk:mockk-agent dependency-version: 1.14.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3415d2c..ee95e94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" From dd8bc04e515984aec6d266ff5be686a1feb560a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:05:04 +0000 Subject: [PATCH 36/60] chore(deps): bump androidx.lifecycle:lifecycle-runtime-ktx Bumps androidx.lifecycle:lifecycle-runtime-ktx from 2.9.1 to 2.9.2. --- updated-dependencies: - dependency-name: androidx.lifecycle:lifecycle-runtime-ktx dependency-version: 2.9.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3415d2c..2a1c0b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" From cf2dcd87d70b85eb2a47fb18c3bb831515a96a11 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Thu, 28 Aug 2025 05:09:42 +0200 Subject: [PATCH 37/60] chore(deps): remove unused firebase dependency --- app/build.gradle.kts | 1 - gradle/libs.versions.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index af5bcb6..15c1db3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -253,7 +253,6 @@ dependencies { // Firebase implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics.ktx) implementation(libs.firebase.crashlytics.ktx) // Dependency Injection diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee95e94..f3092bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,7 +70,6 @@ material = { module = "com.google.android.material:material", version.ref = "mat # --- 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 --- From 57bd271d5189069df6506667b36c7f3e2f5267d3 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 11:59:07 +0200 Subject: [PATCH 38/60] feat: add auto scroll switch composable --- .../ui/setting/SettingSwitchItem.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt 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 0000000..b4cf952 --- /dev/null +++ b/app/src/main/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt @@ -0,0 +1,30 @@ +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.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 SettingAutoScrollItem( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = stringResource(id = R.string.settings_label_auto_scroll)) + Switch(checked = enabled, onCheckedChange = onToggle) + } +} From 664b0cda0bc37b4ecccd569d0fc83b66bb292c2d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 12:21:55 +0200 Subject: [PATCH 39/60] tests: add auto scroll setting to getFakeUserSettingRepository --- .../usbterminal/common/FrequentMocks.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 index 141f6b8..40a2fea 100644 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt +++ b/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt @@ -7,9 +7,14 @@ import org.kabiri.android.usbterminal.model.UserSettingPreferences internal fun getFakeUserSettingRepository( onSetBaudRate: () -> Unit = {}, - fakeUserSetting: UserSettingPreferences = UserSettingPreferences(baudRate = 123) -): IUserSettingRepository { - return object: IUserSettingRepository { + onSetAutoScroll: () -> Unit = {}, + fakeUserSetting: UserSettingPreferences = + UserSettingPreferences( + baudRate = 123, + autoScroll = true, + ), +): IUserSettingRepository = + object : IUserSettingRepository { override val preferenceFlow: Flow get() = flowOf(fakeUserSetting) @@ -17,11 +22,11 @@ internal fun getFakeUserSettingRepository( onSetBaudRate() } - override suspend fun clear() {} - - override suspend fun fetchInitialPreferences(): UserSettingPreferences { - return fakeUserSetting + override suspend fun setAutoScroll(enabled: Boolean) { + onSetAutoScroll() } + override suspend fun clear() {} + + override suspend fun fetchInitialPreferences(): UserSettingPreferences = fakeUserSetting } -} \ No newline at end of file From 5c7e9bff1e9903b1a44a68fafd943d46ac6a5ae8 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 12:22:31 +0200 Subject: [PATCH 40/60] res: add auto-scroll terminal output label to settings --- app/src/main/res/values/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3477914..2b64ea5 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 From 4f164cf90fd33e6238a2989cbf56801507002b07 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 12:43:18 +0200 Subject: [PATCH 41/60] feat: add auto scroll preference to UserSettingRepository; move tests from instrumented directory to unit tests and adjust them to use the produce file by TemporaryFolder() rule --- .../UserSettingRepositoryAndroidTest.kt | 91 -------------- .../data/repository/UserSettingRepository.kt | 78 +++++++----- .../repository/UserSettingRepositoryTest.kt | 112 ++++++++++++++++++ 3 files changed, 159 insertions(+), 122 deletions(-) delete mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryAndroidTest.kt create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepositoryTest.kt 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 9057fbe..0000000 --- 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/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt b/app/src/main/java/org/kabiri/android/usbterminal/data/repository/UserSettingRepository.kt index 680e0bb..d1e6f01 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 @@ -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] = defaultBaudRate.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() ?: defaultBaudRate, + 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/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 0000000..210c1bc --- /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) + } +} From be838c1939c48bc17525092b7ff2eae7218ca928 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 12:58:00 +0200 Subject: [PATCH 42/60] feat: implement GetAutoScrollUseCase and its test --- .../domain/GetAutoScrollUseCase.kt | 21 +++++++++ .../domain/GetAutoScrollUseCaseTest.kt | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt 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 0000000..65e4ff6 --- /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 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/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 0000000..04baaaf --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt @@ -0,0 +1,45 @@ +package org.kabiri.android.usbterminal.domain + +import com.google.common.truth.Truth.assertThat +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.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, + ) + } + + @Test + fun testGetAutoScrollUseCaseReturns() = + runTest { + // arrange + val expected = false + mockUserSettingRepository.apply { + coEvery { preferenceFlow } returns + flowOf( + UserSettingPreferences(autoScroll = expected), + ) + } + + // act + val actual = sut().first() + + // assert + assertThat(actual).isEqualTo(expected) + } +} From e61835218d63e27cd1c066bb05d06f165f340198 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:02:52 +0200 Subject: [PATCH 43/60] feat: implement SetAutoScrollUseCase and its test --- .../domain/SetAutoScrollUseCase.kt | 24 ++++++++ .../domain/SetAutoScrollUseCaseTest.kt | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 app/src/main/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCase.kt create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt 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 0000000..cab8a82 --- /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/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 0000000..d0e764a --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt @@ -0,0 +1,59 @@ +package org.kabiri.android.usbterminal.domain + +import com.google.common.truth.Truth.assertThat +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.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, + ) + } + + @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) } + } +} From 6bd22b2c24dc317c4bfd4695ad4c8b88ba3f7f43 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:04:42 +0200 Subject: [PATCH 44/60] tests: enhance GetAutoScrollUseCaseTest with additional assertions for true/false cases --- .../domain/GetAutoScrollUseCaseTest.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index 04baaaf..61d2ba6 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt @@ -25,7 +25,26 @@ internal class GetAutoScrollUseCaseTest { } @Test - fun testGetAutoScrollUseCaseReturns() = + 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 From 18c9e9949f897bf3ff878d9d870977609e6a6967 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:07:15 +0200 Subject: [PATCH 45/60] feat: add providers for GetAutoScrollUseCase and SetAutoScrollUseCase in AppModule --- .../android/usbterminal/di/AppModule.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 766af90..4833d1c 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) + } +} From 6bb57000738fd3906e8a3990f2a6ebf83d35f824 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:08:04 +0200 Subject: [PATCH 46/60] feat: add autoScroll preference to UserSettingPreferences data class --- .../android/usbterminal/model/UserSettingPreferences.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 75637ea..f4f8722 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 @@ -3,5 +3,6 @@ package org.kabiri.android.usbterminal.model internal const val defaultBaudRate: Int = 9600 data class UserSettingPreferences( - val baudRate: Int = defaultBaudRate // Arduino default -) \ No newline at end of file + val baudRate: Int = defaultBaudRate, // Arduino default + val autoScroll: Boolean = true, +) From 795acffcdcb4582fad805ce68312300ae208290d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:10:59 +0200 Subject: [PATCH 47/60] test: add cleanup method --- .../android/usbterminal/domain/GetAutoScrollUseCaseTest.kt | 7 +++++++ .../android/usbterminal/domain/SetAutoScrollUseCaseTest.kt | 7 +++++++ 2 files changed, 14 insertions(+) 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 index 61d2ba6..89b9909 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCaseTest.kt @@ -1,11 +1,13 @@ 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 @@ -24,6 +26,11 @@ internal class GetAutoScrollUseCaseTest { ) } + @After + internal fun cleanUp() { + clearAllMocks() + } + @Test fun `test getAutoScrollUseCase returns true`() = runTest { 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 index d0e764a..afcc431 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetAutoScrollUseCaseTest.kt @@ -1,12 +1,14 @@ 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 @@ -25,6 +27,11 @@ internal class SetAutoScrollUseCaseTest { ) } + @After + internal fun cleanUp() { + clearAllMocks() + } + @Test fun `test setAutoScrollUseCase calls setAutoScroll with true on repository`() = runTest { From 398efae86891cb35f9d3b5da9bf1fffe2a4b4e28 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:29:26 +0200 Subject: [PATCH 48/60] feat: add autoScroll functionality to SettingViewModel and add tests for it --- .../ui/setting/SettingViewModel.kt | 29 +++-- .../viewmodel/SettingViewModelTest.kt | 105 ++++++++++++++++++ 2 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt 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 937b09b..9f82812 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,7 +3,9 @@ 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 javax.inject.Inject @@ -13,17 +15,24 @@ 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(defaultBaudRate) + } } -} \ No newline at end of file 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 0000000..dc566de --- /dev/null +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt @@ -0,0 +1,105 @@ +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) } + } +} From e69868a267c573e798374854ea93dd3bb80b5e7e Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:31:12 +0200 Subject: [PATCH 49/60] feat: add auto-scroll toggle to settings UI --- .../usbterminal/ui/setting/SettingContent.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 94de228..104ac1b 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 @@ -17,16 +17,22 @@ internal fun SettingContent( onDismiss: () -> Unit, ) { val currentBaudRate by settingViewModel.currentBaudRate.collectAsState(defaultBaudRate) + val autoScrollEnabled by settingViewModel.currentAutoScroll.collectAsState(true) Column(modifier = modifier) { - // Settings Header SettingsHeader() + // Auto-scroll Setting + SettingAutoScrollItem( + 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)) } } - From 9ecf7b2bbbffde3f88543a9856a7418b8f0e272d Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:31:20 +0200 Subject: [PATCH 50/60] feat: implement auto-scroll functionality in MainActivity based on settings --- .../java/org/kabiri/android/usbterminal/MainActivity.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 2b3f1a4..c722126 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt @@ -52,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() } } } From cb894e62fada41ee1f0642add0d58085779eec20 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:37:29 +0200 Subject: [PATCH 51/60] feat: set default auto-scroll value when the user clicks on default button in the settings; replace default values with constants for baud rate and auto-scroll; update tests --- .../data/repository/ArduinoRepository.kt | 4 ++-- .../data/repository/UserSettingRepository.kt | 6 +++--- .../usbterminal/model/UserSettingPreferences.kt | 7 ++++--- .../usbterminal/ui/setting/SettingContent.kt | 4 ++-- .../usbterminal/ui/setting/SettingViewModel.kt | 6 ++++-- .../viewmodel/SettingViewModelTest.kt | 16 ++++++++++++++++ 6 files changed, 31 insertions(+), 12 deletions(-) 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 70ddf55..d05ec02 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 d1e6f01..57fd6cb 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 @@ -12,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 @@ -69,7 +69,7 @@ internal class UserSettingRepository override suspend fun clear() { dataStore.edit { preferences -> - preferences[PreferencesKeys.BAUD_RATE] = defaultBaudRate.toString() + preferences[PreferencesKeys.BAUD_RATE] = DEFAULT_BAUD_RATE.toString() preferences[PreferencesKeys.AUTO_SCROLL] = true } } @@ -78,7 +78,7 @@ internal class UserSettingRepository // Get the user settings from preferences // and convert it to a [UserSettingPreferences] object return UserSettingPreferences( - baudRate = preferences[PreferencesKeys.BAUD_RATE]?.toIntOrNull() ?: defaultBaudRate, + baudRate = preferences[PreferencesKeys.BAUD_RATE]?.toIntOrNull() ?: DEFAULT_BAUD_RATE, autoScroll = preferences[PreferencesKeys.AUTO_SCROLL] ?: true, ) } 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 f4f8722..43403ff 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,8 +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 - val autoScroll: Boolean = true, + 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 104ac1b..d324852 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,7 +16,7 @@ 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) { 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 9f82812..346734d 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 @@ -7,7 +7,8 @@ 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 /** @@ -33,6 +34,7 @@ internal class SettingViewModel fun setAutoScrollEnabled(enabled: Boolean) = setAutoScroll(enabled) fun resetDefault() { - setBaudRate(defaultBaudRate) + setBaudRate(DEFAULT_BAUD_RATE) + setAutoScroll(DEFAULT_AUTO_SCROLL) } } 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 index dc566de..360f0b6 100644 --- a/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt +++ b/app/src/test/java/org/kabiri/android/usbterminal/viewmodel/SettingViewModelTest.kt @@ -102,4 +102,20 @@ internal class SettingViewModelTest { // 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) } + } } From b51a688c0d22d3464c3554df64ee3253d1cd8068 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:46:20 +0200 Subject: [PATCH 52/60] test: move Get/Set-CustomBaudRateUseCase tests to unit tests directory and adjust them to use mockk --- .../GetCustomBaudRateUseCaseAndroidTest.kt | 30 ----------- .../SetCustomBaudRateUseCaseAndroidTest.kt | 33 ------------ .../domain/GetCustomBaudRateUseCaseTest.kt | 52 +++++++++++++++++++ .../domain/SetCustomBaudRateUseCaseTest.kt | 46 ++++++++++++++++ 4 files changed, 98 insertions(+), 63 deletions(-) delete mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseAndroidTest.kt delete mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseAndroidTest.kt create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/domain/GetCustomBaudRateUseCaseTest.kt create mode 100644 app/src/test/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseTest.kt 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 82a33ac..0000000 --- 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 6bfb814..0000000 --- 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/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 0000000..a4563dd --- /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/SetCustomBaudRateUseCaseTest.kt b/app/src/test/java/org/kabiri/android/usbterminal/domain/SetCustomBaudRateUseCaseTest.kt new file mode 100644 index 0000000..a3d409d --- /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) } + } +} From db7935ec8ade1388e66996e7585910e47e29f498 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 13:46:43 +0200 Subject: [PATCH 53/60] refactor: remove FrequentMocks.kt --- .../usbterminal/common/FrequentMocks.kt | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt 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 40a2fea..0000000 --- a/app/src/androidTest/java/org/kabiri/android/usbterminal/common/FrequentMocks.kt +++ /dev/null @@ -1,32 +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 = {}, - onSetAutoScroll: () -> Unit = {}, - fakeUserSetting: UserSettingPreferences = - UserSettingPreferences( - baudRate = 123, - autoScroll = true, - ), -): IUserSettingRepository = - object : IUserSettingRepository { - override val preferenceFlow: Flow - get() = flowOf(fakeUserSetting) - - override suspend fun setBaudRate(baudRate: Int) { - onSetBaudRate() - } - - override suspend fun setAutoScroll(enabled: Boolean) { - onSetAutoScroll() - } - - override suspend fun clear() {} - - override suspend fun fetchInitialPreferences(): UserSettingPreferences = fakeUserSetting - } From 5929df85299d46935b97bd64298326f4c84621e5 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 14:07:13 +0200 Subject: [PATCH 54/60] test: add Compose tests for SettingContent to verify displayed texts --- app/build.gradle.kts | 2 + .../ui/setting/SettingContentAndroidTest.kt | 76 +++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 80 insertions(+) create mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingContentAndroidTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 15c1db3..0fe44de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -291,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/ui/setting/SettingContentAndroidTest.kt b/app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingContentAndroidTest.kt new file mode 100644 index 0000000..4bda8bc --- /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/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3092bf..e86624f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,8 @@ 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" } From 2b59b2eb7691a558bf207acbe1b2a7e4a83ab47e Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 14:14:32 +0200 Subject: [PATCH 55/60] fix: improve auto-scroll text in settings item --- .../usbterminal/ui/setting/SettingSwitchItem.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index b4cf952..3752bd0 100644 --- 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 @@ -4,9 +4,11 @@ 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 @@ -16,15 +18,20 @@ import org.kabiri.android.usbterminal.R internal fun SettingAutoScrollItem( enabled: Boolean, onToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, ) { Row( modifier = - 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)) + Text( + text = stringResource(id = R.string.settings_label_auto_scroll), + color = MaterialTheme.colorScheme.onPrimary, + ) Switch(checked = enabled, onCheckedChange = onToggle) } } From 14945c36fd8ba95cb5d0cb85540f57172239ffe7 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 14:32:17 +0200 Subject: [PATCH 56/60] refactor: rename auto-scroll setting component to SettingSwitchItem for clarity --- .../org/kabiri/android/usbterminal/ui/setting/SettingContent.kt | 2 +- .../kabiri/android/usbterminal/ui/setting/SettingSwitchItem.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 d324852..1c5bdb9 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 @@ -24,7 +24,7 @@ internal fun SettingContent( SettingsHeader() // Auto-scroll Setting - SettingAutoScrollItem( + SettingSwitchItem( enabled = autoScrollEnabled, onToggle = settingViewModel::setAutoScrollEnabled, ) 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 index 3752bd0..604a722 100644 --- 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 @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp import org.kabiri.android.usbterminal.R @Composable -internal fun SettingAutoScrollItem( +internal fun SettingSwitchItem( enabled: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, From e8450f36bdfec9468d5b282605dd7f6dd7a61977 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 14:40:21 +0200 Subject: [PATCH 57/60] tests: add Android Compose tests for SettingSwitchItem functionality --- .../setting/SettingSwitchItemAndroidTest.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 app/src/androidTest/java/org/kabiri/android/usbterminal/ui/setting/SettingSwitchItemAndroidTest.kt 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 0000000..2a95638 --- /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() + } +} From 90dd52852e0c89b0dc8b2e49aba927faabe7df42 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 14:50:14 +0200 Subject: [PATCH 58/60] refactor: change interface declaration to a fun interface for GetAutoScrollUseCase --- .../kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 65e4ff6..e8a3366 100644 --- a/app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt +++ b/app/src/main/java/org/kabiri/android/usbterminal/domain/GetAutoScrollUseCase.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.map import org.kabiri.android.usbterminal.data.repository.IUserSettingRepository import javax.inject.Inject -internal interface IGetAutoScrollUseCase { +internal fun interface IGetAutoScrollUseCase { operator fun invoke(): Flow } From f4eb19aac2407957b893c0a22685224b0ec02654 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 15:11:42 +0200 Subject: [PATCH 59/60] chore: stop tracking IDE files .idea/androidTestResultsUserPreferences.xml and .idea/codeStyles/Project.xml --- .idea/androidTestResultsUserPreferences.xml | 144 -------------------- .idea/codeStyles/Project.xml | 122 ----------------- 2 files changed, 266 deletions(-) delete mode 100644 .idea/androidTestResultsUserPreferences.xml delete mode 100644 .idea/codeStyles/Project.xml diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml deleted file mode 100644 index 87ee7a8..0000000 --- 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 88ea3aa..0000000 --- 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 From 4cbb39fe9dc61aba233d76d2b5c51fb03dff4303 Mon Sep 17 00:00:00 2001 From: Ali Kabiri Date: Sat, 30 Aug 2025 15:44:47 +0200 Subject: [PATCH 60/60] chore: bump version code and version number --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0fe44de..6499738 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,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" }