Initial public release.

This commit is contained in:
Joe Kearney 2026-03-01 17:03:10 -06:00
parent ed31acd60f
commit 58d87b11b7
249 changed files with 15831 additions and 4 deletions

132
subapp-terminal/README.md Normal file
View 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

View 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)
}
}

View 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>

View file

@ -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

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 KiB

View 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>

View 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>