9 Commits

Author SHA1 Message Date
rudi 2a22a636b6 Add screen ratio fixing
Build and Test a3270 / Build Android APK (push) Successful in 6m49s
Release a3270 / Build & Publish Release (push) Successful in 4m43s
2026-08-21 17:59:41 -04:00
rudi d3c149fe4a Add hardware keyboard support
Build and Test a3270 / Build Android APK (push) Successful in 6m43s
2026-08-21 17:51:42 -04:00
rudi 8c8e1f17db Fix graphics
Release a3270 / Build & Publish Release (push) Successful in 4m39s
Build and Test a3270 / Build Android APK (push) Successful in 6m36s
2026-08-21 16:39:14 -04:00
rudi 17f8cae925 Debugging v0.2 features from j3270
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
Release a3270 / Build & Publish Release (push) Successful in 6m53s
2026-08-21 12:52:29 -04:00
rudi 9bf91f5c49 Add v0.2 features from j3270
Build and Test a3270 / Build Android APK (push) Successful in 4m32s
2026-08-21 02:59:22 -04:00
rudi cf58b5c72c Debug input 2
Build and Test a3270 / Build Android APK (push) Successful in 4m25s
2026-08-21 01:03:14 -04:00
rudi 96ee5ebce1 Debug input
Build and Test a3270 / Build Android APK (push) Successful in 6m43s
2026-08-21 00:30:45 -04:00
rudi 3a7811543d Debug input 2026-08-21 00:28:32 -04:00
rudi c767953c69 Add input buffering
Build and Test a3270 / Build Android APK (push) Successful in 4m35s
2026-08-20 20:21:41 -04:00
19 changed files with 2502 additions and 498 deletions
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+3 -1
View File
@@ -1,7 +1,6 @@
# a3270 # a3270
[![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions) [![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions)
[![Latest Release](https://git.hugfreevikings.wtf/rudi/a3270/badges/release.svg)](https://git.hugfreevikings.wtf/rudi/a3270/releases)
[![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com) [![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE)
@@ -110,3 +109,6 @@ I have contributed no code to this project, it was entirely written by LLMs with
- Claude Opus 4.6 - Claude Opus 4.6
- Gemini 3.1 Pro - Gemini 3.1 Pro
- Gemini 3.7 Flash - Gemini 3.7 Flash
- Gemma4 12B and 26B
- GPT-OSS 120B
- Qwen3 4B and 32B
+12 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "org.pubvm.a3270" applicationId "org.pubvm.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 1 versionCode 4
versionName "0.1.0" versionName "0.1.3"
} }
buildTypes { buildTypes {
@@ -42,6 +42,13 @@ android {
if (findProject(':lib3270j') != null) { if (findProject(':lib3270j') != null) {
project(':lib3270j') { project(':lib3270j') {
apply plugin: 'java-library' apply plugin: 'java-library'
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
java { java {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@@ -62,4 +69,7 @@ dependencies {
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
} }
+1 -1
View File
@@ -42,7 +42,7 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
fi fi
echo "Building debug APK..." echo "Building debug APK..."
./gradlew assembleDebug --no-daemon ./gradlew assembleDebug
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk" APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
if [ -f "$APK_PATH" ]; then if [ -f "$APK_PATH" ]; then
+2 -1
View File
@@ -1,2 +1,3 @@
android.useAndroidX=true android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
+378 -115
View File
@@ -4,75 +4,90 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.Context
import android.view.KeyCharacterMap
import org.lib3270j.protocol.DS3270Constants.*
import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.OiaStatusBar import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import androidx.compose.foundation.layout.size import org.pubvm.a3270.ui.UntrustedCertificateDialog
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object {
init {
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = java.util.logging.Level.ALL
for (h in rootLogger.handlers) {
rootLogger.removeHandler(h)
}
rootLogger.addHandler(object : java.util.logging.Handler() {
override fun publish(record: java.util.logging.LogRecord?) {
if (record == null) return
val msg = record.message ?: ""
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
when {
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
android.util.Log.e(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
android.util.Log.w(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
android.util.Log.i(tag, msg, record.thrown)
else ->
android.util.Log.d(tag, msg, record.thrown)
}
}
override fun flush() {}
override fun close() {}
})
}
}
private val viewModel: TerminalViewModel by viewModels() private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false) private val isShiftPressedState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
} }
} }
setContent { setContent {
MaterialTheme( MaterialTheme(
colorScheme = darkColorScheme( colorScheme = darkColorScheme(
background = Color(0xFF000000), background = Color.Black,
surface = Color(0xFF1E1E1E) surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
) )
) { ) {
MainScreen( MainScreen(
@@ -84,62 +99,280 @@ class MainActivity : ComponentActivity() {
} }
} }
private val log = java.util.logging.Logger.getLogger("a3270-MainActivity")
private var deadKey: Int = 0
private fun getEffectiveUnicodeChar(event: KeyEvent): Int {
val cleanMeta = event.metaState and (
KeyEvent.META_SHIFT_ON or
KeyEvent.META_SHIFT_LEFT_ON or
KeyEvent.META_SHIFT_RIGHT_ON or
KeyEvent.META_CAPS_LOCK_ON
)
// 1. Try getUnicodeChar with cleanMeta
var u = event.getUnicodeChar(cleanMeta)
if (u > 0) return u
// 2. Try unicodeChar property
u = event.unicodeChar
if (u > 0) return u
// 3. Try KeyCharacterMap with cleanMeta
val kcm = event.keyCharacterMap ?: KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
u = kcm.get(event.keyCode, cleanMeta)
if (u > 0) return u
// 4. Try KeyCharacterMap with meta = 0
u = kcm.get(event.keyCode, 0)
if (u > 0) {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (shift) {
val c = u.toChar()
if (c.isLowerCase()) return c.uppercaseChar().code
}
return u
}
// 5. Try displayLabel as fallback
val label = event.displayLabel
if (label >= ' ') {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
return if (shift) label.uppercaseChar().code else label.lowercaseChar().code
}
return 0
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0) val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) { if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift isShiftPressedState.value = shift
} }
// Handle string / multi-character input (e.g. adb input text, barcode scanners, fast text input)
if (event.action == KeyEvent.ACTION_MULTIPLE) {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
val chars = event.characters
if (!chars.isNullOrEmpty()) {
viewModel.typeString(chars)
return true
}
} else {
val unicode = getEffectiveUnicodeChar(event)
if (unicode >= 32) {
val count = maxOf(1, event.repeatCount)
viewModel.typeString(unicode.toChar().toString().repeat(count))
return true
}
}
}
if (event.action == KeyEvent.ACTION_DOWN) { if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode val keyCode = event.keyCode
val ctrl = event.isCtrlPressed || (event.metaState and KeyEvent.META_CTRL_ON != 0)
val alt = event.isAltPressed || (event.metaState and KeyEvent.META_ALT_ON != 0)
// 1. Function Keys (F1..F12 -> PF1..PF12 or PF13..PF24 with Shift or Alt)
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) { if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1 val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift) fNum + 12 else fNum val pfNum = if (shift || alt) fNum + 12 else fNum
val pfAids = intArrayOf( val pfAids = intArrayOf(
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2, AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4, AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6, AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8, AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
) )
viewModel.sendAid(pfAids[pfNum - 1]) viewModel.sendAid(pfAids[pfNum - 1])
if (shift) { if (shift) {
isShiftPressedState.value = false isShiftPressedState.value = false
} }
return true return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { }
// 2. Ctrl Shortcuts
if (ctrl) {
when (keyCode) {
KeyEvent.KEYCODE_V -> {
pasteFromClipboard()
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_Z -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
}
}
// 3. Alt Shortcuts (PA keys, Clear, Reset)
if (alt) {
when (keyCode) {
KeyEvent.KEYCODE_1 -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_2 -> {
viewModel.sendAid(AID_PA2)
return true
}
KeyEvent.KEYCODE_3 -> {
viewModel.sendAid(AID_PA3)
return true
}
KeyEvent.KEYCODE_C -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
}
}
// 4. Shift Shortcuts
if (shift && keyCode == KeyEvent.KEYCODE_INSERT) {
pasteFromClipboard()
return true
}
// 5. Standard Navigation & Special Keys
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.cursorLeft() viewModel.cursorLeft()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) { }
KeyEvent.KEYCODE_DPAD_UP -> {
viewModel.cursorUp() viewModel.cursorUp()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { }
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.cursorDown() viewModel.cursorDown()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { }
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.cursorRight() viewModel.cursorRight()
return true return true
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) { }
KeyEvent.KEYCODE_MOVE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
KeyEvent.KEYCODE_PAGE_UP -> {
viewModel.sendAid(AID_PF7)
return true
}
KeyEvent.KEYCODE_PAGE_DOWN -> {
viewModel.sendAid(AID_PF8)
return true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
viewModel.sendAid(AID_ENTER) viewModel.sendAid(AID_ENTER)
return true return true
} else if (keyCode == KeyEvent.KEYCODE_TAB) { }
if (event.isShiftPressed) { KeyEvent.KEYCODE_TAB -> {
if (shift) {
viewModel.backTab() viewModel.backTab()
isShiftPressedState.value = false
} else { } else {
viewModel.tab() viewModel.tab()
} }
return true return true
} }
KeyEvent.KEYCODE_DEL -> {
viewModel.backspace()
return true
} }
KeyEvent.KEYCODE_FORWARD_DEL -> {
viewModel.deleteChar()
return true
}
KeyEvent.KEYCODE_ESCAPE -> {
if (shift) {
viewModel.sendAid(AID_CLEAR)
isShiftPressedState.value = false
} else {
viewModel.resetKeyboard()
}
return true
}
KeyEvent.KEYCODE_BREAK -> {
viewModel.sendAid(AID_PA1)
return true
}
}
// 6. Alphanumeric & Symbol Character Input
val unicode = getEffectiveUnicodeChar(event)
log.info("dispatchKeyEvent DOWN: keyCode=$keyCode, metaState=${event.metaState}, unicode=$unicode, char='${if (unicode > 0) unicode.toChar() else ' '}'")
if (unicode > 0) {
if ((unicode and KeyCharacterMap.COMBINING_ACCENT) != 0) {
deadKey = unicode and KeyCharacterMap.COMBINING_ACCENT_MASK
return true
}
val charToType = if (deadKey != 0) {
val combined = KeyCharacterMap.getDeadChar(deadKey, unicode)
deadKey = 0
if (combined != 0) combined.toChar() else unicode.toChar()
} else {
unicode.toChar()
}
if (charToType >= ' ') {
log.info("dispatchKeyEvent typing char: '$charToType'")
viewModel.typeString(charToType.toString())
return true
}
} else {
log.info("dispatchKeyEvent unhandled DOWN keyCode=$keyCode, metaState=${event.metaState}")
}
}
if (event.action == KeyEvent.ACTION_UP) {
when (event.keyCode) {
KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> {
// Let super handle modifier tracking
}
else -> {
return true
}
}
}
return super.dispatchKeyEvent(event) return super.dispatchKeyEvent(event)
} }
private fun pasteFromClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
val clip = clipboard?.primaryClip
if (clip != null && clip.itemCount > 0) {
val text = clip.getItemAt(0)?.coerceToText(this)?.toString()
if (!text.isNullOrEmpty()) {
viewModel.pasteString(text)
}
}
}
} }
@Composable @Composable
@@ -159,15 +392,19 @@ fun MainScreen(
val screenVersion by viewModel.screenVersion.collectAsState() val screenVersion by viewModel.screenVersion.collectAsState()
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState() val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
val cursorBlink by viewModel.cursorBlink.collectAsState() val cursorBlink by viewModel.cursorBlink.collectAsState()
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
val verifyCerts by viewModel.verifyCerts.collectAsState()
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
val isTlsActive by viewModel.isTlsActive.collectAsState()
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val keyboardController = LocalSoftwareKeyboardController.current
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -187,7 +424,17 @@ fun MainScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context) val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) { if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName) viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode
)
} }
} }
@@ -210,19 +457,20 @@ fun MainScreen(
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays) // Main 3270 Terminal Screen View
TerminalView( TerminalView(
screenBuffer = screenBuffer, screenBuffer = screenBuffer,
rows = rows, rows = rows,
cols = cols, cols = cols,
cursorAddr = cursorAddr, cursorAddr = cursorAddr,
screenVersion = screenVersion, screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHiddenFields = maskHiddenInput, maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink, blinkCursor = cursorBlink,
onTapAddress = { addr -> onTapAddress = { addr ->
viewModel.setCursor(addr) viewModel.setCursor(addr)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
keyboardController?.show()
}, },
onPasteText = { text -> onPasteText = { text ->
viewModel.pasteString(text) viewModel.pasteString(text)
@@ -230,57 +478,41 @@ fun MainScreen(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Transparent BasicTextField maintaining active Android IME connection // Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
BasicTextField( AndroidView(
value = textFieldValue, factory = { ctx ->
onValueChange = { newValue -> TerminalInputView(ctx).apply {
val text = newValue.text onInputText = { text ->
if (text.isNotEmpty()) { viewModel.typeString(text)
for (ch in text) {
if (ch == '\n' || ch == '\r') {
viewModel.sendAid(AID_ENTER)
} else {
viewModel.typeChar(ch)
}
}
textFieldValue = TextFieldValue("")
onClearShift() onClearShift()
} }
}, onSendAid = { aid ->
textStyle = TextStyle(color = Color.Transparent), viewModel.sendAid(aid)
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.None
),
keyboardActions = KeyboardActions(
onDone = { viewModel.sendAid(AID_ENTER) },
onGo = { viewModel.sendAid(AID_ENTER) },
onSend = { viewModel.sendAid(AID_ENTER) },
onNext = {
// Stubbed for future form field navigation
} }
), onBackspace = {
modifier = Modifier
.size(1.dp)
.focusRequester(focusRequester)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
viewModel.backspace() viewModel.backspace()
onClearShift() onClearShift()
true
} else {
false
} }
onTab = {
viewModel.tab()
} }
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
) )
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24) // Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar( TwoRowKeyBar(
connectionState = connectionState, connectionState = connectionState,
isShiftPressed = isShiftPressed, isShiftPressed = isShiftPressed,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift, onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true }, onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() }, onDisconnectClick = { viewModel.disconnect() },
@@ -288,35 +520,35 @@ fun MainScreen(
onSettingsClick = { showSettingsDialog = true }, onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid -> onSendAid = { aid ->
viewModel.sendAid(aid) viewModel.sendAid(aid)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onReset = { onReset = {
viewModel.resetKeyboard() viewModel.resetKeyboard()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onTab = { onTab = {
viewModel.tab() viewModel.tab()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onBackTab = { onBackTab = {
viewModel.backTab() viewModel.backTab()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorLeft = { onCursorLeft = {
viewModel.cursorLeft() viewModel.cursorLeft()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorUp = { onCursorUp = {
viewModel.cursorUp() viewModel.cursorUp()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorDown = { onCursorDown = {
viewModel.cursorDown() viewModel.cursorDown()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorRight = { onCursorRight = {
viewModel.cursorRight() viewModel.cursorRight()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -328,7 +560,10 @@ fun MainScreen(
oiaText = oiaText, oiaText = oiaText,
cursorAddr = cursorAddr, cursorAddr = cursorAddr,
rows = rows, rows = rows,
cols = cols cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode
) )
} }
} }
@@ -336,19 +571,28 @@ fun MainScreen(
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName -> onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName) viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
} }
) )
} }
if (showFtDialog) { if (showFtDialog) {
FileTransferDialog( FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
onDismiss = { showFtDialog = false }, onDismiss = { showFtDialog = false },
onStartTransfer = { config -> onStartTransfer = { config ->
showFtDialog = false viewModel.startFileTransfer(config) { error ->
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
} }
) )
} }
@@ -357,11 +601,30 @@ fun MainScreen(
SettingsDialog( SettingsDialog(
initialMaskHiddenInput = maskHiddenInput, initialMaskHiddenInput = maskHiddenInput,
initialCursorBlink = cursorBlink, initialCursorBlink = cursorBlink,
initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode,
onDismiss = { showSettingsDialog = false }, onDismiss = { showSettingsDialog = false },
onSave = { mask, blink -> onSave = { mask, blink, haptic, verify, gfx ->
showSettingsDialog = false showSettingsDialog = false
viewModel.updateSettings(mask, blink) viewModel.updateSettings(mask, blink, haptic, verify, gfx)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}
)
}
val prompt = untrustedCertPrompt
if (prompt != null) {
UntrustedCertificateDialog(
host = prompt.host,
port = prompt.port,
chain = prompt.chain,
exception = prompt.exception,
onAccept = {
viewModel.resolveUntrustedCert(true)
},
onReject = {
viewModel.resolveUntrustedCert(false)
} }
) )
} }
+412 -139
View File
@@ -5,23 +5,64 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings import org.pubvm.a3270.storage.AppSettings
import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
sealed interface TerminalInputAction {
data class TypeText(val text: String) : TerminalInputAction
data class SendAid(val aidCode: Int) : TerminalInputAction
data object Backspace : TerminalInputAction
data object DeleteChar : TerminalInputAction
data object Tab : TerminalInputAction
data object BackTab : TerminalInputAction
data object CursorLeft : TerminalInputAction
data object CursorRight : TerminalInputAction
data object CursorUp : TerminalInputAction
data object CursorDown : TerminalInputAction
data object CursorHome : TerminalInputAction
data object EraseEof : TerminalInputAction
data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
}
data class UntrustedCertPromptState(
val host: String,
val port: Int,
val chain: Array<java.security.cert.X509Certificate>?,
val exception: java.security.cert.CertificateException?,
val deferred: kotlinx.coroutines.CompletableDeferred<Boolean>
)
data class FTProgressState(
val isActive: Boolean = false,
val isRunning: Boolean = false,
val bytesTransferred: Long = 0L,
val statusMessage: String = "",
val isError: Boolean = false
)
class TerminalViewModel(application: Application) : AndroidViewModel(application) { class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name) private val log = Logger.getLogger(TerminalViewModel::class.java.name)
@@ -32,6 +73,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val inputExecutor = Executors.newSingleThreadExecutor() private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher() private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
// Non-blocking FIFO input channel buffer
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED) private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@@ -62,29 +106,199 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application)) private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow() val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
fun updateSettings(maskHidden: Boolean, blink: Boolean) { private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application))
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden) val hapticFeedback: StateFlow<Boolean> = _hapticFeedback.asStateFlow()
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
}
private val _verifyCerts = MutableStateFlow(AppSettings.isVerifyCertsEnabled(application))
val verifyCerts: StateFlow<Boolean> = _verifyCerts.asStateFlow()
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
private val _isTlsVerified = MutableStateFlow(true)
val isTlsVerified: StateFlow<Boolean> = _isTlsVerified.asStateFlow()
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
// File Transfer State
private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
@Volatile
private var client: Telnet3270Client? = null private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set private set
var activeHostType: String = "TSO"
private set
fun getClient(): Telnet3270Client? = client
private var lastScreenContentHash: Int = 0 private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false private var hasInitialScreenLoaded: Boolean = false
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") { init {
if (_connectionState.value.isConnected()) return // Start continuous background input buffer consumer
viewModelScope.launch(inputDispatcher) {
for (action in inputChannel) {
processInputAction(action)
// Drain any additional pending actions in the buffer before updating the UI
while (true) {
val next = inputChannel.tryReceive().getOrNull() ?: break
processInputAction(next)
}
val c = client
if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress
}
_screenVersion.value = System.currentTimeMillis()
}
}
}
private fun processInputAction(action: TerminalInputAction) {
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
try {
when (action) {
is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
ip.isKeyboardLocked = false
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'")
}
}
}
is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false
ip.backspace()
}
is TerminalInputAction.DeleteChar -> {
ip.isKeyboardLocked = false
ip.deleteChar()
}
is TerminalInputAction.Tab -> {
ip.tab()
}
is TerminalInputAction.BackTab -> {
ip.backTab()
}
is TerminalInputAction.CursorLeft -> {
ip.cursorLeft()
}
is TerminalInputAction.CursorRight -> {
ip.cursorRight()
}
is TerminalInputAction.CursorUp -> {
ip.cursorUp()
}
is TerminalInputAction.CursorDown -> {
ip.cursorDown()
}
is TerminalInputAction.CursorHome -> {
ip.cursorHome()
}
is TerminalInputAction.EraseEof -> {
ip.isKeyboardLocked = false
ip.eraseEof()
}
is TerminalInputAction.EraseInput -> {
ip.isKeyboardLocked = false
ip.eraseInput()
}
is TerminalInputAction.Newline -> {
ip.newline()
}
is TerminalInputAction.Reset -> {
ip.reset()
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
}
}
}
} catch (e: Exception) {
log.warning("Error processing input action: ${e.message}")
}
}
fun resolveUntrustedCert(accept: Boolean) {
try {
val prompt = _untrustedCertPrompt.value
if (prompt != null) {
prompt.deferred.complete(accept)
_untrustedCertPrompt.value = null
}
} catch (t: Throwable) {
log.warning("Error resolving untrusted cert: ${t.message}")
}
}
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
_hapticFeedback.value = haptic
_verifyCerts.value = verifyCertsVal
_defaultGraphicsMode.value = defaultGraphicsVal
}
fun connect(
host: String,
port: Int = 23,
modelNum: Int = 2,
luName: String = "",
hostType: String = "TSO",
useTls: Boolean = false,
tlsVerifyCert: Boolean = true,
tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH"
) {
currentHost = host currentHost = host
activeHostType = hostType
lastScreenContentHash = 0 lastScreenContentHash = 0
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
fileTransferCoordinator = null
_ftState.value = FTProgressState()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
_oiaText.value = "Connecting to $host:$port..." // Always disconnect any existing client before establishing a new connection
if (client != null || _connectionState.value.isConnected()) {
try {
client?.disconnect()
} catch (_: Exception) {}
client = null
_connectionState.value = ConnectionState.NOT_CONNECTED
}
val model = when (modelNum) { val model = when (modelNum) {
3 -> TerminalModel.IBM_3279_3 3 -> TerminalModel.IBM_3279_3
@@ -93,10 +307,52 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
else -> TerminalModel.IBM_3279_2 else -> TerminalModel.IBM_3279_2
} }
val config = ConnectionConfig(host, port, model).apply { val parsedConfig = ConnectionConfig.parseHostString(host, port, model)
val effectiveHost = parsedConfig.host
val effectivePort = parsedConfig.port
val effectiveTls = useTls || parsedConfig.isUseTls
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
if (luName.isNotBlank()) { if (luName.isNotBlank()) {
setLuName(luName) setLuName(luName)
} }
isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
}
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true
}
log.info("Prompting user for untrusted certificate: $effectiveHost:$effectivePort")
val deferred = kotlinx.coroutines.CompletableDeferred<Boolean>()
_untrustedCertPrompt.value = UntrustedCertPromptState(
host = effectiveHost,
port = effectivePort,
chain = chain,
exception = exception,
deferred = deferred
)
val accepted = try {
kotlinx.coroutines.runBlocking { deferred.await() }
} catch (t: Throwable) {
log.warning("Certificate verifier prompt interrupted or failed: ${t.message}")
false
} finally {
_untrustedCertPrompt.value = null
}
accepted
} }
val newClient = Telnet3270Client(config) val newClient = Telnet3270Client(config)
@@ -105,8 +361,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
newClient.addConnectionListener(object : ConnectionListener { newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState _connectionState.value = newState
val tlsSuffix = if (effectiveTls) {
if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
} else ""
_oiaText.value = if (newState.isFullSession()) { _oiaText.value = if (newState.isFullSession()) {
"3270 Connected ($host)" "3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
} else if (newState.isHalfConnected()) { } else if (newState.isHalfConnected()) {
"Connecting..." "Connecting..."
} else { } else {
@@ -136,6 +395,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_screenVersion.value = System.currentTimeMillis() _screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated()
val newHash = computeScreenContentHash(buf) val newHash = computeScreenContentHash(buf)
val contentChanged = (newHash != lastScreenContentHash) val contentChanged = (newHash != lastScreenContentHash)
@@ -164,10 +426,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_screenBuffer.value = newClient.screenBuffer _screenBuffer.value = newClient.screenBuffer
newClient.connect() newClient.connect()
} catch (e: Exception) { } catch (e: Throwable) {
log.severe("Failed to connect: ${e.message}") log.severe("Failed to connect: ${e.message}")
_connectionState.value = ConnectionState.NOT_CONNECTED _connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}" _oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
} }
} }
} }
@@ -175,186 +437,194 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
fun disconnect() { fun disconnect() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect() client?.disconnect()
client = null client = null
_connectionState.value = ConnectionState.NOT_CONNECTED _connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Disconnected" _oiaText.value = "Disconnected"
_ftState.value = FTProgressState()
} catch (e: Exception) { } catch (e: Exception) {
log.warning("Error disconnecting: ${e.message}") log.warning("Error disconnecting: ${e.message}")
} }
} }
} }
fun typeChar(ch: Char) { fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
val c = client ?: return val c = client
viewModelScope.launch(inputDispatcher) { if (c == null || !_connectionState.value.isConnected()) {
val err = "Terminal is not connected."
_ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
onResult(err)
return
}
viewModelScope.launch(Dispatchers.IO) {
try { try {
val ip = c.inputProcessor // Resolve local path if relative (default to app files or download directory)
ip.isKeyboardLocked = false // Ensure keyboard lock is cleared on user typing val rawPath = config.localFilename
val buf = c.screenBuffer if (!rawPath.startsWith("/")) {
var curAddr = buf.cursorAddress val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
if (buf.isFormatted) { val resolvedFile = File(appFilesDir, rawPath)
val faVal = buf.getFieldAttributeAt(curAddr) config.localFilename = resolvedFile.absolutePath
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
} }
if (fileTransferCoordinator == null) {
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
override fun onTransferStarted() {
_ftState.value = FTProgressState(
isActive = true,
isRunning = false,
bytesTransferred = 0L,
statusMessage = "Starting IND\$FILE transfer..."
)
}
override fun onTransferRunning() {
_ftState.value = _ftState.value.copy(
isRunning = true,
statusMessage = "Transfer in progress..."
)
}
override fun onBytesTransferred(bytes: Long) {
_ftState.value = _ftState.value.copy(
bytesTransferred = bytes,
statusMessage = "Transferring: $bytes bytes"
)
}
override fun onTransferComplete(message: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = message,
isError = false
)
}
override fun onTransferAborted(error: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = "Transfer failed: $error",
isError = true
)
}
})
}
val err = fileTransferCoordinator?.startTransfer(config)
if (err != null) {
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Error: $err",
isError = true
)
}
withContext(Dispatchers.Main) {
onResult(err)
} }
ip.typeCharacter(ch)
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) { } catch (e: Exception) {
log.warning("Error typing char: ${e.message}") log.warning("Error in startFileTransfer: ${e.message}")
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Transfer error: ${e.message}",
isError = true
)
withContext(Dispatchers.Main) {
onResult(e.message)
} }
} }
} }
}
fun cancelFileTransfer() {
viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.cancel()
} catch (e: Exception) {
log.warning("Error cancelling transfer: ${e.message}")
}
}
}
fun typeChar(ch: Char) {
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
}
fun typeString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun pasteString(text: String) { fun pasteString(text: String) {
val c = client ?: return if (text.isNotEmpty()) {
viewModelScope.launch(inputDispatcher) { inputChannel.trySend(TerminalInputAction.TypeText(text))
try {
val ip = c.inputProcessor
ip.isKeyboardLocked = false
for (ch in text) {
if (ch == '\n' || ch == '\r') {
ip.tab()
} else if (ch >= ' ') {
ip.typeCharacter(ch)
}
}
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error pasting text: ${e.message}")
}
} }
} }
fun backspace() { fun backspace() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.Backspace)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.isKeyboardLocked = false
c.inputProcessor.backspace()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error backspacing: ${e.message}")
}
} }
fun deleteChar() {
inputChannel.trySend(TerminalInputAction.DeleteChar)
} }
fun tab() { fun tab() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.Tab)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.tab()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error tabbing: ${e.message}")
}
}
} }
fun backTab() { fun backTab() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.BackTab)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.backTab()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error back-tabbing: ${e.message}")
}
}
} }
fun resetKeyboard() { fun resetKeyboard() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.Reset)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.reset()
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error resetting keyboard: ${e.message}")
} }
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
} }
fun eraseInput() {
inputChannel.trySend(TerminalInputAction.EraseInput)
}
fun newline() {
inputChannel.trySend(TerminalInputAction.Newline)
} }
fun sendAid(aidCode: Int) { fun sendAid(aidCode: Int) {
val c = client ?: return inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.setKeyboardLocked(false)
c.inputProcessor.sendAid(aidCode)
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error sending AID: ${e.message}")
}
}
} }
fun setCursor(baddr: Int) { fun setCursor(baddr: Int) {
val buf = _screenBuffer.value ?: return inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
if (baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = baddr
_cursorAddress.value = baddr
_screenVersion.value = System.currentTimeMillis()
}
} }
fun cursorLeft() { fun cursorLeft() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.CursorLeft)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorLeft()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorLeft: ${e.message}")
}
}
} }
fun cursorUp() { fun cursorUp() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.CursorUp)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorUp()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorUp: ${e.message}")
}
}
} }
fun cursorDown() { fun cursorDown() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.CursorDown)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorDown()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorDown: ${e.message}")
}
}
} }
fun cursorRight() { fun cursorRight() {
val c = client ?: return inputChannel.trySend(TerminalInputAction.CursorRight)
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorRight()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorRight: ${e.message}")
}
} }
fun cursorHome() {
inputChannel.trySend(TerminalInputAction.CursorHome)
} }
private fun extractScreenSnippet(buf: ScreenBuffer?): String { private fun extractScreenSnippet(buf: ScreenBuffer?): String {
@@ -428,7 +698,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect() client?.disconnect()
inputChannel.close()
inputExecutor.shutdown() inputExecutor.shutdown()
} }
} }
@@ -0,0 +1,248 @@
package org.pubvm.a3270.ft
import android.os.Handler
import android.os.Looper
import org.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft
import java.io.File
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.logging.Logger
/**
* Android coordinator for IND$FILE file transfers.
* Manages the transfer state machine, timeouts, and coordinates between
* UI, Telnet3270Client, and lower-level CUT/DFT protocol handlers.
*/
class FileTransfer(
private val client: Telnet3270Client,
private val callback: FileTransferCallback
) : FTCut.FTCutListener, FTDft.FTDftListener {
private val log = Logger.getLogger(FileTransfer::class.java.name)
private val mainHandler = Handler(Looper.getMainLooper())
enum class FTMode {
UNKNOWN, CUT, DFT
}
interface FileTransferCallback {
fun onTransferStarted()
fun onTransferRunning()
fun onBytesTransferred(bytes: Long)
fun onTransferComplete(message: String)
fun onTransferAborted(error: String)
}
private var currentConfig: FTConfig? = null
private var localFile: File? = null
private var state = FTState.NONE
private var activeMode = FTMode.UNKNOWN
private var cutHandler: FTCut? = null
private var dftHandler: FTDft? = null
private var timeoutTimer: Timer? = null
private val startTimeoutMs = 30000L // 30 seconds
fun startTransfer(config: FTConfig): String? {
if (state != FTState.NONE) {
return "A transfer is already in progress."
}
val validationError = config.validate()
if (validationError != null) {
return validationError
}
this.currentConfig = config
this.localFile = File(config.localFilename)
this.activeMode = FTMode.UNKNOWN
// Check overwrite
if (config.isReceive && !config.isAppend && !config.isOverwrite) {
if (localFile?.exists() == true) {
return "Local file already exists and overwrite is not permitted."
}
}
// Initialize protocol handlers lazily
if (cutHandler == null) {
cutHandler = FTCut(
client.screenBuffer,
client.inputProcessor,
client.translator,
this
)
}
if (dftHandler == null) {
dftHandler = FTDft(
client.inputProcessor,
client.translator,
this
)
client.dataStreamProcessor.setFTDft(dftHandler)
}
// Build and type the IND$FILE command
val command = config.buildCommand()
log.info("Starting IND\$FILE transfer with command: $command")
// Erase field and verify it can hold the command
val capacity = client.inputProcessor.kybdPrime()
if (capacity < 0) {
cleanupHandlers(false)
return when (capacity) {
-1 -> "Keyboard is locked."
-3 -> "No unprotected input field found on screen."
else -> "Cannot start transfer from current screen state."
}
}
if (capacity < command.length) {
cleanupHandlers(false)
return "Current input field is too small for IND\$FILE command ($capacity chars max)."
}
setState(FTState.AWAIT_ACK)
client.emulateInput(command + "\n")
startTimeout()
mainHandler.post { callback.onTransferStarted() }
return null
}
fun cancel() {
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
log.info("User cancelled transfer")
setState(FTState.ABORT_WAIT)
} else if (state != FTState.NONE) {
log.info("Forcing cancel from state $state")
completeTransfer("Transfer cancelled.")
mainHandler.post { callback.onTransferAborted("Cancelled by user.") }
}
}
fun reset() {
log.info("Force resetting FileTransfer state from $state")
completeTransfer("Reset")
}
fun isTransferActive(): Boolean = state != FTState.NONE
fun getActiveMode(): FTMode = activeMode
fun onScreenUpdated() {
if ((activeMode == FTMode.CUT || activeMode == FTMode.UNKNOWN) &&
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
cutHandler?.processScreenUpdate()
}
}
private fun startTimeout() {
cancelTimeout()
timeoutTimer = Timer("FTTimeout", true).apply {
schedule(object : TimerTask() {
override fun run() {
mainHandler.post {
if (state == FTState.AWAIT_ACK) {
log.warning("Transfer start timeout")
completeTransfer("Transfer failed to start within 30 seconds.")
callback.onTransferAborted("Transfer start timeout.")
}
}
}
}, startTimeoutMs)
}
}
private fun cancelTimeout() {
timeoutTimer?.cancel()
timeoutTimer = null
}
private fun cleanupHandlers(success: Boolean) {
cutHandler?.cleanup()
dftHandler?.cleanup()
if (!success && currentConfig != null && currentConfig?.isReceive == true && !currentConfig!!.isAppend) {
if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile!!.exists()) {
log.info("Cleaning up incomplete download: ${localFile!!.absolutePath}")
localFile!!.delete()
}
}
}
private fun completeTransfer(errorMessage: String?) {
cancelTimeout()
val success = (errorMessage == null)
cleanupHandlers(success)
setState(FTState.NONE)
activeMode = FTMode.UNKNOWN
currentConfig = null
}
override fun onCutRunning() {
handleTransferRunning(FTMode.CUT)
}
override fun onDftRunning() {
handleTransferRunning(FTMode.DFT)
}
private fun handleTransferRunning(mode: FTMode) {
if (activeMode == FTMode.UNKNOWN) {
activeMode = mode
log.info("FT mode established: $activeMode")
try {
if (activeMode == FTMode.DFT) {
dftHandler?.initTransfer(localFile)
} else {
cutHandler?.initTransfer(localFile)
}
} catch (e: IOException) {
log.warning("Failed to open local file for $activeMode: ${e.message}")
onTransferAborted("Failed to open local file: ${e.message}")
return
}
}
cancelTimeout()
setState(FTState.RUNNING)
mainHandler.post { callback.onTransferRunning() }
}
override fun onTransferComplete(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post {
if (errorMessage == null) {
callback.onTransferComplete("File transfer complete.")
} else {
callback.onTransferAborted(errorMessage)
}
}
}
override fun onTransferAborted(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post { callback.onTransferAborted(errorMessage ?: "Transfer aborted") }
}
override fun onBytesTransferred(bytes: Long) {
mainHandler.post { callback.onBytesTransferred(bytes) }
}
override fun getCurrentState(): FTState = state
override fun setState(state: FTState) {
this.state = state
}
override fun getConfig(): FTConfig? = currentConfig
override fun getLocalFile(): File? = localFile
}
@@ -7,6 +7,10 @@ object AppSettings {
private const val PREFS_NAME = "a3270_settings" private const val PREFS_NAME = "a3270_settings"
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input" private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
private const val KEY_CURSOR_BLINK = "cursor_blink" private const val KEY_CURSOR_BLINK = "cursor_blink"
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private fun getPrefs(context: Context): SharedPreferences { private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -27,4 +31,28 @@ object AppSettings {
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) { fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply() getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
} }
fun isHapticFeedbackEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
}
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
}
fun isVerifyCertsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
}
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
}
fun getDefaultGraphicsMode(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
}
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
} }
@@ -13,7 +13,12 @@ data class SavedHost(
val port: Int = 23, val port: Int = 23,
val model: Int = 2, val model: Int = 2,
val luName: String = "", val luName: String = "",
val autoConnect: Boolean = false val autoConnect: Boolean = false,
val hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH"
) { ) {
fun toJson(): JSONObject { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
@@ -24,6 +29,11 @@ data class SavedHost(
put("model", model) put("model", model)
put("luName", luName) put("luName", luName)
put("autoConnect", autoConnect) put("autoConnect", autoConnect)
put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
} }
} }
@@ -36,7 +46,12 @@ data class SavedHost(
port = json.optInt("port", 23), port = json.optInt("port", 23),
model = json.optInt("model", 2), model = json.optInt("model", 2),
luName = json.optString("luName", ""), luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false) autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH")
) )
} }
} }
@@ -57,7 +72,13 @@ object HostStorage {
val jsonArray = JSONArray(jsonStr) val jsonArray = JSONArray(jsonStr)
for (i in 0 until jsonArray.length()) { for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i) val obj = jsonArray.getJSONObject(i)
list.add(SavedHost.fromJson(obj)) val saved = SavedHost.fromJson(obj)
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
saved.copy(graphicsMode = "BOTH")
} else {
saved
}
list.add(effective)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
+335 -93
View File
@@ -1,5 +1,6 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -10,22 +11,34 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost import org.pubvm.a3270.storage.SavedHost
@Composable @Composable
fun ConnectDialog( fun ConnectDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit onConnect: (
host: String,
port: Int,
model: Int,
luName: String,
hostType: String,
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String
) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
@@ -37,6 +50,11 @@ fun ConnectDialog(
var modelNum by remember { mutableIntStateOf(2) } var modelNum by remember { mutableIntStateOf(2) }
var luName by remember { mutableStateOf("") } var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) } var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
fun loadProfile(saved: SavedHost) { fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id selectedHostId = saved.id
@@ -46,6 +64,11 @@ fun ConnectDialog(
modelNum = saved.model modelNum = saved.model
luName = saved.luName luName = saved.luName
autoConnect = saved.autoConnect autoConnect = saved.autoConnect
hostType = saved.hostType
useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
} }
fun clearFields() { fun clearFields() {
@@ -56,10 +79,15 @@ fun ConnectDialog(
modelNum = 2 modelNum = 2
luName = "" luName = ""
autoConnect = false autoConnect = false
hostType = "TSO"
useTls = false
tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH"
} }
fun saveCurrentProfile(): SavedHost? { fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: 23 val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" } val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val hostToSave = SavedHost( val hostToSave = SavedHost(
@@ -69,7 +97,12 @@ fun ConnectDialog(
port = port, port = port,
model = modelNum, model = modelNum,
luName = luName.trim(), luName = luName.trim(),
autoConnect = autoConnect autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode
) )
HostStorage.saveHost(context, hostToSave) HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
@@ -86,20 +119,24 @@ fun ConnectDialog(
keyboardController?.show() keyboardController?.show()
} }
Dialog(onDismissRequest = onDismiss) { Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E), color = Color(0xFF1E1E1E),
tonalElevation = 6.dp, tonalElevation = 6.dp,
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.96f) .fillMaxWidth(0.95f)
.wrapContentHeight() .heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(14.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Title Row // Title Row
Row( Row(
@@ -107,12 +144,17 @@ fun ConnectDialog(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White) Text(
"Connect / Manage Hosts",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
TextButton( TextButton(
onClick = { clearFields() }, onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp) contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) { ) {
Text("+ New Profile", fontSize = 12.sp) Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
} }
} }
@@ -120,96 +162,107 @@ fun ConnectDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 300.dp) .weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) { ) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Text(
"Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
// Saved Profiles List savedHosts.forEach { saved ->
if (savedHosts.isNotEmpty()) { val isSelected = (saved.id == selectedHostId)
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray) val tlsTag = if (saved.useTls) {
Box( if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
modifier = Modifier } else ""
.fillMaxWidth() val eTag = if (!saved.tn3270e) " [Non-E]" else ""
.heightIn(max = 95.dp) val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
.verticalScroll(rememberScrollState())
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
savedHosts.forEach { profile ->
val isSelected = profile.id == selectedHostId
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719), color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.border( .clickable { loadProfile(saved) }
width = if (isSelected) 1.dp else 0.dp,
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
shape = RoundedCornerShape(6.dp)
)
.clickable { loadProfile(profile) }
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
if (profile.autoConnect) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF2B8A3E),
shape = RoundedCornerShape(4.dp)
) {
Text( Text(
"AUTO", text = saved.name,
fontSize = 9.sp, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = Color.White, color = Color.White
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) )
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp,
color = Color.LightGray
) )
} }
} Row(verticalAlignment = Alignment.CenterVertically) {
} IconButton(
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
}
Row {
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
Text("Edit", fontSize = 11.sp)
}
TextButton(
onClick = { onClick = {
HostStorage.deleteHost(context, profile.id) loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved
onConnect(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode
)
},
modifier = Modifier.size(32.dp)
) {
Text("", fontSize = 14.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == profile.id) { if (selectedHostId == saved.id) {
clearFields() clearFields()
} }
}, },
contentPadding = PaddingValues(horizontal = 4.dp), modifier = Modifier.size(32.dp)
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
) { ) {
Text("Delete", fontSize = 11.sp) Text("", fontSize = 13.sp, color = Color(0xFFFF6B6B))
}
} }
} }
} }
} }
} }
} }
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
} }
Text( Text(
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:", text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Gray color = Color.Gray
@@ -223,46 +276,223 @@ fun ConnectDialog(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it },
label = { Text("Host / IP Address") }, label = { Text("Host / IP Address") },
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(0.68f)
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
value = portStr, value = portStr,
onValueChange = { portStr = it }, onValueChange = { portStr = it },
label = { Text("Port (default 23)") }, label = { Text("Port") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(0.32f)
) )
}
// Terminal Model Selector
Column(modifier = Modifier.fillMaxWidth()) {
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text("Model:", fontSize = 11.sp, color = Color.Gray) listOf(
listOf(2, 3, 4, 5).forEach { m -> 2 to "M2",
FilterChip( 3 to "M3",
selected = (modelNum == m), 4 to "M4",
onClick = { modelNum = m }, 5 to "M5"
label = { Text("M$m", fontSize = 11.sp) } ).forEach { (m, label) ->
val isSelected = (modelNum == m)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { modelNum = m }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = label,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
) )
} }
} }
}
}
}
// Host System Type (TSO, CMS, CICS)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Host System Type:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("TSO", "CMS", "CICS").forEach { ht ->
val isSelected = (hostType == ht)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { hostType = ht }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = ht,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
// Graphics Mode (Both, Vector, Symbols, Off)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Graphics Mode:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = graphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { graphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
}
}
}
}
}
OutlinedTextField( OutlinedTextField(
value = luName, value = luName,
onValueChange = { luName = it }, onValueChange = { luName = it },
label = { Text("LU Name (Optional)") }, label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Protocol & Security Options
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// TLS / SSL Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newTls = !useTls
useTls = newTls
if (newTls && portStr.trim() == "23") {
portStr = "992"
} else if (!newTls && portStr.trim() == "992") {
portStr = "23"
}
}
) {
Checkbox(
checked = useTls,
onCheckedChange = { checked ->
useTls = checked
if (checked && portStr.trim() == "23") {
portStr = "992"
} else if (!checked && portStr.trim() == "992") {
portStr = "23"
}
}
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
}
// Verify Certificate Checkbox (only when TLS is active)
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp)
.clickable { tlsVerifyCert = !tlsVerifyCert }
) {
Checkbox(
checked = tlsVerifyCert,
onCheckedChange = { tlsVerifyCert = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Verify Server Certificate",
fontSize = 12.sp,
color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450)
)
}
}
// TN3270E Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { tn3270e = !tn3270e }
) {
Checkbox(
checked = tn3270e,
onCheckedChange = { tn3270e = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TN3270E (Extended 3270)", fontSize = 12.sp, color = Color.White)
}
// Auto-connect Checkbox
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -274,7 +504,8 @@ fun ConnectDialog(
onCheckedChange = { autoConnect = it } onCheckedChange = { autoConnect = it }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp) Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
} }
} }
} }
@@ -288,22 +519,33 @@ fun ConnectDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Cancel") Text("Cancel", color = Color.LightGray)
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) { OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save") Text("Save")
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
Button( Button(
onClick = { onClick = {
val saved = saveCurrentProfile() val saved = saveCurrentProfile()
if (saved != null) { if (saved != null) {
onConnect(saved.host, saved.port, saved.model, saved.luName) onConnect(
} saved.host,
saved.port,
saved.model,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode
)
} }
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
Text("Connect") Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
} }
} }
} }
@@ -1,123 +1,227 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ft.FTConfig import org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
@Composable @Composable
fun FileTransferDialog( fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
onDismiss: () -> Unit, onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) { ) {
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") } var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") } var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) } var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) } var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) } var hostType by remember { mutableStateOf(initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
val focusRequester = remember { FocusRequester() } var lreclStr by remember { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current var blksizeStr by remember { mutableStateOf("") }
var overwrite by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("IND\$FILE File Transfer") }, title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = { text = {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
OutlinedTextField( OutlinedTextField(
value = hostFile, value = hostFile,
onValueChange = { hostFile = it }, onValueChange = { hostFile = it },
label = { Text("Host File Name") }, label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
value = localFile, value = localFile,
onValueChange = { localFile = it }, onValueChange = { localFile = it },
label = { Text("Local File Path") }, label = { Text("Local File Name / Path") },
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Transfer Direction
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Direction:") Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FilterChip( FilterChip(
selected = isReceive, selected = isReceive,
onClick = { isReceive = true }, onClick = { isReceive = true },
label = { Text("Receive (GET)") } label = { Text("Receive (GET)", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = !isReceive, selected = !isReceive,
onClick = { isReceive = false }, onClick = { isReceive = false },
label = { Text("Send (PUT)") } label = { Text("Send (PUT)", fontSize = 11.sp) }
) )
} }
} }
// Transfer Mode
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Mode:") Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FilterChip( FilterChip(
selected = isAscii, selected = isAscii,
onClick = { isAscii = true }, onClick = { isAscii = true },
label = { Text("ASCII") } label = { Text("ASCII (Text)", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = !isAscii, selected = !isAscii,
onClick = { isAscii = false }, onClick = { isAscii = false },
label = { Text("Binary") } label = { Text("Binary", fontSize = 11.sp) }
) )
} }
} }
// Host Type (TSO, CMS, CICS)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Host:") Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FTConfig.HostType.values().forEach { ht -> FTConfig.HostType.values().forEach { ht ->
FilterChip( FilterChip(
selected = (hostType == ht), selected = (hostType == ht),
onClick = { hostType = ht }, onClick = { hostType = ht },
label = { Text(ht.name) } label = { Text(ht.name, fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
} }
} }
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
} }
}, },
confirmButton = { confirmButton = {
@@ -127,20 +231,27 @@ fun FileTransferDialog(
val config = FTConfig().apply { val config = FTConfig().apply {
setHostFilename(hostFile.trim()) setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim()) setLocalFilename(localFile.trim())
setReceive(isReceive) setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
setAscii(isAscii) setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType) setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
}
} }
onStartTransfer(config) onStartTransfer(config)
} }
} },
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
) { ) {
Text("Start Transfer") Text("Start Transfer")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Cancel") Text("Close")
} }
} }
) )
@@ -1,20 +1,20 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.focus.focusProperties
import org.lib3270j.ConnectionState import org.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.* import org.lib3270j.protocol.DS3270Constants.*
@@ -22,6 +22,7 @@ import org.lib3270j.protocol.DS3270Constants.*
fun TwoRowKeyBar( fun TwoRowKeyBar(
connectionState: ConnectionState, connectionState: ConnectionState,
isShiftPressed: Boolean = false, isShiftPressed: Boolean = false,
hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {}, onClearShift: () -> Unit = {},
onConnectClick: () -> Unit, onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit, onDisconnectClick: () -> Unit,
@@ -58,6 +59,7 @@ fun TwoRowKeyBar(
label = "", label = "",
color = Color(0xFF343A40), color = Color(0xFF343A40),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp, innerPaddingHorizontal = 0.dp,
onClick = { menuExpanded = true } onClick = { menuExpanded = true }
) )
@@ -106,6 +108,7 @@ fun TwoRowKeyBar(
label = tabLabel, label = tabLabel,
color = Color(0xFF1C7ED6), color = Color(0xFF1C7ED6),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp, innerPaddingHorizontal = 0.dp,
onClick = { onClick = {
if (isShiftPressed) { if (isShiftPressed) {
@@ -118,34 +121,34 @@ fun TwoRowKeyBar(
) )
// 3. RESET // 3. RESET
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset) KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
// 4. ENTER // 4. ENTER
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) }) KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 5. CLEAR // 5. CLEAR
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) }) KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
// 6. PA1 // 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) }) KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
// 7. PA2 // 7. PA2
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) }) KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
// 8. PA3 // 8. PA3
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) }) KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
// 9. Left Navigation // 9. Left Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft) KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
// 10. Up Navigation // 10. Up Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp) KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
// 11. Down Navigation // 11. Down Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown) KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
// 12. Right Navigation // 12. Right Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight) KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
} }
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30))) Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
@@ -174,6 +177,7 @@ fun TwoRowKeyBar(
label = "F$i", label = "F$i",
color = Color(0xFF364FC7), color = Color(0xFF364FC7),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp, innerPaddingHorizontal = 0.dp,
onClick = { onClick = {
onSendAid(aid) onSendAid(aid)
@@ -193,10 +197,17 @@ private fun KeyButton(
color: Color, color: Color,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hapticFeedbackEnabled: Boolean = true,
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
) { ) {
val view = LocalView.current
Surface( Surface(
onClick = onClick, onClick = {
if (hapticFeedbackEnabled) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
onClick()
},
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
color = color, color = color,
shadowElevation = 1.dp, shadowElevation = 1.dp,
@@ -24,6 +24,9 @@ fun OiaStatusBar(
cursorAddr: Int, cursorAddr: Int,
rows: Int, rows: Int,
cols: Int, cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1 val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
@@ -76,6 +79,46 @@ fun OiaStatusBar(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1 maxLines = 1
) )
// TLS Badge
if (connectionState.isConnected() && isTls) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// GFX Mode Badge
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (graphicsMode) {
"VECTOR_GRAPHICS" -> "GOCA"
"PROGRAMMED_SYMBOLS" -> "PS"
else -> "GFX"
},
color = Color(0xFF22B8CF),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
} }
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY) // Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
@@ -1,7 +1,10 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -12,27 +15,40 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable @Composable
fun SettingsDialog( fun SettingsDialog(
initialMaskHiddenInput: Boolean, initialMaskHiddenInput: Boolean,
initialCursorBlink: Boolean, initialCursorBlink: Boolean,
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean) -> Unit onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
) { ) {
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) } var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) } var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
Dialog(onDismissRequest = onDismiss) { Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)), colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.95f)
.padding(16.dp) .heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp) .padding(20.dp)
) { ) {
Text( Text(
@@ -106,6 +122,126 @@ fun SettingsDialog(
) )
} }
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 3: Button Haptic Feedback
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Button Haptic Feedback",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = hapticFeedback,
onCheckedChange = { hapticFeedback = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 4: Overall TLS Certificate Verification
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Verify TLS Certificates",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = verifyCerts,
onCheckedChange = { verifyCerts = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 5: Default Graphics Support Mode
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Default Graphics Mode",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Configure default vector graphics (GOCA) and Programmed Symbols (APL) mode.",
fontSize = 12.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { defaultGraphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Action Buttons // Action Buttons
@@ -119,7 +255,7 @@ fun SettingsDialog(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { onClick = {
onSave(maskHiddenInput, cursorBlink) onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
@@ -0,0 +1,114 @@
package org.pubvm.a3270.ui
import android.content.Context
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
*
* It avoids the destructive TextFieldValue("") resetting cycle which causes Android IME
* (Gboard/Samsung Keyboard) to reset symbol locks, cancel Caps Lock, and drop fast keystrokes.
*/
class TerminalInputConnection(
targetView: View,
private val onInputText: (String) -> Unit,
private val onSendAid: (Int) -> Unit,
private val onBackspace: () -> Unit,
private val onTab: () -> Unit,
private val onBackTab: () -> Unit
) : BaseInputConnection(targetView, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (!text.isNullOrEmpty()) {
onInputText(text.toString())
}
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onSendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_DEL -> {
onBackspace()
return true
}
KeyEvent.KEYCODE_TAB -> {
if (event.isShiftPressed) onBackTab() else onTab()
return true
}
}
val unicode = event.keyCharacterMap.get(event.keyCode, event.metaState)
if (unicode > 0) {
onInputText(unicode.toChar().toString())
return true
}
}
return super.sendKeyEvent(event)
}
}
/**
* Native Android View hosting the terminal InputConnection with Termux-style EditorInfo.
*
* TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + TYPE_CLASS_TEXT forces keyboards like Gboard
* to permanently display the alphanumeric number row across the top, disable intrusive
* autocorrect overlays, and preserve Shift/Caps Lock and Symbol mode (?123) state across typing.
*/
class TerminalInputView(context: Context) : View(context) {
var onInputText: (String) -> Unit = {}
var onSendAid: (Int) -> Unit = {}
var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {}
init {
isFocusable = true
isFocusableInTouchMode = true
}
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE
return TerminalInputConnection(this, onInputText, onSendAid, onBackspace, onTab, onBackTab)
}
fun showSoftKeyboard() {
requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
+148 -31
View File
@@ -58,6 +58,8 @@ fun TerminalView(
cols: Int, cols: Int,
cursorAddr: Int, cursorAddr: Int,
screenVersion: Long, screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
maskHiddenFields: Boolean = true, maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true, blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
@@ -68,6 +70,9 @@ fun TerminalView(
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) } var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) } var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) } var showContextMenu by remember { mutableStateOf(false) }
@@ -137,11 +142,10 @@ fun TerminalView(
showContextMenu = false showContextMenu = false
selectionStart = null selectionStart = null
selectionEnd = null selectionEnd = null
val cellWidth = size.width / cols val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
val cellHeight = size.height / rows if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
if (cellWidth > 0 && cellHeight > 0) { val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1) val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col var addr = row * cols + col
val buf = screenBuffer val buf = screenBuffer
@@ -158,17 +162,30 @@ fun TerminalView(
} }
} }
) { ) {
val width = size.width val version = currentScreenVersion
val height = size.height val activeCursorAddr = currentCursorAddr
val cellWidth = width / cols if (version == -1L) return@Canvas
val cellHeight = height / rows
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth
val cellHeight = metrics.cellHeight
val offsetX = metrics.offsetX
val offsetY = metrics.offsetY
val paint = Paint().apply { val paint = Paint().apply {
isAntiAlias = true isAntiAlias = true
typeface = Typeface.MONOSPACE typeface = Typeface.MONOSPACE
textSize = cellHeight * 0.85f textAlign = Paint.Align.CENTER
}
// Calculate optimal font size ensuring characters fit cleanly in cell without crowding
val baseTextSize = cellHeight * 0.78f
paint.textSize = baseTextSize
val measuredW = paint.measureText("W")
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
} }
val buf = screenBuffer val buf = screenBuffer
@@ -185,11 +202,11 @@ fun TerminalView(
val start = selectionStart val start = selectionStart
val end = selectionEnd val end = selectionEnd
if (start != null && end != null) { if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow) selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow) selMaxRow = maxOf(startRow, endRow)
@@ -202,8 +219,8 @@ fun TerminalView(
val addr = r * cols + c val addr = r * cols + c
if (addr >= totalCells) break if (addr >= totalCells) break
val left = c * cellWidth val left = offsetX + c * cellWidth
val top = r * cellHeight val top = offsetY + r * cellHeight
var charVal = ' ' var charVal = ' '
var fgColor: Color var fgColor: Color
@@ -211,6 +228,8 @@ fun TerminalView(
var isBold = false var isBold = false
var isUnderline = false var isUnderline = false
var isReverse = false var isReverse = false
var csVal = 0
var ecVal = 0
if (buf != null) { if (buf != null) {
val ea = buf.getCell(addr) val ea = buf.getCell(addr)
@@ -225,6 +244,10 @@ fun TerminalView(
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA) fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa) bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity // Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) { if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true isBold = true
@@ -271,7 +294,7 @@ fun TerminalView(
) )
} }
// Highlight selected block range // Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) { if (isSelected) {
drawRect( drawRect(
@@ -282,7 +305,7 @@ fun TerminalView(
} }
// Cursor indicator (respects blinking toggle & timer) // Cursor indicator (respects blinking toggle & timer)
if (addr == cursorAddr && !isSelected && cursorVisible) { if (addr == activeCursorAddr && !isSelected && cursorVisible) {
drawRect( drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f), color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top), topLeft = Offset(left, top),
@@ -290,15 +313,37 @@ fun TerminalView(
) )
} }
if (charVal != ' ') { // Draw Programmed Symbol (PS / APL) if defined
var drawnAsPs = false
if (csVal >= 0x40 && programSymbolManager != null) {
val slot = programSymbolManager.getSymbol(csVal, ecVal)
if (slot != null) {
val symWidth = slot.width
val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
if (!drawnAsPs && charVal != ' ') {
paint.color = fgColor.toArgb() paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
charVal.toString(), charVal.toString(),
left + (cellWidth / 4), textX,
textY, textY,
paint paint
) )
@@ -307,13 +352,32 @@ fun TerminalView(
if (isUnderline) { if (isUnderline) {
drawLine( drawLine(
color = fgColor, color = fgColor,
start = Offset(left, top + cellHeight - 2), start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2), end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f strokeWidth = 2f
) )
} }
} }
} }
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
if (rgb != null) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
}
}
} }
// Context Menu Popup // Context Menu Popup
@@ -370,6 +434,57 @@ fun TerminalView(
} }
} }
data class TerminalGridMetrics(
val cellWidth: Float,
val cellHeight: Float,
val gridWidth: Float,
val gridHeight: Float,
val offsetX: Float,
val offsetY: Float
)
fun calculateGridMetrics(
viewWidth: Float,
viewHeight: Float,
cols: Int,
rows: Int
): TerminalGridMetrics {
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
}
val rawCellW = viewWidth / cols
val rawCellH = viewHeight / rows
val currentRatio = rawCellW / rawCellH
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
val targetMinRatio = 0.48f
val targetMaxRatio = 0.60f
val cellW: Float
val cellH: Float
if (currentRatio > targetMaxRatio) {
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
cellH = rawCellH
cellW = rawCellH * targetMaxRatio
} else if (currentRatio < targetMinRatio) {
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
cellW = rawCellW
cellH = rawCellW / targetMinRatio
} else {
cellW = rawCellW
cellH = rawCellH
}
val gridW = cols * cellW
val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f
val offY = (viewHeight - gridH) / 2f
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
private fun copySelection( private fun copySelection(
start: Offset?, start: Offset?,
end: Offset?, end: Offset?,
@@ -381,13 +496,15 @@ private fun copySelection(
) { ) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) { if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols val viewW = displayMetrics.widthPixels.toFloat()
val cellHeight = displayMetrics.heightPixels.toFloat() / rows val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow) val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow) val maxRow = maxOf(startRow, endRow)
@@ -0,0 +1,192 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import java.security.MessageDigest
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun UntrustedCertificateDialog(
host: String,
port: Int,
chain: Array<X509Certificate>?,
exception: CertificateException?,
onAccept: () -> Unit,
onReject: () -> Unit
) {
val cert = chain?.firstOrNull()
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
val detailsText = buildString {
if (exception != null) {
append("Validation Error:\n ")
append(exception.localizedMessage ?: exception.message ?: exception.toString())
append("\n\n")
}
if (cert != null) {
try {
append("Subject:\n ")
append(cert.subjectX500Principal?.name ?: cert.subjectDN?.name ?: "Unknown")
append("\n\n")
} catch (t: Throwable) {
append("Subject: ${t.message ?: "Unavailable"}\n\n")
}
try {
append("Issuer:\n ")
append(cert.issuerX500Principal?.name ?: cert.issuerDN?.name ?: "Unknown")
append("\n\n")
} catch (t: Throwable) {
append("Issuer: ${t.message ?: "Unavailable"}\n\n")
}
try {
append("Validity:\n From: ")
append(cert.notBefore?.let { sdf.format(it) } ?: "Unknown")
append("\n To: ")
append(cert.notAfter?.let { sdf.format(it) } ?: "Unknown")
append("\n\n")
} catch (t: Throwable) {
append("Validity: ${t.message ?: "Unavailable"}\n\n")
}
try {
append("Serial Number:\n ")
append(cert.serialNumber?.toString(16)?.uppercase(Locale.US) ?: "Unknown")
append("\n\n")
} catch (t: Throwable) {
append("Serial Number: ${t.message ?: "Unavailable"}\n\n")
}
try {
append("SHA-256 Fingerprint:\n ")
append(computeFingerprint(cert, "SHA-256"))
append("\n\n")
} catch (t: Throwable) {
append("SHA-256 Fingerprint: ${t.message ?: "Unavailable"}\n\n")
}
try {
append("SHA-1 Fingerprint:\n ")
append(computeFingerprint(cert, "SHA-1"))
} catch (t: Throwable) {
append("SHA-1 Fingerprint: ${t.message ?: "Unavailable"}")
}
} else {
append("No peer certificate information available.")
}
}
Dialog(
onDismissRequest = onReject,
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.96f)
.wrapContentHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "⚠️",
fontSize = 24.sp
)
Column {
Text(
text = "Untrusted SSL/TLS Certificate",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFB450)
)
Text(
text = "The server certificate for $host:$port could not be verified.",
fontSize = 12.sp,
color = Color.LightGray
)
}
}
// Scrollable Certificate Details Box
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
.border(1.dp, Color(0xFF333333), RoundedCornerShape(6.dp))
.padding(10.dp)
) {
val scrollState = rememberScrollState()
Text(
text = detailsText,
color = Color(0xFFDCDCDC),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 15.sp,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onReject) {
Text("Cancel Connection", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onAccept,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB46428))
) {
Text("Connect Anyway", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
private fun computeFingerprint(cert: X509Certificate, algorithm: String): String {
return try {
val md = MessageDigest.getInstance(algorithm)
val digest = md.digest(cert.encoded)
digest.joinToString(":") { "%02X".format(it.toInt() and 0xFF) }
} catch (e: Throwable) {
"Unable to compute fingerprint: ${e.message}"
}
}
@@ -0,0 +1,168 @@
package org.pubvm.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.lib3270j.TerminalModel
import org.lib3270j.charset.EbcdicTranslator
import org.lib3270j.input.InputProcessor
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testUnformattedTypingAndNavigation() {
assertEquals(0, screenBuffer.cursorAddress)
// Type alphanumeric string
val text = "LOGON APPLID(TSO)"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(text.length, screenBuffer.cursorAddress)
for (i in text.indices) {
assertEquals(text[i], screenBuffer.getCell(i).ucs4)
}
// Navigation
inputProcessor.cursorLeft()
assertEquals(text.length - 1, screenBuffer.cursorAddress)
inputProcessor.cursorRight()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorDown()
assertEquals(80 + text.length, screenBuffer.cursorAddress)
inputProcessor.cursorUp()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorHome()
assertEquals(0, screenBuffer.cursorAddress)
}
@Test
fun testBackspaceAndForwardDelete() {
val text = "HELLO"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(5, screenBuffer.cursorAddress)
// Backspace moves left and deletes char
inputProcessor.backspace()
assertEquals(4, screenBuffer.cursorAddress)
}
@Test
fun testFormattedFieldInputAndTab() {
// Create formatted screen with protected label and unprotected input field
// Position 0: Protected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
// Set label "USERID:" at positions 1..7
val label = "USERID:"
for (i in label.indices) {
screenBuffer.getCell(i + 1).ucs4 = label[i]
screenBuffer.getCell(i + 1).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 10: Unprotected FA
screenBuffer.setCellFA(10, FA_PRINTABLE.toByte())
// Position 20: Protected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 30: Unprotected FA
screenBuffer.setCellFA(30, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 11
// Type into first field
val userid = "IBMUSER"
for (ch in userid) {
inputProcessor.typeCharacter(ch)
}
assertEquals(18, screenBuffer.cursorAddress)
for (i in userid.indices) {
assertEquals(userid[i], screenBuffer.getCell(11 + i).ucs4)
}
// Tab should jump to position 31 (next unprotected field)
inputProcessor.tab()
assertEquals(31, screenBuffer.cursorAddress)
// BackTab should jump back to position 11
inputProcessor.backTab()
assertEquals(11, screenBuffer.cursorAddress)
// Home should jump to first unprotected field (position 11)
screenBuffer.cursorAddress = 50
inputProcessor.cursorHome()
assertEquals(11, screenBuffer.cursorAddress)
}
@Test
fun testFormattedDeleteCharAndEraseEof() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 1
val text = "ABCDE"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
// Move cursor to 'C' at pos 3
screenBuffer.cursorAddress = 3
inputProcessor.deleteChar()
// "ABDE "
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals('D', screenBuffer.getCell(3).ucs4)
assertEquals('E', screenBuffer.getCell(4).ucs4)
assertEquals(0, screenBuffer.getCell(5).ec.toInt())
// Erase EOF from pos 3
inputProcessor.eraseEof()
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals(0, screenBuffer.getCell(3).ec.toInt())
assertEquals(0, screenBuffer.getCell(4).ec.toInt())
}
@Test
fun testPfKeyAids() {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
for (i in 1..24) {
val aid = pfAids[i - 1]
inputProcessor.sendAid(aid)
assertEquals(aid, inputProcessor.lastAid)
assertTrue(inputProcessor.isKeyboardLocked)
inputProcessor.reset()
assertFalse(inputProcessor.isKeyboardLocked)
}
}
}