Add v0.2 features from j3270
Build and Test a3270 / Build Android APK (push) Successful in 4m32s

This commit is contained in:
2026-08-21 02:59:22 -04:00
parent cf58b5c72c
commit 9bf91f5c49
9 changed files with 609 additions and 26 deletions
+44 -6
View File
@@ -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)
}
)
}
}
@@ -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<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,
@@ -96,6 +104,21 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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
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<Boolean>()
_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 {
@@ -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()
}
}
@@ -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")
)
}
}
@@ -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
)
}
}
) {
@@ -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)
@@ -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))
) {
@@ -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
@@ -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<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) {
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}"
}
}