Initial public release.
This commit is contained in:
parent
ed31acd60f
commit
58d87b11b7
249 changed files with 15831 additions and 4 deletions
132
subapp-terminal/README.md
Normal file
132
subapp-terminal/README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# KTag Terminal Subapp
|
||||
|
||||
A Jetpack Compose Android application providing a serial terminal for USB-connected KTag devices.
|
||||
|
||||
## Overview
|
||||
|
||||
The Terminal app connects to KTag devices via USB serial, allowing you to:
|
||||
|
||||
- Send commands to the device
|
||||
- View debug output and responses
|
||||
- Monitor device state in real-time
|
||||
- Share terminal logs for debugging
|
||||
|
||||
This is primarily a development and debugging tool for working with KTag firmware.
|
||||
|
||||
## Architecture
|
||||
|
||||
The app follows a simple architecture with a manager class handling USB serial communication.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TerminalActivity │
|
||||
│ (Compose Host) │
|
||||
│ • Device selection dropdown │
|
||||
│ • Terminal output display │
|
||||
│ • Command input field │
|
||||
└─────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────┐
|
||||
│ UsbSerialManager │
|
||||
│ • USB device detection & permissions │
|
||||
│ • Serial port connection management │
|
||||
│ • Read/Write operations │
|
||||
│ • Connection state callbacks │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/main/java/club/clubk/ktag/apps/terminal/
|
||||
├── TerminalActivity.kt # Main terminal UI
|
||||
├── UsbSerialManager.kt # USB serial communication
|
||||
├── TerminalSubApp.kt # Subapp registration
|
||||
└── TerminalInitializer.kt
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Terminal Display
|
||||
|
||||
- **Monospace font**: Proper terminal appearance
|
||||
- **ANSI color support**: Full 256-color parsing
|
||||
- **Directional indicators**:
|
||||
- `>>` prefix for sent commands (blue)
|
||||
- `--` prefix for system messages (gray)
|
||||
- No prefix for received data (light gray)
|
||||
- **Auto-scroll**: Follows new output
|
||||
- **Line buffering**: Properly handles `\r\n` line endings
|
||||
- **10,000 line limit**: Prevents memory issues
|
||||
|
||||
### Device Connection
|
||||
|
||||
- **Auto-detection**: Lists available USB serial devices
|
||||
- **Hot-plug support**: Detects connect/disconnect events
|
||||
- **Permission handling**: Automatic USB permission requests
|
||||
- **Driver support**: Works with common USB-serial chips via usb-serial-for-android
|
||||
|
||||
### Actions
|
||||
|
||||
- **Send**: Transmit commands with Enter key or send button
|
||||
- **Clear**: Reset terminal history
|
||||
- **Share**: Export terminal log as text
|
||||
- **Refresh**: Re-scan for USB devices
|
||||
|
||||
## Key Components
|
||||
|
||||
### TerminalActivity
|
||||
|
||||
Main Compose screen containing:
|
||||
|
||||
- `TopAppBar` with refresh, share, and clear actions
|
||||
- Device selection `DropdownMenu`
|
||||
- Connect/Disconnect `Button`
|
||||
- `LazyColumn` terminal display with `TerminalLineRow`
|
||||
- `OutlinedTextField` for command input
|
||||
|
||||
### UsbSerialManager
|
||||
|
||||
Handles all USB serial operations:
|
||||
|
||||
```kotlin
|
||||
interface Listener {
|
||||
fun onDataReceived(data: ByteArray)
|
||||
fun onConnectionStateChanged(state: ConnectionState)
|
||||
fun onError(message: String)
|
||||
fun onDevicesChanged(devices: List<UsbSerialDriver>)
|
||||
}
|
||||
|
||||
enum class ConnectionState {
|
||||
DISCONNECTED,
|
||||
AWAITING_PERMISSION,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
ERROR
|
||||
}
|
||||
```
|
||||
|
||||
### ANSI Color Parsing
|
||||
|
||||
The `parseAnsi()` function supports:
|
||||
|
||||
- Standard colors (30-37, 40-47)
|
||||
- Bright colors (90-97)
|
||||
- 256-color mode (38;5;n, 48;5;n)
|
||||
- Bold, italic, underline, dim styles
|
||||
|
||||
## Serial Settings
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Baud Rate | 115200 |
|
||||
| Data Bits | 8 |
|
||||
| Stop Bits | 1 |
|
||||
| Parity | None |
|
||||
| Line Ending | `\r\n` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Jetpack Compose (Material3)
|
||||
- usb-serial-for-android (USB serial drivers)
|
||||
- Material Icons
|
||||
44
subapp-terminal/build.gradle.kts
Normal file
44
subapp-terminal/build.gradle.kts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "club.clubk.ktag.apps.terminal"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.startup)
|
||||
|
||||
// USB Serial library
|
||||
implementation("com.github.mik3y:usb-serial-for-android:3.10.0")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
32
subapp-terminal/src/main/AndroidManifest.xml
Normal file
32
subapp-terminal/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.usb.host"
|
||||
android:required="true" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".TerminalActivity"
|
||||
android:exported="true"
|
||||
android:label="KTag Terminal"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||
android:resource="@xml/device_filter" />
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="club.clubk.ktag.apps.terminal.TerminalInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
package club.clubk.ktag.apps.terminal
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import club.clubk.ktag.apps.core.UsbSerialManager
|
||||
import club.clubk.ktag.apps.terminal.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.terminal.ui.theme.KTagYellow
|
||||
import club.clubk.ktag.apps.terminal.ui.theme.TerminalTheme
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val TerminalFontFamily = FontFamily(
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||
Font(R.font.jetbrains_mono_bold, FontWeight.Bold)
|
||||
)
|
||||
|
||||
class TerminalActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TerminalTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class TerminalLine(
|
||||
val timestamp: Long,
|
||||
val direction: Direction,
|
||||
val content: String
|
||||
) {
|
||||
enum class Direction { RX, TX, SYSTEM }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TerminalScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var connectionState by remember { mutableStateOf(UsbSerialManager.ConnectionState.DISCONNECTED) }
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var availableDevices by remember { mutableStateOf<List<UsbSerialDriver>>(emptyList()) }
|
||||
var selectedDevice by remember { mutableStateOf<UsbSerialDriver?>(null) }
|
||||
var showDeviceMenu by remember { mutableStateOf(false) }
|
||||
val terminalLines = remember { mutableStateListOf<TerminalLine>() }
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
val rxBuffer = remember { StringBuilder() }
|
||||
|
||||
val usbSerialManager = remember {
|
||||
UsbSerialManager(context, object : UsbSerialManager.Listener {
|
||||
override fun onDataReceived(data: ByteArray) {
|
||||
val text = String(data, Charsets.UTF_8)
|
||||
mainHandler.post {
|
||||
rxBuffer.append(text)
|
||||
// Split on newlines and emit complete lines
|
||||
var newlineIndex: Int
|
||||
while (rxBuffer.indexOf('\n').also { newlineIndex = it } != -1) {
|
||||
var lineEnd = newlineIndex
|
||||
// Handle \r\n by stripping the \r as well
|
||||
if (lineEnd > 0 && rxBuffer[lineEnd - 1] == '\r') {
|
||||
lineEnd--
|
||||
}
|
||||
val lineContent = rxBuffer.substring(0, lineEnd)
|
||||
rxBuffer.delete(0, newlineIndex + 1)
|
||||
terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.RX, lineContent))
|
||||
}
|
||||
// Limit buffer size
|
||||
while (terminalLines.size > MAX_LINES) {
|
||||
terminalLines.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionStateChanged(state: UsbSerialManager.ConnectionState) {
|
||||
mainHandler.post {
|
||||
connectionState = state
|
||||
if (state == UsbSerialManager.ConnectionState.CONNECTED) {
|
||||
rxBuffer.clear()
|
||||
terminalLines.add(
|
||||
TerminalLine(
|
||||
System.currentTimeMillis(),
|
||||
TerminalLine.Direction.SYSTEM,
|
||||
"Connected to ${selectedDevice?.device?.deviceName ?: "device"}"
|
||||
)
|
||||
)
|
||||
} else if (state == UsbSerialManager.ConnectionState.DISCONNECTED) {
|
||||
// Flush any remaining buffered data
|
||||
if (rxBuffer.isNotEmpty()) {
|
||||
terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.RX, rxBuffer.toString()))
|
||||
rxBuffer.clear()
|
||||
}
|
||||
terminalLines.add(
|
||||
TerminalLine(
|
||||
System.currentTimeMillis(),
|
||||
TerminalLine.Direction.SYSTEM,
|
||||
"Disconnected"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
mainHandler.post {
|
||||
errorMessage = message
|
||||
terminalLines.add(
|
||||
TerminalLine(
|
||||
System.currentTimeMillis(),
|
||||
TerminalLine.Direction.SYSTEM,
|
||||
"Error: $message"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDevicesChanged(devices: List<UsbSerialDriver>) {
|
||||
mainHandler.post {
|
||||
availableDevices = devices
|
||||
if (selectedDevice != null && devices.none { it.device.deviceId == selectedDevice?.device?.deviceId }) {
|
||||
selectedDevice = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAutoConnecting(driver: UsbSerialDriver) {
|
||||
mainHandler.post {
|
||||
selectedDevice = driver
|
||||
}
|
||||
}
|
||||
}, actionUsbPermission = "club.clubk.ktag.apps.terminal.USB_PERMISSION")
|
||||
}
|
||||
|
||||
DisposableEffect(usbSerialManager) {
|
||||
usbSerialManager.register()
|
||||
onDispose {
|
||||
usbSerialManager.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when new lines are added
|
||||
LaunchedEffect(terminalLines.size) {
|
||||
if (terminalLines.isNotEmpty()) {
|
||||
listState.animateScrollToItem(terminalLines.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendCommand() {
|
||||
if (inputText.isNotBlank() && connectionState == UsbSerialManager.ConnectionState.CONNECTED) {
|
||||
val command = inputText
|
||||
terminalLines.add(TerminalLine(System.currentTimeMillis(), TerminalLine.Direction.TX, command))
|
||||
usbSerialManager.write((command + "\r\n").toByteArray())
|
||||
inputText = ""
|
||||
// Limit buffer size
|
||||
while (terminalLines.size > MAX_LINES) {
|
||||
terminalLines.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("KTag Terminal") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = KTagDarkGray,
|
||||
titleContentColor = KTagYellow,
|
||||
actionIconContentColor = KTagYellow
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = { usbSerialManager.refreshDevices() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh devices")
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
val text = terminalLines.joinToString("\n") { line ->
|
||||
val prefix = when (line.direction) {
|
||||
TerminalLine.Direction.RX -> ""
|
||||
TerminalLine.Direction.TX -> ">> "
|
||||
TerminalLine.Direction.SYSTEM -> "-- "
|
||||
}
|
||||
prefix + stripAnsi(line.content)
|
||||
}
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
context.startActivity(shareIntent)
|
||||
},
|
||||
enabled = terminalLines.isNotEmpty()
|
||||
) {
|
||||
Icon(Icons.Default.Share, contentDescription = "Share terminal history")
|
||||
}
|
||||
IconButton(onClick = { terminalLines.clear() }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear terminal")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.imePadding()
|
||||
) {
|
||||
// Connection controls
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(
|
||||
onClick = { showDeviceMenu = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = selectedDevice?.device?.deviceName
|
||||
?: if (availableDevices.isEmpty()) "No devices" else "Select device",
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDeviceMenu,
|
||||
onDismissRequest = { showDeviceMenu = false }
|
||||
) {
|
||||
if (availableDevices.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No USB serial devices found") },
|
||||
onClick = { showDeviceMenu = false }
|
||||
)
|
||||
} else {
|
||||
availableDevices.forEach { driver ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("${driver.device.deviceName} (${driver.javaClass.simpleName.removeSuffix("Driver")})")
|
||||
},
|
||||
onClick = {
|
||||
selectedDevice = driver
|
||||
showDeviceMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (connectionState) {
|
||||
UsbSerialManager.ConnectionState.CONNECTED -> {
|
||||
usbSerialManager.disconnect()
|
||||
}
|
||||
UsbSerialManager.ConnectionState.DISCONNECTED,
|
||||
UsbSerialManager.ConnectionState.ERROR -> {
|
||||
selectedDevice?.let { usbSerialManager.connect(it) }
|
||||
}
|
||||
else -> { /* Connecting/awaiting permission - do nothing */ }
|
||||
}
|
||||
},
|
||||
enabled = selectedDevice != null &&
|
||||
connectionState != UsbSerialManager.ConnectionState.CONNECTING &&
|
||||
connectionState != UsbSerialManager.ConnectionState.AWAITING_PERMISSION,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (connectionState == UsbSerialManager.ConnectionState.CONNECTED)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
when (connectionState) {
|
||||
UsbSerialManager.ConnectionState.CONNECTED -> "Disconnect"
|
||||
UsbSerialManager.ConnectionState.CONNECTING -> "Connecting..."
|
||||
UsbSerialManager.ConnectionState.AWAITING_PERMISSION -> "Requesting..."
|
||||
else -> "Connect"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal display
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF323031))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
items(terminalLines) { line ->
|
||||
TerminalLineRow(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Input area
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Enter command...", fontFamily = TerminalFontFamily) },
|
||||
singleLine = true,
|
||||
enabled = connectionState == UsbSerialManager.ConnectionState.CONNECTED,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { sendCommand() }),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = TerminalFontFamily)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { sendCommand() },
|
||||
enabled = connectionState == UsbSerialManager.ConnectionState.CONNECTED && inputText.isNotBlank()
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TerminalLineRow(line: TerminalLine) {
|
||||
val (prefix, defaultColor) = when (line.direction) {
|
||||
TerminalLine.Direction.RX -> "" to Color(0xFFCCCCCC) // Light gray for RX
|
||||
TerminalLine.Direction.TX -> ">> " to Color(0xFF4D6CFA) // KTag Blue for TX
|
||||
TerminalLine.Direction.SYSTEM -> "-- " to Color(0xFF808080) // Gray for system
|
||||
}
|
||||
|
||||
val styledText = if (line.direction == TerminalLine.Direction.RX) {
|
||||
parseAnsi(line.content, defaultColor)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
pushStyle(SpanStyle(color = defaultColor))
|
||||
append(prefix)
|
||||
append(line.content)
|
||||
pop()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = styledText,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = TerminalFontFamily,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAnsi(text: String, defaultColor: Color): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
var currentColor = defaultColor
|
||||
var currentBgColor: Color? = null
|
||||
var isBold = false
|
||||
var isItalic = false
|
||||
var isUnderline = false
|
||||
var isDim = false
|
||||
|
||||
val ansiPattern = Regex("\u001B\\[([0-9;]*)m")
|
||||
var lastEnd = 0
|
||||
|
||||
ansiPattern.findAll(text).forEach { match ->
|
||||
// Append text before this escape sequence
|
||||
if (match.range.first > lastEnd) {
|
||||
pushStyle(SpanStyle(
|
||||
color = if (isDim) currentColor.copy(alpha = 0.5f) else currentColor,
|
||||
background = currentBgColor ?: Color.Transparent,
|
||||
fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = if (isUnderline) TextDecoration.Underline else TextDecoration.None
|
||||
))
|
||||
append(text.substring(lastEnd, match.range.first))
|
||||
pop()
|
||||
}
|
||||
lastEnd = match.range.last + 1
|
||||
|
||||
// Parse the escape codes
|
||||
val codes = match.groupValues[1].split(";").mapNotNull { it.toIntOrNull() }
|
||||
if (codes.isEmpty()) {
|
||||
// Reset
|
||||
currentColor = defaultColor
|
||||
currentBgColor = null
|
||||
isBold = false
|
||||
isItalic = false
|
||||
isUnderline = false
|
||||
isDim = false
|
||||
} else {
|
||||
var i = 0
|
||||
while (i < codes.size) {
|
||||
when (codes[i]) {
|
||||
0 -> {
|
||||
currentColor = defaultColor
|
||||
currentBgColor = null
|
||||
isBold = false
|
||||
isItalic = false
|
||||
isUnderline = false
|
||||
isDim = false
|
||||
}
|
||||
1 -> isBold = true
|
||||
2 -> isDim = true
|
||||
3 -> isItalic = true
|
||||
4 -> isUnderline = true
|
||||
22 -> { isBold = false; isDim = false }
|
||||
23 -> isItalic = false
|
||||
24 -> isUnderline = false
|
||||
// Standard foreground colors (30-37)
|
||||
30 -> currentColor = Color(0xFF000000) // Black
|
||||
31 -> currentColor = Color(0xFFCD3131) // Red
|
||||
32 -> currentColor = Color(0xFF0DBC79) // Green
|
||||
33 -> currentColor = Color(0xFFE5E510) // Yellow
|
||||
34 -> currentColor = Color(0xFF2472C8) // Blue
|
||||
35 -> currentColor = Color(0xFFBC3FBC) // Magenta
|
||||
36 -> currentColor = Color(0xFF11A8CD) // Cyan
|
||||
37 -> currentColor = Color(0xFFE5E5E5) // White
|
||||
39 -> currentColor = defaultColor // Default foreground
|
||||
// Bright foreground colors (90-97)
|
||||
90 -> currentColor = Color(0xFF666666) // Bright black (gray)
|
||||
91 -> currentColor = Color(0xFFF14C4C) // Bright red
|
||||
92 -> currentColor = Color(0xFF23D18B) // Bright green
|
||||
93 -> currentColor = Color(0xFFF5F543) // Bright yellow
|
||||
94 -> currentColor = Color(0xFF3B8EEA) // Bright blue
|
||||
95 -> currentColor = Color(0xFFD670D6) // Bright magenta
|
||||
96 -> currentColor = Color(0xFF29B8DB) // Bright cyan
|
||||
97 -> currentColor = Color(0xFFFFFFFF) // Bright white
|
||||
// Standard background colors (40-47)
|
||||
40 -> currentBgColor = Color(0xFF000000)
|
||||
41 -> currentBgColor = Color(0xFFCD3131)
|
||||
42 -> currentBgColor = Color(0xFF0DBC79)
|
||||
43 -> currentBgColor = Color(0xFFE5E510)
|
||||
44 -> currentBgColor = Color(0xFF2472C8)
|
||||
45 -> currentBgColor = Color(0xFFBC3FBC)
|
||||
46 -> currentBgColor = Color(0xFF11A8CD)
|
||||
47 -> currentBgColor = Color(0xFFE5E5E5)
|
||||
49 -> currentBgColor = null // Default background
|
||||
// 256-color mode (38;5;n or 48;5;n)
|
||||
38 -> {
|
||||
if (i + 2 < codes.size && codes[i + 1] == 5) {
|
||||
currentColor = ansi256ToColor(codes[i + 2], defaultColor)
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
48 -> {
|
||||
if (i + 2 < codes.size && codes[i + 1] == 5) {
|
||||
currentBgColor = ansi256ToColor(codes[i + 2], defaultColor)
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append remaining text
|
||||
if (lastEnd < text.length) {
|
||||
pushStyle(SpanStyle(
|
||||
color = if (isDim) currentColor.copy(alpha = 0.5f) else currentColor,
|
||||
background = currentBgColor ?: Color.Transparent,
|
||||
fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal,
|
||||
fontStyle = if (isItalic) FontStyle.Italic else FontStyle.Normal,
|
||||
textDecoration = if (isUnderline) TextDecoration.Underline else TextDecoration.None
|
||||
))
|
||||
append(text.substring(lastEnd))
|
||||
pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ansi256ToColor(code: Int, defaultColor: Color): Color {
|
||||
return when {
|
||||
code < 16 -> {
|
||||
// Standard colors
|
||||
val standardColors = listOf(
|
||||
Color(0xFF000000), Color(0xFFCD3131), Color(0xFF0DBC79), Color(0xFFE5E510),
|
||||
Color(0xFF2472C8), Color(0xFFBC3FBC), Color(0xFF11A8CD), Color(0xFFE5E5E5),
|
||||
Color(0xFF666666), Color(0xFFF14C4C), Color(0xFF23D18B), Color(0xFFF5F543),
|
||||
Color(0xFF3B8EEA), Color(0xFFD670D6), Color(0xFF29B8DB), Color(0xFFFFFFFF)
|
||||
)
|
||||
standardColors.getOrElse(code) { defaultColor }
|
||||
}
|
||||
code < 232 -> {
|
||||
// 216-color cube (6x6x6)
|
||||
val index = code - 16
|
||||
val r = (index / 36) * 51
|
||||
val g = ((index / 6) % 6) * 51
|
||||
val b = (index % 6) * 51
|
||||
Color(0xFF000000 or (r shl 16).toLong() or (g shl 8).toLong() or b.toLong())
|
||||
}
|
||||
else -> {
|
||||
// Grayscale (24 shades)
|
||||
val gray = (code - 232) * 10 + 8
|
||||
Color(0xFF000000 or (gray shl 16).toLong() or (gray shl 8).toLong() or gray.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripAnsi(text: String): String {
|
||||
return text.replace(Regex("\u001B\\[[0-9;]*m"), "")
|
||||
}
|
||||
|
||||
private const val MAX_LINES = 10000
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.terminal
|
||||
|
||||
import android.content.Context
|
||||
import androidx.startup.Initializer
|
||||
import club.clubk.ktag.apps.core.SubAppRegistry
|
||||
|
||||
class TerminalInitializer : Initializer<Unit> {
|
||||
override fun create(context: Context) {
|
||||
SubAppRegistry.register(TerminalSubApp())
|
||||
}
|
||||
|
||||
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package club.clubk.ktag.apps.terminal
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import club.clubk.ktag.apps.core.SubApp
|
||||
|
||||
class TerminalSubApp : SubApp {
|
||||
override val id = "terminal"
|
||||
override val name = "Terminal"
|
||||
override val icon = R.drawable.ic_terminal
|
||||
|
||||
override fun createIntent(context: Context): Intent {
|
||||
return Intent(context, TerminalActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package club.clubk.ktag.apps.terminal.ui.theme
|
||||
|
||||
// Re-export KTag colors used directly in TerminalActivity
|
||||
val KTagDarkGray = club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
val KTagYellow = club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
// Terminal color aliases for clarity in module-specific code
|
||||
val TerminalGreen = club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
val TerminalBlue = club.clubk.ktag.apps.core.ui.theme.KTagBlue
|
||||
val TerminalRed = club.clubk.ktag.apps.core.ui.theme.KTagRed
|
||||
val TerminalYellow = club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
val TerminalPurple = club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
val TerminalDarkGray = club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package club.clubk.ktag.apps.terminal.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagDarkGray
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagGreen
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagPurple
|
||||
import club.clubk.ktag.apps.core.ui.theme.KTagYellow
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = KTagYellow,
|
||||
onPrimary = KTagDarkGray,
|
||||
secondary = KTagGreen,
|
||||
onSecondary = KTagDarkGray,
|
||||
tertiary = KTagPurple
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TerminalTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
BIN
subapp-terminal/src/main/res/drawable/ic_terminal.png
Normal file
BIN
subapp-terminal/src/main/res/drawable/ic_terminal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 908 KiB |
18
subapp-terminal/src/main/res/font/jetbrains_mono.xml
Normal file
18
subapp-terminal/src/main/res/font/jetbrains_mono.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<font-family xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<font
|
||||
android:font="@font/jetbrains_mono_regular"
|
||||
android:fontStyle="normal"
|
||||
android:fontWeight="400"
|
||||
app:font="@font/jetbrains_mono_regular"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="400" />
|
||||
<font
|
||||
android:font="@font/jetbrains_mono_bold"
|
||||
android:fontStyle="normal"
|
||||
android:fontWeight="700"
|
||||
app:font="@font/jetbrains_mono_bold"
|
||||
app:fontStyle="normal"
|
||||
app:fontWeight="700" />
|
||||
</font-family>
|
||||
BIN
subapp-terminal/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
BIN
subapp-terminal/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
Binary file not shown.
BIN
subapp-terminal/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
BIN
subapp-terminal/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
Binary file not shown.
15
subapp-terminal/src/main/res/xml/device_filter.xml
Normal file
15
subapp-terminal/src/main/res/xml/device_filter.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- FTDI -->
|
||||
<usb-device vendor-id="1027" />
|
||||
<!-- Prolific PL2303 -->
|
||||
<usb-device vendor-id="1659" />
|
||||
<!-- Silabs CP210x -->
|
||||
<usb-device vendor-id="4292" />
|
||||
<!-- Qinheng CH340/CH341 -->
|
||||
<usb-device vendor-id="6790" />
|
||||
<!-- Arduino -->
|
||||
<usb-device vendor-id="9025" />
|
||||
<!-- Qinheng CH9102 -->
|
||||
<usb-device vendor-id="6790" product-id="21972" />
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue