3 Commits

Author SHA1 Message Date
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
13 changed files with 899 additions and 109 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
+9 -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 3 versionCode 4
versionName "0.1.2" 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
+81 -9
View File
@@ -32,8 +32,39 @@ 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 org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.UntrustedCertificateDialog
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)
@@ -147,6 +178,12 @@ 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() val ftState by viewModel.ftState.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
@@ -173,7 +210,16 @@ 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, autoHost.hostType) viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.graphicsMode
)
} }
} }
@@ -203,6 +249,8 @@ fun MainScreen(
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 ->
@@ -249,6 +297,7 @@ fun MainScreen(
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() },
@@ -296,7 +345,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
) )
} }
} }
@@ -304,9 +356,9 @@ fun MainScreen(
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType -> onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType) viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -318,9 +370,10 @@ fun MainScreen(
ftProgressState = ftState, ftProgressState = ftState,
onDismiss = { showFtDialog = false }, onDismiss = { showFtDialog = false },
onStartTransfer = { config -> onStartTransfer = { config ->
val error = viewModel.startFileTransfer(config) viewModel.startFileTransfer(config) { error ->
if (error != null) { if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show() Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
} }
}, },
onCancelTransfer = { onCancelTransfer = {
@@ -333,12 +386,31 @@ 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)
terminalInputViewRef?.showSoftKeyboard() 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)
}
)
}
} }
@@ -10,6 +10,7 @@ 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
@@ -41,6 +42,14 @@ sealed interface TerminalInputAction {
data class SetCursor(val baddr: Int) : 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( data class FTProgressState(
val isActive: Boolean = false, val isActive: Boolean = false,
val isRunning: Boolean = false, val isRunning: Boolean = false,
@@ -92,6 +101,24 @@ 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()
private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application))
val hapticFeedback: StateFlow<Boolean> = _hapticFeedback.asStateFlow()
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 // File Transfer State
private var fileTransferCoordinator: FileTransfer? = null private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState()) private val _ftState = MutableStateFlow(FTProgressState())
@@ -103,6 +130,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
var activeHostType: String = "TSO" var activeHostType: String = "TSO"
private set 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
@@ -191,15 +220,41 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun updateSettings(maskHidden: Boolean, blink: Boolean) { fun resolveUntrustedCert(accept: Boolean) {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden) try {
AppSettings.setCursorBlinkEnabled(getApplication(), blink) val prompt = _untrustedCertPrompt.value
_maskHiddenInput.value = maskHidden if (prompt != null) {
_cursorBlink.value = blink prompt.deferred.complete(accept)
_untrustedCertPrompt.value = null
}
} catch (t: Throwable) {
log.warning("Error resolving untrusted cert: ${t.message}")
}
} }
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "", hostType: String = "TSO") { fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
if (_connectionState.value.isConnected()) return 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,
graphicsModeStr: String = "BOTH"
) {
currentHost = host currentHost = host
activeHostType = hostType activeHostType = hostType
lastScreenContentHash = 0 lastScreenContentHash = 0
@@ -209,7 +264,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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
@@ -218,10 +280,50 @@ 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 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
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)
@@ -230,8 +332,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 - $activeHostType)" "3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
} else if (newState.isHalfConnected()) { } else if (newState.isHalfConnected()) {
"Connecting..." "Connecting..."
} else { } else {
@@ -292,10 +397,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"}"
} }
} }
} }
@@ -316,77 +421,105 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun startFileTransfer(config: FTConfig): String? { fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
val c = client ?: return "Terminal is not connected." val c = client
if (c == null || !_connectionState.value.isConnected()) {
// Resolve local path if relative (default to app files or download directory) val err = "Terminal is not connected."
val rawPath = config.localFilename _ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
if (!rawPath.startsWith("/")) { onResult(err)
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir return
val resolvedFile = File(appFilesDir, rawPath)
config.localFilename = resolvedFile.absolutePath
} }
if (fileTransferCoordinator == null) { viewModelScope.launch(Dispatchers.IO) {
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback { try {
override fun onTransferStarted() { // Resolve local path if relative (default to app files or download directory)
_ftState.value = FTProgressState( val rawPath = config.localFilename
isActive = true, if (!rawPath.startsWith("/")) {
isRunning = false, val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
bytesTransferred = 0L, val resolvedFile = File(appFilesDir, rawPath)
statusMessage = "Starting IND\$FILE transfer..." config.localFilename = resolvedFile.absolutePath
)
} }
override fun onTransferRunning() { if (fileTransferCoordinator == null) {
_ftState.value = _ftState.value.copy( fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
isRunning = true, override fun onTransferStarted() {
statusMessage = "Transfer in progress..." _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
)
}
})
} }
override fun onBytesTransferred(bytes: Long) { val err = fileTransferCoordinator?.startTransfer(config)
_ftState.value = _ftState.value.copy( if (err != null) {
bytesTransferred = bytes,
statusMessage = "Transferring: $bytes bytes"
)
}
override fun onTransferComplete(message: String) {
_ftState.value = FTProgressState( _ftState.value = FTProgressState(
isActive = false, isActive = false,
isRunning = false, statusMessage = "Error: $err",
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 isError = true
) )
} }
}) withContext(Dispatchers.Main) {
onResult(err)
}
} catch (e: Exception) {
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)
}
}
} }
val err = fileTransferCoordinator?.startTransfer(config)
if (err != null) {
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Error: $err",
isError = true
)
}
return err
} }
fun cancelFileTransfer() { fun cancelFileTransfer() {
fileTransferCoordinator?.cancel() viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.cancel()
} catch (e: Exception) {
log.warning("Error cancelling transfer: ${e.message}")
}
}
} }
fun typeChar(ch: Char) { fun typeChar(ch: Char) {
@@ -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()
}
} }
@@ -14,7 +14,10 @@ data class SavedHost(
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 hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val graphicsMode: String = "BOTH"
) { ) {
fun toJson(): JSONObject { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
@@ -26,6 +29,9 @@ data class SavedHost(
put("luName", luName) put("luName", luName)
put("autoConnect", autoConnect) put("autoConnect", autoConnect)
put("hostType", hostType) put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("graphicsMode", graphicsMode)
} }
} }
@@ -39,7 +45,10 @@ data class SavedHost(
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") hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
graphicsMode = json.optString("graphicsMode", "BOTH")
) )
} }
} }
@@ -60,7 +69,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()
@@ -25,7 +25,7 @@ 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, hostType: String) -> Unit onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String, useTls: Boolean, tlsVerifyCert: 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)) }
@@ -38,6 +38,9 @@ fun ConnectDialog(
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 hostType by remember { mutableStateOf("TSO") }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
fun loadProfile(saved: SavedHost) { fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id selectedHostId = saved.id
@@ -48,6 +51,9 @@ fun ConnectDialog(
luName = saved.luName luName = saved.luName
autoConnect = saved.autoConnect autoConnect = saved.autoConnect
hostType = saved.hostType hostType = saved.hostType
useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert
graphicsMode = saved.graphicsMode
} }
fun clearFields() { fun clearFields() {
@@ -59,10 +65,13 @@ fun ConnectDialog(
luName = "" luName = ""
autoConnect = false autoConnect = false
hostType = "TSO" hostType = "TSO"
useTls = false
tlsVerifyCert = 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(
@@ -73,7 +82,10 @@ fun ConnectDialog(
model = modelNum, model = modelNum,
luName = luName.trim(), luName = luName.trim(),
autoConnect = autoConnect, autoConnect = autoConnect,
hostType = hostType hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
graphicsMode = graphicsMode
) )
HostStorage.saveHost(context, hostToSave) HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
@@ -124,7 +136,7 @@ fun ConnectDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 440.dp) .heightIn(max = 460.dp)
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
@@ -142,6 +154,11 @@ fun ConnectDialog(
) { ) {
savedHosts.forEach { saved -> savedHosts.forEach { saved ->
val isSelected = (saved.id == selectedHostId) val isSelected = (saved.id == selectedHostId)
val tlsTag = if (saved.useTls) {
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
} else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B), color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
@@ -165,8 +182,8 @@ fun ConnectDialog(
color = Color.White color = Color.White
) )
Text( Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})" + text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$gfxTag" +
if (saved.autoConnect) " [Auto-Connect]" else "", if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp, fontSize = 10.sp,
color = Color.LightGray color = Color.LightGray
) )
@@ -176,7 +193,16 @@ fun ConnectDialog(
onClick = { onClick = {
loadProfile(saved) loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved val hostToConn = saveCurrentProfile() ?: saved
onConnect(hostToConn.host, hostToConn.port, hostToConn.model, hostToConn.luName, hostToConn.hostType) onConnect(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.graphicsMode
)
}, },
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) { ) {
@@ -230,7 +256,7 @@ fun ConnectDialog(
OutlinedTextField( OutlinedTextField(
value = portStr, value = portStr,
onValueChange = { portStr = it }, onValueChange = { portStr = it },
label = { Text("Port (default 23)") }, label = { Text("Port (default 23 or 992)") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -265,6 +291,74 @@ fun ConnectDialog(
} }
} }
// Graphics Mode
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Graphics:", fontSize = 11.sp, color = Color.Gray)
listOf(
"PROGRAMMED_SYMBOLS" to "Symbols",
"VECTOR_GRAPHICS" to "Vector",
"BOTH" to "Both",
"NONE" to "None"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = (graphicsMode.equals(modeKey, ignoreCase = true)),
onClick = { graphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
)
}
}
// 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
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.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))
}
}
OutlinedTextField( OutlinedTextField(
value = luName, value = luName,
onValueChange = { luName = it }, onValueChange = { luName = it },
@@ -309,7 +403,16 @@ fun ConnectDialog(
onClick = { onClick = {
val saved = saveCurrentProfile() val saved = saveCurrentProfile()
if (saved != null) { if (saved != null) {
onConnect(saved.host, saved.port, saved.model, saved.luName, saved.hostType) onConnect(
saved.host,
saved.port,
saved.model,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.graphicsMode
)
} }
} }
) { ) {
@@ -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,9 @@
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.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
@@ -16,11 +18,17 @@ import androidx.compose.ui.window.Dialog
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) {
Card( Card(
@@ -33,6 +41,7 @@ fun SettingsDialog(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp) .padding(20.dp)
) { ) {
Text( Text(
@@ -106,6 +115,109 @@ 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(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "None"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = defaultGraphicsMode.equals(modeKey, ignoreCase = true),
onClick = { defaultGraphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
)
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Action Buttons // Action Buttons
@@ -119,7 +231,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))
) { ) {
@@ -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,
@@ -211,6 +213,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 +229,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
@@ -290,7 +298,28 @@ 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, 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
@@ -314,6 +343,25 @@ fun TerminalView(
} }
} }
} }
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = (cols * cellWidth).toInt()
val gridH = (rows * cellHeight).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(0f, 0f, gridW.toFloat(), gridH.toFloat()),
null
)
}
}
}
} }
// Context Menu Popup // Context Menu Popup
@@ -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}"
}
}