This commit is contained in:
@@ -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}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user