From 9bf91f5c4939e7ff0e29812329f3ce937a9e8887 Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 21 Aug 2026 02:59:22 -0400 Subject: [PATCH] Add v0.2 features from j3270 --- src/main/java/org/pubvm/a3270/MainActivity.kt | 50 +++++- .../java/org/pubvm/a3270/TerminalViewModel.kt | 93 +++++++++- .../org/pubvm/a3270/storage/AppSettings.kt | 19 ++ .../org/pubvm/a3270/storage/HostStorage.kt | 13 +- .../java/org/pubvm/a3270/ui/ConnectDialog.kt | 121 ++++++++++++- .../java/org/pubvm/a3270/ui/OiaStatusBar.kt | 43 +++++ .../java/org/pubvm/a3270/ui/SettingsDialog.kt | 78 +++++++- .../java/org/pubvm/a3270/ui/TerminalView.kt | 50 +++++- .../a3270/ui/UntrustedCertificateDialog.kt | 168 ++++++++++++++++++ 9 files changed, 609 insertions(+), 26 deletions(-) create mode 100644 src/main/java/org/pubvm/a3270/ui/UntrustedCertificateDialog.kt diff --git a/src/main/java/org/pubvm/a3270/MainActivity.kt b/src/main/java/org/pubvm/a3270/MainActivity.kt index 5277c60..7b66439 100644 --- a/src/main/java/org/pubvm/a3270/MainActivity.kt +++ b/src/main/java/org/pubvm/a3270/MainActivity.kt @@ -32,6 +32,8 @@ import org.pubvm.a3270.ui.TerminalInputView import org.pubvm.a3270.ui.TerminalView import org.pubvm.a3270.ui.TwoRowKeyBar +import org.pubvm.a3270.ui.UntrustedCertificateDialog + class MainActivity : ComponentActivity() { private val viewModel: TerminalViewModel by viewModels() @@ -148,6 +150,11 @@ fun MainScreen( val maskHiddenInput by viewModel.maskHiddenInput.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) } @@ -174,7 +181,16 @@ fun MainScreen( LaunchedEffect(Unit) { val autoHost = HostStorage.getAutoConnectHost(context) 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 + ) } } @@ -204,6 +220,7 @@ fun MainScreen( cols = cols, cursorAddr = cursorAddr, screenVersion = screenVersion, + programSymbolManager = viewModel.getClient()?.programSymbolManager, maskHiddenFields = maskHiddenInput, blinkCursor = cursorBlink, onTapAddress = { addr -> @@ -298,7 +315,10 @@ fun MainScreen( oiaText = oiaText, cursorAddr = cursorAddr, rows = rows, - cols = cols + cols = cols, + isTls = isTlsActive, + isTlsVerified = isTlsVerified, + graphicsMode = defaultGraphicsMode ) } } @@ -306,9 +326,9 @@ fun MainScreen( if (showConnectDialog) { ConnectDialog( onDismiss = { showConnectDialog = false }, - onConnect = { host, port, model, luName, hostType -> + onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode -> showConnectDialog = false - viewModel.connect(host, port, model, luName, hostType) + viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode) terminalInputViewRef?.showSoftKeyboard() } ) @@ -337,12 +357,30 @@ fun MainScreen( initialMaskHiddenInput = maskHiddenInput, initialCursorBlink = cursorBlink, initialHapticFeedback = hapticFeedback, + initialVerifyCerts = verifyCerts, + initialDefaultGraphicsMode = defaultGraphicsMode, onDismiss = { showSettingsDialog = false }, - onSave = { mask, blink, haptic -> + onSave = { mask, blink, haptic, verify, gfx -> showSettingsDialog = false - viewModel.updateSettings(mask, blink, haptic) + viewModel.updateSettings(mask, blink, haptic, verify, gfx) 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) + } + ) + } } diff --git a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt index 26e49bf..ba25df4 100644 --- a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt +++ b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt @@ -42,6 +42,14 @@ sealed interface TerminalInputAction { data class SetCursor(val baddr: Int) : TerminalInputAction } +data class UntrustedCertPromptState( + val host: String, + val port: Int, + val chain: Array?, + val exception: java.security.cert.CertificateException?, + val deferred: kotlinx.coroutines.CompletableDeferred +) + data class FTProgressState( val isActive: Boolean = false, val isRunning: Boolean = false, @@ -96,6 +104,21 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application)) val hapticFeedback: StateFlow = _hapticFeedback.asStateFlow() + private val _verifyCerts = MutableStateFlow(AppSettings.isVerifyCertsEnabled(application)) + val verifyCerts: StateFlow = _verifyCerts.asStateFlow() + + private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application)) + val defaultGraphicsMode: StateFlow = _defaultGraphicsMode.asStateFlow() + + private val _isTlsActive = MutableStateFlow(false) + val isTlsActive: StateFlow = _isTlsActive.asStateFlow() + + private val _isTlsVerified = MutableStateFlow(true) + val isTlsVerified: StateFlow = _isTlsVerified.asStateFlow() + + private val _untrustedCertPrompt = MutableStateFlow(null) + val untrustedCertPrompt: StateFlow = _untrustedCertPrompt.asStateFlow() + // File Transfer State private var fileTransferCoordinator: FileTransfer? = null private val _ftState = MutableStateFlow(FTProgressState()) @@ -107,6 +130,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application var activeHostType: String = "TSO" private set + fun getClient(): Telnet3270Client? = client + private var lastScreenContentHash: Int = 0 private var hasInitialScreenLoaded: Boolean = false @@ -195,16 +220,37 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } } - fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean) { + fun resolveUntrustedCert(accept: Boolean) { + val prompt = _untrustedCertPrompt.value + if (prompt != null) { + prompt.deferred.complete(accept) + _untrustedCertPrompt.value = null + } + } + + 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") { + fun connect( + host: String, + port: Int = 23, + modelNum: Int = 2, + luName: String = "", + hostType: String = "TSO", + useTls: Boolean = false, + tlsVerifyCert: Boolean = true, + graphicsModeStr: String = "BOTH" + ) { if (_connectionState.value.isConnected()) return currentHost = host activeHostType = hostType @@ -215,8 +261,6 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch(Dispatchers.IO) { try { - _oiaText.value = "Connecting to $host:$port..." - val model = when (modelNum) { 3 -> TerminalModel.IBM_3279_3 4 -> TerminalModel.IBM_3279_4 @@ -224,10 +268,44 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application 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()) { 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() + _untrustedCertPrompt.value = UntrustedCertPromptState( + host = effectiveHost, + port = effectivePort, + chain = chain, + exception = exception, + deferred = deferred + ) + val accepted = kotlinx.coroutines.runBlocking { deferred.await() } + _untrustedCertPrompt.value = null + accepted } val newClient = Telnet3270Client(config) @@ -236,8 +314,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application newClient.addConnectionListener(object : ConnectionListener { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { _connectionState.value = newState + val tlsSuffix = if (effectiveTls) { + if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]" + } else "" _oiaText.value = if (newState.isFullSession()) { - "3270 Connected ($host - $activeHostType)" + "3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix" } else if (newState.isHalfConnected()) { "Connecting..." } else { diff --git a/src/main/java/org/pubvm/a3270/storage/AppSettings.kt b/src/main/java/org/pubvm/a3270/storage/AppSettings.kt index e618470..e45d5df 100644 --- a/src/main/java/org/pubvm/a3270/storage/AppSettings.kt +++ b/src/main/java/org/pubvm/a3270/storage/AppSettings.kt @@ -9,6 +9,9 @@ object AppSettings { 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 { return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } @@ -36,4 +39,20 @@ object AppSettings { 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() + } } diff --git a/src/main/java/org/pubvm/a3270/storage/HostStorage.kt b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt index ce3510d..6383ab6 100644 --- a/src/main/java/org/pubvm/a3270/storage/HostStorage.kt +++ b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt @@ -14,7 +14,10 @@ data class SavedHost( val model: Int = 2, val luName: String = "", 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 { return JSONObject().apply { @@ -26,6 +29,9 @@ data class SavedHost( put("luName", luName) put("autoConnect", autoConnect) put("hostType", hostType) + put("useTls", useTls) + put("tlsVerifyCert", tlsVerifyCert) + put("graphicsMode", graphicsMode) } } @@ -39,7 +45,10 @@ data class SavedHost( model = json.optInt("model", 2), luName = json.optString("luName", ""), 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") ) } } diff --git a/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt index b3601d4..c9130f2 100644 --- a/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt +++ b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt @@ -25,7 +25,7 @@ import org.pubvm.a3270.storage.SavedHost @Composable fun ConnectDialog( 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 var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } @@ -38,6 +38,9 @@ fun ConnectDialog( var luName by remember { mutableStateOf("") } 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 graphicsMode by remember { mutableStateOf("BOTH") } fun loadProfile(saved: SavedHost) { selectedHostId = saved.id @@ -48,6 +51,9 @@ fun ConnectDialog( luName = saved.luName autoConnect = saved.autoConnect hostType = saved.hostType + useTls = saved.useTls + tlsVerifyCert = saved.tlsVerifyCert + graphicsMode = saved.graphicsMode } fun clearFields() { @@ -59,10 +65,13 @@ fun ConnectDialog( luName = "" autoConnect = false hostType = "TSO" + useTls = false + tlsVerifyCert = true + graphicsMode = "BOTH" } fun saveCurrentProfile(): SavedHost? { - val port = portStr.toIntOrNull() ?: 23 + val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23 if (host.isBlank()) return null val nameToSave = profileName.ifBlank { "${host.trim()}:$port" } val hostToSave = SavedHost( @@ -73,7 +82,10 @@ fun ConnectDialog( model = modelNum, luName = luName.trim(), autoConnect = autoConnect, - hostType = hostType + hostType = hostType, + useTls = useTls, + tlsVerifyCert = tlsVerifyCert, + graphicsMode = graphicsMode ) HostStorage.saveHost(context, hostToSave) savedHosts = HostStorage.getSavedHosts(context) @@ -124,7 +136,7 @@ fun ConnectDialog( Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = 440.dp) + .heightIn(max = 460.dp) ) { val scrollState = rememberScrollState() Column( @@ -142,6 +154,11 @@ fun ConnectDialog( ) { savedHosts.forEach { saved -> 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( shape = RoundedCornerShape(6.dp), color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B), @@ -165,8 +182,8 @@ fun ConnectDialog( color = Color.White ) Text( - text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})" + - if (saved.autoConnect) " [Auto-Connect]" else "", + text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$gfxTag" + + if (saved.autoConnect) " [Auto]" else "", fontSize = 10.sp, color = Color.LightGray ) @@ -176,7 +193,16 @@ fun ConnectDialog( onClick = { loadProfile(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) ) { @@ -230,7 +256,7 @@ fun ConnectDialog( OutlinedTextField( value = portStr, onValueChange = { portStr = it }, - label = { Text("Port (default 23)") }, + label = { Text("Port (default 23 or 992)") }, singleLine = true, 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( + "BOTH" to "Both", + "VECTOR_GRAPHICS" to "Vector", + "PROGRAMMED_SYMBOLS" to "Symbols", + "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( value = luName, onValueChange = { luName = it }, @@ -309,7 +403,16 @@ fun ConnectDialog( onClick = { val saved = saveCurrentProfile() 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 + ) } } ) { diff --git a/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt index eb7ce45..129bea4 100644 --- a/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt @@ -24,6 +24,9 @@ fun OiaStatusBar( cursorAddr: Int, rows: Int, cols: Int, + isTls: Boolean = false, + isTlsVerified: Boolean = true, + graphicsMode: String = "NONE", modifier: Modifier = Modifier ) { val row = if (cols > 0) (cursorAddr / cols) + 1 else 1 @@ -76,6 +79,46 @@ fun OiaStatusBar( fontWeight = FontWeight.Bold, 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) diff --git a/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt b/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt index 15488b6..2a1e87b 100644 --- a/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt +++ b/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt @@ -19,12 +19,16 @@ fun SettingsDialog( initialMaskHiddenInput: Boolean, initialCursorBlink: Boolean, initialHapticFeedback: Boolean = true, + initialVerifyCerts: Boolean = true, + initialDefaultGraphicsMode: String = "BOTH", onDismiss: () -> Unit, - onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean) -> Unit + onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit ) { var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) } 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) { Card( @@ -144,6 +148,76 @@ fun SettingsDialog( ) } + 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)) // Action Buttons @@ -157,7 +231,7 @@ fun SettingsDialog( Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { - onSave(maskHiddenInput, cursorBlink, hapticFeedback) + onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode) }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) ) { diff --git a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt index d223792..13464a1 100644 --- a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt +++ b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt @@ -58,6 +58,7 @@ fun TerminalView( cols: Int, cursorAddr: Int, screenVersion: Long, + programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null, maskHiddenFields: Boolean = true, blinkCursor: Boolean = true, onTapAddress: (Int) -> Unit, @@ -211,6 +212,8 @@ fun TerminalView( var isBold = false var isUnderline = false var isReverse = false + var csVal = 0 + var ecVal = 0 if (buf != null) { val ea = buf.getCell(addr) @@ -225,6 +228,10 @@ fun TerminalView( fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA) 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 if (faIsHigh(currentFA.toInt() and 0xFF)) { isBold = true @@ -290,7 +297,48 @@ 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 pixelData = slot.pixelData + if (pixelData != null && symWidth > 0 && symHeight > 0) { + val fgArgb = fgColor.toArgb() + val bgArgb = bgColor.toArgb() + val isTriple = slot.isTriplePlane + val rgbArray = IntArray(symWidth * symHeight) + for (py in 0 until symHeight) { + for (px in 0 until symWidth) { + val pIdx = py * symWidth + px + val v = if (pIdx < pixelData.size) (pixelData[pIdx].toInt() and 0xFF) else 0 + if (v == 0) { + rgbArray[pIdx] = bgArgb + } else if (!isTriple) { + rgbArray[pIdx] = fgArgb + } else { + val redVal = if ((v and 0x01) != 0) 255 else 0 + val greenVal = if ((v and 0x02) != 0) 255 else 0 + val blueVal = if ((v and 0x04) != 0) 255 else 0 + rgbArray[pIdx] = (0xFF shl 24) or (redVal shl 16) or (greenVal shl 8) or blueVal + } + } + } + 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.isFakeBoldText = isBold val fontMetrics = paint.fontMetrics diff --git a/src/main/java/org/pubvm/a3270/ui/UntrustedCertificateDialog.kt b/src/main/java/org/pubvm/a3270/ui/UntrustedCertificateDialog.kt new file mode 100644 index 0000000..16f5901 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/UntrustedCertificateDialog.kt @@ -0,0 +1,168 @@ +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?, + 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) { + append("Subject:\n ") + append(cert.subjectX500Principal.name) + append("\n\n") + + append("Issuer:\n ") + append(cert.issuerX500Principal.name) + append("\n\n") + + append("Validity:\n From: ") + append(sdf.format(cert.notBefore)) + append("\n To: ") + append(sdf.format(cert.notAfter)) + append("\n\n") + + append("Serial Number:\n ") + append(cert.serialNumber.toString(16).uppercase(Locale.US)) + append("\n\n") + + append("SHA-256 Fingerprint:\n ") + append(computeFingerprint(cert, "SHA-256")) + append("\n\n") + + append("SHA-1 Fingerprint:\n ") + append(computeFingerprint(cert, "SHA-1")) + } 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) } + } catch (e: Exception) { + "Unable to compute fingerprint: ${e.message}" + } +}