Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
55b1599
ui: replace TextView with ComposeView for terminal output and use the…
superus8r Aug 31, 2025
a8610d5
ui: start output from the bottom of the LazyColumn for terminal output
superus8r Aug 31, 2025
c4c13b9
refactor: clean up imports in MainActivity
superus8r Aug 31, 2025
69de6da
feature: Allow copying output to clipboard via long click
superus8r Aug 31, 2025
a14b26c
fix: append output2 in serialWrite
superus8r Aug 31, 2025
fdf24e6
refactor: simplify ViewModel instantiation in tests
superus8r Aug 31, 2025
f1a727c
refactor: reformat with ktlint
superus8r Aug 31, 2025
349f968
refactor: remove unnecessary line breaks from string resources
superus8r Aug 31, 2025
30e4040
refactor: remove unnecessary newline characters from error messages a…
superus8r Aug 31, 2025
3561a18
refactor: remove unused output LiveData and related code
superus8r Aug 31, 2025
56cdabc
refactor: implement terminal output observation and cleanup unused ou…
superus8r Aug 31, 2025
70d22fe
refactor: rename output2 to output
superus8r Aug 31, 2025
1238306
tests: adjust output view ID in MainActivityAndroidTest to match the …
superus8r Aug 31, 2025
4509ca5
test: make sure ArduinoRepository works as expected
superus8r Aug 31, 2025
c42c899
test: make compose tests for TerminalOutput component functionality
superus8r Aug 31, 2025
e6d3d04
refactor(tests): enable auto-scroll in TerminalOutput
superus8r Aug 31, 2025
620aeae
chore: include Java source directory in jacoco sourceDirectories
superus8r Aug 31, 2025
1fe8fc8
refactor: remove unused getAppearance function from OutputText
superus8r Aug 31, 2025
75aef1b
tests: add compose tests for TerminalOutput component and clipboard f…
superus8r Aug 31, 2025
f9b8ae1
tests: add compose tests for TerminalOutput component's autoScroll be…
superus8r Aug 31, 2025
c19aaf7
refactor: reformat ArduinoRepository for readability; no changes
superus8r Aug 31, 2025
8c98d31
fix(tests): update TerminalOutput component tests to assert disabled …
superus8r Aug 31, 2025
4a9e095
refactor(tests): remove unused import
superus8r Aug 31, 2025
d6b9df3
Merge pull request #60 from superus8r/feature/DROID-19_AddCopyToClipb…
superus8r Aug 31, 2025
d4c0706
chore: bump version code and version number
superus8r Aug 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ android {
applicationId = "org.kabiri.android.usbterminal"
minSdk = 24
targetSdk = 35
versionCode = System.getenv("CIRCLE_BUILD_NUM")?.toIntOrNull() ?: 17
versionName = "0.9.87${System.getenv("CIRCLE_BUILD_NUM") ?: ""}"
versionCode = System.getenv("CIRCLE_BUILD_NUM")?.toIntOrNull() ?: 18
versionName = "0.9.88${System.getenv("CIRCLE_BUILD_NUM") ?: ""}"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down Expand Up @@ -120,7 +120,8 @@ tasks.register<JacocoReport>("jacocoTestReport") {
)
val kotlinDebugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { exclude(fileFilter) }
val mainKotlinSrc = layout.projectDirectory.dir("src/main/kotlin")
sourceDirectories.from(files(mainKotlinSrc))
val mainJavaSrc = layout.projectDirectory.dir("src/main/java")
sourceDirectories.from(files(mainKotlinSrc, mainJavaSrc))
classDirectories.from(files(kotlinDebugTree))
executionData.from(fileTree(layout.buildDirectory) {
include(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal class MainActivityAndroidTest {
// arrange
// act
// assert
onView(withId(R.id.tvOutput)).check(matches(isDisplayed()))
onView(withId(R.id.composeOutput)).check(matches(isDisplayed()))
onView(withId(R.id.btEnter)).check(matches(isDisplayed()))
onView(withId(R.id.etInput)).check(matches(isDisplayed()))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package org.kabiri.android.usbterminal.ui.terminal

import android.content.ClipboardManager
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.kabiri.android.usbterminal.model.OutputText
import org.kabiri.android.usbterminal.ui.theme.UsbTerminalTheme

@RunWith(AndroidJUnit4::class)
class TerminalOutputAndroidTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@Test
fun terminalOutput_displaysAllLines() {
// arrange
val logs =
mutableStateListOf(
OutputText("Line 1", OutputText.OutputType.TYPE_NORMAL),
OutputText("Error!", OutputText.OutputType.TYPE_ERROR),
OutputText("Info", OutputText.OutputType.TYPE_INFO),
)

// act
composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(logs = logs, autoScroll = false)
}
}

// assert
composeRule.onNodeWithText("Line 1").assertIsDisplayed()
composeRule.onNodeWithText("Error!").assertIsDisplayed()
composeRule.onNodeWithText("Info").assertIsDisplayed()
}

@Test
fun terminalOutput_longPressCopiesAllText() {
// arrange
val context = composeRule.activity
val logs =
mutableStateListOf(
OutputText("A\n", OutputText.OutputType.TYPE_NORMAL),
OutputText("B", OutputText.OutputType.TYPE_NORMAL),
)

composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(logs = logs, autoScroll = true)
}
}

// act: long-press on one of the visible lines (parent handles the gesture)
composeRule.onNodeWithText("B").performTouchInput { longClick() }
composeRule.waitForIdle()

// assert clipboard contains concatenated text
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val copied =
clipboard.primaryClip
?.getItemAt(0)
?.coerceToText(context)
?.toString()
assertThat(copied).isEqualTo("A\nB")
}

@Test
fun terminalOutput_appliesModifierTag() {
// arrange
val logs =
mutableStateListOf(
OutputText("Tagged line", OutputText.OutputType.TYPE_NORMAL),
)

// act
composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(
logs = logs,
autoScroll = false,
modifier = Modifier.testTag("terminal"),
)
}
}

// assert: the tagged node exists and is visible, and the text is displayed
composeRule.onNodeWithTag("terminal").assertExists().assertIsDisplayed()
composeRule.onNodeWithText("Tagged line").assertIsDisplayed()
}

@Test
fun terminalOutput_handlesEmptyLogs_andLongPressCopiesEmpty() {
// arrange
val context = composeRule.activity
val logs = mutableStateListOf<OutputText>()

composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(
logs = logs,
autoScroll = false,
modifier =
Modifier.testTag("terminal"),
)
}
}

// act: long-press the list itself
composeRule.onNodeWithTag("terminal").performTouchInput { longClick() }
composeRule.waitForIdle()

// assert: clipboard should contain empty string
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val copied =
clipboard.primaryClip
?.getItemAt(0)
?.coerceToText(context)
?.toString()
assertThat(copied).isEqualTo("")
}

@Test
fun terminalOutput_autoScrollTrue_withEmptyList_isStable() {
// arrange
val logs = mutableStateListOf<OutputText>()

// act
composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(
logs = logs,
autoScroll = true,
modifier = Modifier.testTag("terminal"),
)
}
}

// assert: composable exists but not enabled when with empty logs and autoScroll enabled
composeRule.onNodeWithTag("terminal").assertExists()
}

@Test
fun terminalOutput_noAutoScroll_append_rendersNewItem() {
// arrange
val logs =
mutableStateListOf(
OutputText("Initial", OutputText.OutputType.TYPE_NORMAL),
)

composeRule.setContent {
UsbTerminalTheme {
TerminalOutput(
logs = logs,
autoScroll = false,
modifier = Modifier.testTag("terminal"),
)
}
}

// act: append a new line while autoScroll is disabled
composeRule.runOnUiThread {
logs.add(OutputText("Next", OutputText.OutputType.TYPE_NORMAL))
}
composeRule.waitForIdle()

// assert: new item is rendered (if condition path with autoScroll=false evaluated)
composeRule.onNodeWithText("Next").assertIsDisplayed()
}
}
37 changes: 14 additions & 23 deletions app/src/main/java/org/kabiri/android/usbterminal/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.kabiri.android.usbterminal

import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
Expand All @@ -11,17 +10,17 @@ import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
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.ui.terminal.TerminalOutput
import org.kabiri.android.usbterminal.ui.theme.UsbTerminalTheme
import org.kabiri.android.usbterminal.viewmodel.MainActivityViewModel

private const val TAG = "MainActivity"
Expand All @@ -34,6 +33,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.startObservingUsbDevice()
viewModel.startObservingTerminalOutput()
setContentView(R.layout.activity_main)

val rootView = findViewById<View>(R.id.root_view)
Expand All @@ -56,26 +56,17 @@ class MainActivity : AppCompatActivity() {
}

val etInput = findViewById<EditText>(R.id.etInput)
val tvOutput = findViewById<TextView>(R.id.tvOutput)
val composeOutput = findViewById<ComposeView>(R.id.composeOutput)
val btEnter = findViewById<Button>(R.id.btEnter)

// 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
if (autoScrollEnabled) scrollToLastLine()
}
// Compose terminal output UI
composeOutput.setContent {
UsbTerminalTheme {
val autoScrollEnabled = settingViewModel.currentAutoScroll.collectAsState(initial = true).value
TerminalOutput(
logs = viewModel.output,
autoScroll = autoScrollEnabled,
)
}
}

Expand Down
Loading
Loading