Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
17f8cae925
|
|||
|
9bf91f5c49
|
|||
|
cf58b5c72c
|
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# a3270
|
# a3270
|
||||||
|
|
||||||
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
||||||
[](https://git.hugfreevikings.wtf/rudi/a3270/releases)
|
|
||||||
[](https://developer.android.com)
|
[](https://developer.android.com)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
@@ -110,3 +109,6 @@ I have contributed no code to this project, it was entirely written by LLMs with
|
|||||||
- Claude Opus 4.6
|
- Claude Opus 4.6
|
||||||
- Gemini 3.1 Pro
|
- Gemini 3.1 Pro
|
||||||
- Gemini 3.7 Flash
|
- Gemini 3.7 Flash
|
||||||
|
- Gemma4 12B and 26B
|
||||||
|
- GPT-OSS 120B
|
||||||
|
- Qwen3 4B and 32B
|
||||||
|
|||||||
+9
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "org.pubvm.a3270"
|
applicationId "org.pubvm.a3270"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 3
|
versionCode 4
|
||||||
versionName "0.1.2"
|
versionName "0.1.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -42,6 +42,13 @@ android {
|
|||||||
if (findProject(':lib3270j') != null) {
|
if (findProject(':lib3270j') != null) {
|
||||||
project(':lib3270j') {
|
project(':lib3270j') {
|
||||||
apply plugin: 'java-library'
|
apply plugin: 'java-library'
|
||||||
|
dependencies {
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
}
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
|||||||
@@ -32,8 +32,39 @@ import org.pubvm.a3270.ui.TerminalInputView
|
|||||||
import org.pubvm.a3270.ui.TerminalView
|
import org.pubvm.a3270.ui.TerminalView
|
||||||
import org.pubvm.a3270.ui.TwoRowKeyBar
|
import org.pubvm.a3270.ui.TwoRowKeyBar
|
||||||
|
|
||||||
|
import org.pubvm.a3270.ui.UntrustedCertificateDialog
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init {
|
||||||
|
val rootLogger = java.util.logging.Logger.getLogger("")
|
||||||
|
rootLogger.level = java.util.logging.Level.ALL
|
||||||
|
for (h in rootLogger.handlers) {
|
||||||
|
rootLogger.removeHandler(h)
|
||||||
|
}
|
||||||
|
rootLogger.addHandler(object : java.util.logging.Handler() {
|
||||||
|
override fun publish(record: java.util.logging.LogRecord?) {
|
||||||
|
if (record == null) return
|
||||||
|
val msg = record.message ?: ""
|
||||||
|
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
|
||||||
|
when {
|
||||||
|
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
|
||||||
|
android.util.Log.e(tag, msg, record.thrown)
|
||||||
|
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
|
||||||
|
android.util.Log.w(tag, msg, record.thrown)
|
||||||
|
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
|
||||||
|
android.util.Log.i(tag, msg, record.thrown)
|
||||||
|
else ->
|
||||||
|
android.util.Log.d(tag, msg, record.thrown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun flush() {}
|
||||||
|
override fun close() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val viewModel: TerminalViewModel by viewModels()
|
private val viewModel: TerminalViewModel by viewModels()
|
||||||
private val isShiftPressedState = mutableStateOf(false)
|
private val isShiftPressedState = mutableStateOf(false)
|
||||||
|
|
||||||
@@ -147,6 +178,12 @@ fun MainScreen(
|
|||||||
val screenVersion by viewModel.screenVersion.collectAsState()
|
val screenVersion by viewModel.screenVersion.collectAsState()
|
||||||
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
|
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
|
||||||
val cursorBlink by viewModel.cursorBlink.collectAsState()
|
val cursorBlink by viewModel.cursorBlink.collectAsState()
|
||||||
|
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
|
||||||
|
val verifyCerts by viewModel.verifyCerts.collectAsState()
|
||||||
|
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
|
||||||
|
val isTlsActive by viewModel.isTlsActive.collectAsState()
|
||||||
|
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
|
||||||
|
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
|
||||||
val ftState by viewModel.ftState.collectAsState()
|
val ftState by viewModel.ftState.collectAsState()
|
||||||
|
|
||||||
var showConnectDialog by remember { mutableStateOf(false) }
|
var showConnectDialog by remember { mutableStateOf(false) }
|
||||||
@@ -173,7 +210,16 @@ fun MainScreen(
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val autoHost = HostStorage.getAutoConnectHost(context)
|
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||||
if (autoHost != null && !connectionState.isConnected()) {
|
if (autoHost != null && !connectionState.isConnected()) {
|
||||||
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName, autoHost.hostType)
|
viewModel.connect(
|
||||||
|
autoHost.host,
|
||||||
|
autoHost.port,
|
||||||
|
autoHost.model,
|
||||||
|
autoHost.luName,
|
||||||
|
autoHost.hostType,
|
||||||
|
autoHost.useTls,
|
||||||
|
autoHost.tlsVerifyCert,
|
||||||
|
autoHost.graphicsMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +249,8 @@ fun MainScreen(
|
|||||||
cols = cols,
|
cols = cols,
|
||||||
cursorAddr = cursorAddr,
|
cursorAddr = cursorAddr,
|
||||||
screenVersion = screenVersion,
|
screenVersion = screenVersion,
|
||||||
|
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
||||||
|
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
||||||
maskHiddenFields = maskHiddenInput,
|
maskHiddenFields = maskHiddenInput,
|
||||||
blinkCursor = cursorBlink,
|
blinkCursor = cursorBlink,
|
||||||
onTapAddress = { addr ->
|
onTapAddress = { addr ->
|
||||||
@@ -249,6 +297,7 @@ fun MainScreen(
|
|||||||
TwoRowKeyBar(
|
TwoRowKeyBar(
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
isShiftPressed = isShiftPressed,
|
isShiftPressed = isShiftPressed,
|
||||||
|
hapticFeedbackEnabled = hapticFeedback,
|
||||||
onClearShift = onClearShift,
|
onClearShift = onClearShift,
|
||||||
onConnectClick = { showConnectDialog = true },
|
onConnectClick = { showConnectDialog = true },
|
||||||
onDisconnectClick = { viewModel.disconnect() },
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
@@ -296,7 +345,10 @@ fun MainScreen(
|
|||||||
oiaText = oiaText,
|
oiaText = oiaText,
|
||||||
cursorAddr = cursorAddr,
|
cursorAddr = cursorAddr,
|
||||||
rows = rows,
|
rows = rows,
|
||||||
cols = cols
|
cols = cols,
|
||||||
|
isTls = isTlsActive,
|
||||||
|
isTlsVerified = isTlsVerified,
|
||||||
|
graphicsMode = defaultGraphicsMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,9 +356,9 @@ fun MainScreen(
|
|||||||
if (showConnectDialog) {
|
if (showConnectDialog) {
|
||||||
ConnectDialog(
|
ConnectDialog(
|
||||||
onDismiss = { showConnectDialog = false },
|
onDismiss = { showConnectDialog = false },
|
||||||
onConnect = { host, port, model, luName, hostType ->
|
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode ->
|
||||||
showConnectDialog = false
|
showConnectDialog = false
|
||||||
viewModel.connect(host, port, model, luName, hostType)
|
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode)
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -318,9 +370,10 @@ fun MainScreen(
|
|||||||
ftProgressState = ftState,
|
ftProgressState = ftState,
|
||||||
onDismiss = { showFtDialog = false },
|
onDismiss = { showFtDialog = false },
|
||||||
onStartTransfer = { config ->
|
onStartTransfer = { config ->
|
||||||
val error = viewModel.startFileTransfer(config)
|
viewModel.startFileTransfer(config) { error ->
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancelTransfer = {
|
onCancelTransfer = {
|
||||||
@@ -333,12 +386,31 @@ fun MainScreen(
|
|||||||
SettingsDialog(
|
SettingsDialog(
|
||||||
initialMaskHiddenInput = maskHiddenInput,
|
initialMaskHiddenInput = maskHiddenInput,
|
||||||
initialCursorBlink = cursorBlink,
|
initialCursorBlink = cursorBlink,
|
||||||
|
initialHapticFeedback = hapticFeedback,
|
||||||
|
initialVerifyCerts = verifyCerts,
|
||||||
|
initialDefaultGraphicsMode = defaultGraphicsMode,
|
||||||
onDismiss = { showSettingsDialog = false },
|
onDismiss = { showSettingsDialog = false },
|
||||||
onSave = { mask, blink ->
|
onSave = { mask, blink, haptic, verify, gfx ->
|
||||||
showSettingsDialog = false
|
showSettingsDialog = false
|
||||||
viewModel.updateSettings(mask, blink)
|
viewModel.updateSettings(mask, blink, haptic, verify, gfx)
|
||||||
terminalInputViewRef?.showSoftKeyboard()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val prompt = untrustedCertPrompt
|
||||||
|
if (prompt != null) {
|
||||||
|
UntrustedCertificateDialog(
|
||||||
|
host = prompt.host,
|
||||||
|
port = prompt.port,
|
||||||
|
chain = prompt.chain,
|
||||||
|
exception = prompt.exception,
|
||||||
|
onAccept = {
|
||||||
|
viewModel.resolveUntrustedCert(true)
|
||||||
|
},
|
||||||
|
onReject = {
|
||||||
|
viewModel.resolveUntrustedCert(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.lib3270j.ConnectionConfig
|
import org.lib3270j.ConnectionConfig
|
||||||
import org.lib3270j.ConnectionState
|
import org.lib3270j.ConnectionState
|
||||||
import org.lib3270j.Telnet3270Client
|
import org.lib3270j.Telnet3270Client
|
||||||
@@ -41,6 +42,14 @@ sealed interface TerminalInputAction {
|
|||||||
data class SetCursor(val baddr: Int) : TerminalInputAction
|
data class SetCursor(val baddr: Int) : TerminalInputAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class UntrustedCertPromptState(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val chain: Array<java.security.cert.X509Certificate>?,
|
||||||
|
val exception: java.security.cert.CertificateException?,
|
||||||
|
val deferred: kotlinx.coroutines.CompletableDeferred<Boolean>
|
||||||
|
)
|
||||||
|
|
||||||
data class FTProgressState(
|
data class FTProgressState(
|
||||||
val isActive: Boolean = false,
|
val isActive: Boolean = false,
|
||||||
val isRunning: Boolean = false,
|
val isRunning: Boolean = false,
|
||||||
@@ -92,6 +101,24 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
|
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
|
||||||
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
|
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
|
||||||
|
|
||||||
|
private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application))
|
||||||
|
val hapticFeedback: StateFlow<Boolean> = _hapticFeedback.asStateFlow()
|
||||||
|
|
||||||
|
private val _verifyCerts = MutableStateFlow(AppSettings.isVerifyCertsEnabled(application))
|
||||||
|
val verifyCerts: StateFlow<Boolean> = _verifyCerts.asStateFlow()
|
||||||
|
|
||||||
|
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
|
||||||
|
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _isTlsActive = MutableStateFlow(false)
|
||||||
|
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
|
||||||
|
|
||||||
|
private val _isTlsVerified = MutableStateFlow(true)
|
||||||
|
val isTlsVerified: StateFlow<Boolean> = _isTlsVerified.asStateFlow()
|
||||||
|
|
||||||
|
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
|
||||||
|
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
|
||||||
|
|
||||||
// File Transfer State
|
// File Transfer State
|
||||||
private var fileTransferCoordinator: FileTransfer? = null
|
private var fileTransferCoordinator: FileTransfer? = null
|
||||||
private val _ftState = MutableStateFlow(FTProgressState())
|
private val _ftState = MutableStateFlow(FTProgressState())
|
||||||
@@ -103,6 +130,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
var activeHostType: String = "TSO"
|
var activeHostType: String = "TSO"
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
fun getClient(): Telnet3270Client? = client
|
||||||
|
|
||||||
private var lastScreenContentHash: Int = 0
|
private var lastScreenContentHash: Int = 0
|
||||||
private var hasInitialScreenLoaded: Boolean = false
|
private var hasInitialScreenLoaded: Boolean = false
|
||||||
|
|
||||||
@@ -191,15 +220,41 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSettings(maskHidden: Boolean, blink: Boolean) {
|
fun resolveUntrustedCert(accept: Boolean) {
|
||||||
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
|
try {
|
||||||
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
|
val prompt = _untrustedCertPrompt.value
|
||||||
_maskHiddenInput.value = maskHidden
|
if (prompt != null) {
|
||||||
_cursorBlink.value = blink
|
prompt.deferred.complete(accept)
|
||||||
|
_untrustedCertPrompt.value = null
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
log.warning("Error resolving untrusted cert: ${t.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "", hostType: String = "TSO") {
|
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
|
||||||
if (_connectionState.value.isConnected()) return
|
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
|
||||||
|
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
|
||||||
|
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
|
||||||
|
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
|
||||||
|
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
|
||||||
|
_maskHiddenInput.value = maskHidden
|
||||||
|
_cursorBlink.value = blink
|
||||||
|
_hapticFeedback.value = haptic
|
||||||
|
_verifyCerts.value = verifyCertsVal
|
||||||
|
_defaultGraphicsMode.value = defaultGraphicsVal
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(
|
||||||
|
host: String,
|
||||||
|
port: Int = 23,
|
||||||
|
modelNum: Int = 2,
|
||||||
|
luName: String = "",
|
||||||
|
hostType: String = "TSO",
|
||||||
|
useTls: Boolean = false,
|
||||||
|
tlsVerifyCert: Boolean = true,
|
||||||
|
graphicsModeStr: String = "BOTH"
|
||||||
|
) {
|
||||||
currentHost = host
|
currentHost = host
|
||||||
activeHostType = hostType
|
activeHostType = hostType
|
||||||
lastScreenContentHash = 0
|
lastScreenContentHash = 0
|
||||||
@@ -209,7 +264,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
_oiaText.value = "Connecting to $host:$port..."
|
// Always disconnect any existing client before establishing a new connection
|
||||||
|
if (client != null || _connectionState.value.isConnected()) {
|
||||||
|
try {
|
||||||
|
client?.disconnect()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
client = null
|
||||||
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
val model = when (modelNum) {
|
val model = when (modelNum) {
|
||||||
3 -> TerminalModel.IBM_3279_3
|
3 -> TerminalModel.IBM_3279_3
|
||||||
@@ -218,10 +280,50 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
else -> TerminalModel.IBM_3279_2
|
else -> TerminalModel.IBM_3279_2
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = ConnectionConfig(host, port, model).apply {
|
val parsedConfig = ConnectionConfig.parseHostString(host, port, model)
|
||||||
|
val effectiveHost = parsedConfig.host
|
||||||
|
val effectivePort = parsedConfig.port
|
||||||
|
val effectiveTls = useTls || parsedConfig.isUseTls
|
||||||
|
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
|
||||||
|
val effectiveVerify = tlsVerifyCert && globalVerify
|
||||||
|
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
|
||||||
|
|
||||||
|
_isTlsActive.value = effectiveTls
|
||||||
|
_isTlsVerified.value = effectiveVerify
|
||||||
|
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
|
||||||
|
|
||||||
|
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
|
||||||
if (luName.isNotBlank()) {
|
if (luName.isNotBlank()) {
|
||||||
setLuName(luName)
|
setLuName(luName)
|
||||||
}
|
}
|
||||||
|
isUseTls = effectiveTls
|
||||||
|
isTlsVerifyCert = effectiveVerify
|
||||||
|
graphicsMode = gMode
|
||||||
|
}
|
||||||
|
|
||||||
|
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
|
||||||
|
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
|
||||||
|
log.info("Certificate verification bypassed via global AppSettings")
|
||||||
|
return@TlsCertificateVerifier true
|
||||||
|
}
|
||||||
|
log.info("Prompting user for untrusted certificate: $effectiveHost:$effectivePort")
|
||||||
|
val deferred = kotlinx.coroutines.CompletableDeferred<Boolean>()
|
||||||
|
_untrustedCertPrompt.value = UntrustedCertPromptState(
|
||||||
|
host = effectiveHost,
|
||||||
|
port = effectivePort,
|
||||||
|
chain = chain,
|
||||||
|
exception = exception,
|
||||||
|
deferred = deferred
|
||||||
|
)
|
||||||
|
val accepted = try {
|
||||||
|
kotlinx.coroutines.runBlocking { deferred.await() }
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
log.warning("Certificate verifier prompt interrupted or failed: ${t.message}")
|
||||||
|
false
|
||||||
|
} finally {
|
||||||
|
_untrustedCertPrompt.value = null
|
||||||
|
}
|
||||||
|
accepted
|
||||||
}
|
}
|
||||||
|
|
||||||
val newClient = Telnet3270Client(config)
|
val newClient = Telnet3270Client(config)
|
||||||
@@ -230,8 +332,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
newClient.addConnectionListener(object : ConnectionListener {
|
newClient.addConnectionListener(object : ConnectionListener {
|
||||||
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
||||||
_connectionState.value = newState
|
_connectionState.value = newState
|
||||||
|
val tlsSuffix = if (effectiveTls) {
|
||||||
|
if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
|
||||||
|
} else ""
|
||||||
_oiaText.value = if (newState.isFullSession()) {
|
_oiaText.value = if (newState.isFullSession()) {
|
||||||
"3270 Connected ($host - $activeHostType)"
|
"3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
|
||||||
} else if (newState.isHalfConnected()) {
|
} else if (newState.isHalfConnected()) {
|
||||||
"Connecting..."
|
"Connecting..."
|
||||||
} else {
|
} else {
|
||||||
@@ -292,10 +397,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_screenBuffer.value = newClient.screenBuffer
|
_screenBuffer.value = newClient.screenBuffer
|
||||||
|
|
||||||
newClient.connect()
|
newClient.connect()
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
log.severe("Failed to connect: ${e.message}")
|
log.severe("Failed to connect: ${e.message}")
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}"
|
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,77 +421,105 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startFileTransfer(config: FTConfig): String? {
|
fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
|
||||||
val c = client ?: return "Terminal is not connected."
|
val c = client
|
||||||
|
if (c == null || !_connectionState.value.isConnected()) {
|
||||||
// Resolve local path if relative (default to app files or download directory)
|
val err = "Terminal is not connected."
|
||||||
val rawPath = config.localFilename
|
_ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
|
||||||
if (!rawPath.startsWith("/")) {
|
onResult(err)
|
||||||
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
return
|
||||||
val resolvedFile = File(appFilesDir, rawPath)
|
|
||||||
config.localFilename = resolvedFile.absolutePath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileTransferCoordinator == null) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
|
try {
|
||||||
override fun onTransferStarted() {
|
// Resolve local path if relative (default to app files or download directory)
|
||||||
_ftState.value = FTProgressState(
|
val rawPath = config.localFilename
|
||||||
isActive = true,
|
if (!rawPath.startsWith("/")) {
|
||||||
isRunning = false,
|
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
||||||
bytesTransferred = 0L,
|
val resolvedFile = File(appFilesDir, rawPath)
|
||||||
statusMessage = "Starting IND\$FILE transfer..."
|
config.localFilename = resolvedFile.absolutePath
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTransferRunning() {
|
if (fileTransferCoordinator == null) {
|
||||||
_ftState.value = _ftState.value.copy(
|
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
|
||||||
isRunning = true,
|
override fun onTransferStarted() {
|
||||||
statusMessage = "Transfer in progress..."
|
_ftState.value = FTProgressState(
|
||||||
)
|
isActive = true,
|
||||||
|
isRunning = false,
|
||||||
|
bytesTransferred = 0L,
|
||||||
|
statusMessage = "Starting IND\$FILE transfer..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransferRunning() {
|
||||||
|
_ftState.value = _ftState.value.copy(
|
||||||
|
isRunning = true,
|
||||||
|
statusMessage = "Transfer in progress..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBytesTransferred(bytes: Long) {
|
||||||
|
_ftState.value = _ftState.value.copy(
|
||||||
|
bytesTransferred = bytes,
|
||||||
|
statusMessage = "Transferring: $bytes bytes"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransferComplete(message: String) {
|
||||||
|
_ftState.value = FTProgressState(
|
||||||
|
isActive = false,
|
||||||
|
isRunning = false,
|
||||||
|
bytesTransferred = _ftState.value.bytesTransferred,
|
||||||
|
statusMessage = message,
|
||||||
|
isError = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransferAborted(error: String) {
|
||||||
|
_ftState.value = FTProgressState(
|
||||||
|
isActive = false,
|
||||||
|
isRunning = false,
|
||||||
|
bytesTransferred = _ftState.value.bytesTransferred,
|
||||||
|
statusMessage = "Transfer failed: $error",
|
||||||
|
isError = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBytesTransferred(bytes: Long) {
|
val err = fileTransferCoordinator?.startTransfer(config)
|
||||||
_ftState.value = _ftState.value.copy(
|
if (err != null) {
|
||||||
bytesTransferred = bytes,
|
|
||||||
statusMessage = "Transferring: $bytes bytes"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTransferComplete(message: String) {
|
|
||||||
_ftState.value = FTProgressState(
|
_ftState.value = FTProgressState(
|
||||||
isActive = false,
|
isActive = false,
|
||||||
isRunning = false,
|
statusMessage = "Error: $err",
|
||||||
bytesTransferred = _ftState.value.bytesTransferred,
|
|
||||||
statusMessage = message,
|
|
||||||
isError = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTransferAborted(error: String) {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
isRunning = false,
|
|
||||||
bytesTransferred = _ftState.value.bytesTransferred,
|
|
||||||
statusMessage = "Transfer failed: $error",
|
|
||||||
isError = true
|
isError = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
withContext(Dispatchers.Main) {
|
||||||
|
onResult(err)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warning("Error in startFileTransfer: ${e.message}")
|
||||||
|
_ftState.value = FTProgressState(
|
||||||
|
isActive = false,
|
||||||
|
statusMessage = "Transfer error: ${e.message}",
|
||||||
|
isError = true
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onResult(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val err = fileTransferCoordinator?.startTransfer(config)
|
|
||||||
if (err != null) {
|
|
||||||
_ftState.value = FTProgressState(
|
|
||||||
isActive = false,
|
|
||||||
statusMessage = "Error: $err",
|
|
||||||
isError = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelFileTransfer() {
|
fun cancelFileTransfer() {
|
||||||
fileTransferCoordinator?.cancel()
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
fileTransferCoordinator?.cancel()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warning("Error cancelling transfer: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun typeChar(ch: Char) {
|
fun typeChar(ch: Char) {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ object AppSettings {
|
|||||||
private const val PREFS_NAME = "a3270_settings"
|
private const val PREFS_NAME = "a3270_settings"
|
||||||
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
|
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
|
||||||
private const val KEY_CURSOR_BLINK = "cursor_blink"
|
private const val KEY_CURSOR_BLINK = "cursor_blink"
|
||||||
|
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
|
||||||
|
|
||||||
|
private const val KEY_VERIFY_CERTS = "verify_certs"
|
||||||
|
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
|
||||||
|
|
||||||
private fun getPrefs(context: Context): SharedPreferences {
|
private fun getPrefs(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
@@ -27,4 +31,28 @@ object AppSettings {
|
|||||||
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
|
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
|
||||||
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
|
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isHapticFeedbackEnabled(context: Context): Boolean {
|
||||||
|
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
|
||||||
|
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isVerifyCertsEnabled(context: Context): Boolean {
|
||||||
|
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
|
||||||
|
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultGraphicsMode(context: Context): String {
|
||||||
|
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultGraphicsMode(context: Context, mode: String) {
|
||||||
|
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ data class SavedHost(
|
|||||||
val model: Int = 2,
|
val model: Int = 2,
|
||||||
val luName: String = "",
|
val luName: String = "",
|
||||||
val autoConnect: Boolean = false,
|
val autoConnect: Boolean = false,
|
||||||
val hostType: String = "TSO"
|
val hostType: String = "TSO",
|
||||||
|
val useTls: Boolean = false,
|
||||||
|
val tlsVerifyCert: Boolean = true,
|
||||||
|
val graphicsMode: String = "BOTH"
|
||||||
) {
|
) {
|
||||||
fun toJson(): JSONObject {
|
fun toJson(): JSONObject {
|
||||||
return JSONObject().apply {
|
return JSONObject().apply {
|
||||||
@@ -26,6 +29,9 @@ data class SavedHost(
|
|||||||
put("luName", luName)
|
put("luName", luName)
|
||||||
put("autoConnect", autoConnect)
|
put("autoConnect", autoConnect)
|
||||||
put("hostType", hostType)
|
put("hostType", hostType)
|
||||||
|
put("useTls", useTls)
|
||||||
|
put("tlsVerifyCert", tlsVerifyCert)
|
||||||
|
put("graphicsMode", graphicsMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +45,10 @@ data class SavedHost(
|
|||||||
model = json.optInt("model", 2),
|
model = json.optInt("model", 2),
|
||||||
luName = json.optString("luName", ""),
|
luName = json.optString("luName", ""),
|
||||||
autoConnect = json.optBoolean("autoConnect", false),
|
autoConnect = json.optBoolean("autoConnect", false),
|
||||||
hostType = json.optString("hostType", "TSO")
|
hostType = json.optString("hostType", "TSO"),
|
||||||
|
useTls = json.optBoolean("useTls", false),
|
||||||
|
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
|
||||||
|
graphicsMode = json.optString("graphicsMode", "BOTH")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +69,13 @@ object HostStorage {
|
|||||||
val jsonArray = JSONArray(jsonStr)
|
val jsonArray = JSONArray(jsonStr)
|
||||||
for (i in 0 until jsonArray.length()) {
|
for (i in 0 until jsonArray.length()) {
|
||||||
val obj = jsonArray.getJSONObject(i)
|
val obj = jsonArray.getJSONObject(i)
|
||||||
list.add(SavedHost.fromJson(obj))
|
val saved = SavedHost.fromJson(obj)
|
||||||
|
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
|
||||||
|
saved.copy(graphicsMode = "BOTH")
|
||||||
|
} else {
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
list.add(effective)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import org.pubvm.a3270.storage.SavedHost
|
|||||||
@Composable
|
@Composable
|
||||||
fun ConnectDialog(
|
fun ConnectDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String) -> Unit
|
onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String, useTls: Boolean, tlsVerifyCert: Boolean, graphicsMode: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
||||||
@@ -38,6 +38,9 @@ fun ConnectDialog(
|
|||||||
var luName by remember { mutableStateOf("") }
|
var luName by remember { mutableStateOf("") }
|
||||||
var autoConnect by remember { mutableStateOf(false) }
|
var autoConnect by remember { mutableStateOf(false) }
|
||||||
var hostType by remember { mutableStateOf("TSO") }
|
var hostType by remember { mutableStateOf("TSO") }
|
||||||
|
var useTls by remember { mutableStateOf(false) }
|
||||||
|
var tlsVerifyCert by remember { mutableStateOf(true) }
|
||||||
|
var graphicsMode by remember { mutableStateOf("BOTH") }
|
||||||
|
|
||||||
fun loadProfile(saved: SavedHost) {
|
fun loadProfile(saved: SavedHost) {
|
||||||
selectedHostId = saved.id
|
selectedHostId = saved.id
|
||||||
@@ -48,6 +51,9 @@ fun ConnectDialog(
|
|||||||
luName = saved.luName
|
luName = saved.luName
|
||||||
autoConnect = saved.autoConnect
|
autoConnect = saved.autoConnect
|
||||||
hostType = saved.hostType
|
hostType = saved.hostType
|
||||||
|
useTls = saved.useTls
|
||||||
|
tlsVerifyCert = saved.tlsVerifyCert
|
||||||
|
graphicsMode = saved.graphicsMode
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearFields() {
|
fun clearFields() {
|
||||||
@@ -59,10 +65,13 @@ fun ConnectDialog(
|
|||||||
luName = ""
|
luName = ""
|
||||||
autoConnect = false
|
autoConnect = false
|
||||||
hostType = "TSO"
|
hostType = "TSO"
|
||||||
|
useTls = false
|
||||||
|
tlsVerifyCert = true
|
||||||
|
graphicsMode = "BOTH"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentProfile(): SavedHost? {
|
fun saveCurrentProfile(): SavedHost? {
|
||||||
val port = portStr.toIntOrNull() ?: 23
|
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
|
||||||
if (host.isBlank()) return null
|
if (host.isBlank()) return null
|
||||||
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
||||||
val hostToSave = SavedHost(
|
val hostToSave = SavedHost(
|
||||||
@@ -73,7 +82,10 @@ fun ConnectDialog(
|
|||||||
model = modelNum,
|
model = modelNum,
|
||||||
luName = luName.trim(),
|
luName = luName.trim(),
|
||||||
autoConnect = autoConnect,
|
autoConnect = autoConnect,
|
||||||
hostType = hostType
|
hostType = hostType,
|
||||||
|
useTls = useTls,
|
||||||
|
tlsVerifyCert = tlsVerifyCert,
|
||||||
|
graphicsMode = graphicsMode
|
||||||
)
|
)
|
||||||
HostStorage.saveHost(context, hostToSave)
|
HostStorage.saveHost(context, hostToSave)
|
||||||
savedHosts = HostStorage.getSavedHosts(context)
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
@@ -124,7 +136,7 @@ fun ConnectDialog(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 440.dp)
|
.heightIn(max = 460.dp)
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(
|
Column(
|
||||||
@@ -142,6 +154,11 @@ fun ConnectDialog(
|
|||||||
) {
|
) {
|
||||||
savedHosts.forEach { saved ->
|
savedHosts.forEach { saved ->
|
||||||
val isSelected = (saved.id == selectedHostId)
|
val isSelected = (saved.id == selectedHostId)
|
||||||
|
val tlsTag = if (saved.useTls) {
|
||||||
|
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
|
||||||
|
} else ""
|
||||||
|
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(6.dp),
|
shape = RoundedCornerShape(6.dp),
|
||||||
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
|
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
|
||||||
@@ -165,8 +182,8 @@ fun ConnectDialog(
|
|||||||
color = Color.White
|
color = Color.White
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})" +
|
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$gfxTag" +
|
||||||
if (saved.autoConnect) " [Auto-Connect]" else "",
|
if (saved.autoConnect) " [Auto]" else "",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = Color.LightGray
|
color = Color.LightGray
|
||||||
)
|
)
|
||||||
@@ -176,7 +193,16 @@ fun ConnectDialog(
|
|||||||
onClick = {
|
onClick = {
|
||||||
loadProfile(saved)
|
loadProfile(saved)
|
||||||
val hostToConn = saveCurrentProfile() ?: saved
|
val hostToConn = saveCurrentProfile() ?: saved
|
||||||
onConnect(hostToConn.host, hostToConn.port, hostToConn.model, hostToConn.luName, hostToConn.hostType)
|
onConnect(
|
||||||
|
hostToConn.host,
|
||||||
|
hostToConn.port,
|
||||||
|
hostToConn.model,
|
||||||
|
hostToConn.luName,
|
||||||
|
hostToConn.hostType,
|
||||||
|
hostToConn.useTls,
|
||||||
|
hostToConn.tlsVerifyCert,
|
||||||
|
hostToConn.graphicsMode
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
) {
|
) {
|
||||||
@@ -230,7 +256,7 @@ fun ConnectDialog(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = portStr,
|
value = portStr,
|
||||||
onValueChange = { portStr = it },
|
onValueChange = { portStr = it },
|
||||||
label = { Text("Port (default 23)") },
|
label = { Text("Port (default 23 or 992)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -265,6 +291,74 @@ fun ConnectDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graphics Mode
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Graphics:", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
listOf(
|
||||||
|
"PROGRAMMED_SYMBOLS" to "Symbols",
|
||||||
|
"VECTOR_GRAPHICS" to "Vector",
|
||||||
|
"BOTH" to "Both",
|
||||||
|
"NONE" to "None"
|
||||||
|
).forEach { (modeKey, modeLabel) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = (graphicsMode.equals(modeKey, ignoreCase = true)),
|
||||||
|
onClick = { graphicsMode = modeKey },
|
||||||
|
label = { Text(modeLabel, fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS / SSL Checkbox
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
val newTls = !useTls
|
||||||
|
useTls = newTls
|
||||||
|
if (newTls && portStr.trim() == "23") {
|
||||||
|
portStr = "992"
|
||||||
|
} else if (!newTls && portStr.trim() == "992") {
|
||||||
|
portStr = "23"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = useTls,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
useTls = checked
|
||||||
|
if (checked && portStr.trim() == "23") {
|
||||||
|
portStr = "992"
|
||||||
|
} else if (!checked && portStr.trim() == "992") {
|
||||||
|
portStr = "23"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Certificate Checkbox
|
||||||
|
if (useTls) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp)
|
||||||
|
.clickable { tlsVerifyCert = !tlsVerifyCert }
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = tlsVerifyCert,
|
||||||
|
onCheckedChange = { tlsVerifyCert = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Verify Server Certificate", fontSize = 12.sp, color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = luName,
|
value = luName,
|
||||||
onValueChange = { luName = it },
|
onValueChange = { luName = it },
|
||||||
@@ -309,7 +403,16 @@ fun ConnectDialog(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val saved = saveCurrentProfile()
|
val saved = saveCurrentProfile()
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
onConnect(saved.host, saved.port, saved.model, saved.luName, saved.hostType)
|
onConnect(
|
||||||
|
saved.host,
|
||||||
|
saved.port,
|
||||||
|
saved.model,
|
||||||
|
saved.luName,
|
||||||
|
saved.hostType,
|
||||||
|
saved.useTls,
|
||||||
|
saved.tlsVerifyCert,
|
||||||
|
saved.graphicsMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
package org.pubvm.a3270.ui
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import org.lib3270j.ConnectionState
|
import org.lib3270j.ConnectionState
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import org.lib3270j.protocol.DS3270Constants.*
|
|||||||
fun TwoRowKeyBar(
|
fun TwoRowKeyBar(
|
||||||
connectionState: ConnectionState,
|
connectionState: ConnectionState,
|
||||||
isShiftPressed: Boolean = false,
|
isShiftPressed: Boolean = false,
|
||||||
|
hapticFeedbackEnabled: Boolean = true,
|
||||||
onClearShift: () -> Unit = {},
|
onClearShift: () -> Unit = {},
|
||||||
onConnectClick: () -> Unit,
|
onConnectClick: () -> Unit,
|
||||||
onDisconnectClick: () -> Unit,
|
onDisconnectClick: () -> Unit,
|
||||||
@@ -58,6 +59,7 @@ fun TwoRowKeyBar(
|
|||||||
label = "⋮",
|
label = "⋮",
|
||||||
color = Color(0xFF343A40),
|
color = Color(0xFF343A40),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = { menuExpanded = true }
|
onClick = { menuExpanded = true }
|
||||||
)
|
)
|
||||||
@@ -106,6 +108,7 @@ fun TwoRowKeyBar(
|
|||||||
label = tabLabel,
|
label = tabLabel,
|
||||||
color = Color(0xFF1C7ED6),
|
color = Color(0xFF1C7ED6),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isShiftPressed) {
|
if (isShiftPressed) {
|
||||||
@@ -118,34 +121,34 @@ fun TwoRowKeyBar(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 3. RESET
|
// 3. RESET
|
||||||
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset)
|
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
|
||||||
|
|
||||||
// 4. ENTER
|
// 4. ENTER
|
||||||
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
||||||
|
|
||||||
// 5. CLEAR
|
// 5. CLEAR
|
||||||
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
||||||
|
|
||||||
// 6. PA1
|
// 6. PA1
|
||||||
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
||||||
|
|
||||||
// 7. PA2
|
// 7. PA2
|
||||||
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
||||||
|
|
||||||
// 8. PA3
|
// 8. PA3
|
||||||
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
||||||
|
|
||||||
// 9. Left Navigation
|
// 9. Left Navigation
|
||||||
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
||||||
|
|
||||||
// 10. Up Navigation
|
// 10. Up Navigation
|
||||||
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
||||||
|
|
||||||
// 11. Down Navigation
|
// 11. Down Navigation
|
||||||
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
||||||
|
|
||||||
// 12. Right Navigation
|
// 12. Right Navigation
|
||||||
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
||||||
@@ -174,6 +177,7 @@ fun TwoRowKeyBar(
|
|||||||
label = "F$i",
|
label = "F$i",
|
||||||
color = Color(0xFF364FC7),
|
color = Color(0xFF364FC7),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = {
|
onClick = {
|
||||||
onSendAid(aid)
|
onSendAid(aid)
|
||||||
@@ -193,10 +197,17 @@ private fun KeyButton(
|
|||||||
color: Color,
|
color: Color,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hapticFeedbackEnabled: Boolean = true,
|
||||||
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||||
) {
|
) {
|
||||||
|
val view = LocalView.current
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
if (hapticFeedbackEnabled) {
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
|
}
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
color = color,
|
color = color,
|
||||||
shadowElevation = 1.dp,
|
shadowElevation = 1.dp,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ fun OiaStatusBar(
|
|||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
rows: Int,
|
rows: Int,
|
||||||
cols: Int,
|
cols: Int,
|
||||||
|
isTls: Boolean = false,
|
||||||
|
isTlsVerified: Boolean = true,
|
||||||
|
graphicsMode: String = "NONE",
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
||||||
@@ -76,6 +79,46 @@ fun OiaStatusBar(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TLS Badge
|
||||||
|
if (connectionState.isConnected() && isTls) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Surface(
|
||||||
|
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
|
||||||
|
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GFX Mode Badge
|
||||||
|
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Surface(
|
||||||
|
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when (graphicsMode) {
|
||||||
|
"VECTOR_GRAPHICS" -> "GOCA"
|
||||||
|
"PROGRAMMED_SYMBOLS" -> "PS"
|
||||||
|
else -> "GFX"
|
||||||
|
},
|
||||||
|
color = Color(0xFF22B8CF),
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.pubvm.a3270.ui
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -16,11 +18,17 @@ import androidx.compose.ui.window.Dialog
|
|||||||
fun SettingsDialog(
|
fun SettingsDialog(
|
||||||
initialMaskHiddenInput: Boolean,
|
initialMaskHiddenInput: Boolean,
|
||||||
initialCursorBlink: Boolean,
|
initialCursorBlink: Boolean,
|
||||||
|
initialHapticFeedback: Boolean = true,
|
||||||
|
initialVerifyCerts: Boolean = true,
|
||||||
|
initialDefaultGraphicsMode: String = "BOTH",
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean) -> Unit
|
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
|
||||||
) {
|
) {
|
||||||
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
|
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
|
||||||
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
|
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
|
||||||
|
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
|
||||||
|
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
|
||||||
|
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
Card(
|
Card(
|
||||||
@@ -33,6 +41,7 @@ fun SettingsDialog(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(20.dp)
|
.padding(20.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -106,6 +115,109 @@ fun SettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Setting 3: Button Haptic Feedback
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Button Haptic Feedback",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = hapticFeedback,
|
||||||
|
onCheckedChange = { hapticFeedback = it },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = Color(0xFF2B8A3E)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Setting 4: Overall TLS Certificate Verification
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Verify TLS Certificates",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = verifyCerts,
|
||||||
|
onCheckedChange = { verifyCerts = it },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = Color(0xFF2B8A3E)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Setting 5: Default Graphics Support Mode
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Default Graphics Mode",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Configure default vector graphics (GOCA) and Programmed Symbols (APL) mode.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
"BOTH" to "Both",
|
||||||
|
"VECTOR_GRAPHICS" to "Vector",
|
||||||
|
"PROGRAMMED_SYMBOLS" to "Symbols",
|
||||||
|
"NONE" to "None"
|
||||||
|
).forEach { (modeKey, modeLabel) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = defaultGraphicsMode.equals(modeKey, ignoreCase = true),
|
||||||
|
onClick = { defaultGraphicsMode = modeKey },
|
||||||
|
label = { Text(modeLabel, fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Action Buttons
|
// Action Buttons
|
||||||
@@ -119,7 +231,7 @@ fun SettingsDialog(
|
|||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSave(maskHiddenInput, cursorBlink)
|
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ fun TerminalView(
|
|||||||
cols: Int,
|
cols: Int,
|
||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
screenVersion: Long,
|
screenVersion: Long,
|
||||||
|
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
|
||||||
|
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
|
||||||
maskHiddenFields: Boolean = true,
|
maskHiddenFields: Boolean = true,
|
||||||
blinkCursor: Boolean = true,
|
blinkCursor: Boolean = true,
|
||||||
onTapAddress: (Int) -> Unit,
|
onTapAddress: (Int) -> Unit,
|
||||||
@@ -211,6 +213,8 @@ fun TerminalView(
|
|||||||
var isBold = false
|
var isBold = false
|
||||||
var isUnderline = false
|
var isUnderline = false
|
||||||
var isReverse = false
|
var isReverse = false
|
||||||
|
var csVal = 0
|
||||||
|
var ecVal = 0
|
||||||
|
|
||||||
if (buf != null) {
|
if (buf != null) {
|
||||||
val ea = buf.getCell(addr)
|
val ea = buf.getCell(addr)
|
||||||
@@ -225,6 +229,10 @@ fun TerminalView(
|
|||||||
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
||||||
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
||||||
|
|
||||||
|
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
|
||||||
|
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
|
||||||
|
ecVal = ea.ec.toInt() and 0xFF
|
||||||
|
|
||||||
// Intensity
|
// Intensity
|
||||||
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
||||||
isBold = true
|
isBold = true
|
||||||
@@ -290,7 +298,28 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (charVal != ' ') {
|
// Draw Programmed Symbol (PS / APL) if defined
|
||||||
|
var drawnAsPs = false
|
||||||
|
if (csVal >= 0x40 && programSymbolManager != null) {
|
||||||
|
val slot = programSymbolManager.getSymbol(csVal, ecVal)
|
||||||
|
if (slot != null) {
|
||||||
|
val symWidth = slot.width
|
||||||
|
val symHeight = slot.height
|
||||||
|
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
|
||||||
|
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
drawContext.canvas.nativeCanvas.drawBitmap(
|
||||||
|
bmp,
|
||||||
|
null,
|
||||||
|
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
drawnAsPs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawnAsPs && charVal != ' ') {
|
||||||
paint.color = fgColor.toArgb()
|
paint.color = fgColor.toArgb()
|
||||||
paint.isFakeBoldText = isBold
|
paint.isFakeBoldText = isBold
|
||||||
val fontMetrics = paint.fontMetrics
|
val fontMetrics = paint.fontMetrics
|
||||||
@@ -314,6 +343,25 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw Vector Graphics Plane overlay if present
|
||||||
|
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
||||||
|
val gridW = (cols * cellWidth).toInt()
|
||||||
|
val gridH = (rows * cellHeight).toInt()
|
||||||
|
if (gridW > 0 && gridH > 0) {
|
||||||
|
graphicsPlane.resize(gridW, gridH)
|
||||||
|
val rgb = graphicsPlane.rgbBuffer
|
||||||
|
if (rgb != null) {
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
drawContext.canvas.nativeCanvas.drawBitmap(
|
||||||
|
bmp,
|
||||||
|
null,
|
||||||
|
android.graphics.RectF(0f, 0f, gridW.toFloat(), gridH.toFloat()),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context Menu Popup
|
// Context Menu Popup
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UntrustedCertificateDialog(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
chain: Array<X509Certificate>?,
|
||||||
|
exception: CertificateException?,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onReject: () -> Unit
|
||||||
|
) {
|
||||||
|
val cert = chain?.firstOrNull()
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||||
|
|
||||||
|
val detailsText = buildString {
|
||||||
|
if (exception != null) {
|
||||||
|
append("Validation Error:\n ")
|
||||||
|
append(exception.localizedMessage ?: exception.message ?: exception.toString())
|
||||||
|
append("\n\n")
|
||||||
|
}
|
||||||
|
if (cert != null) {
|
||||||
|
try {
|
||||||
|
append("Subject:\n ")
|
||||||
|
append(cert.subjectX500Principal?.name ?: cert.subjectDN?.name ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Subject: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Issuer:\n ")
|
||||||
|
append(cert.issuerX500Principal?.name ?: cert.issuerDN?.name ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Issuer: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Validity:\n From: ")
|
||||||
|
append(cert.notBefore?.let { sdf.format(it) } ?: "Unknown")
|
||||||
|
append("\n To: ")
|
||||||
|
append(cert.notAfter?.let { sdf.format(it) } ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Validity: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Serial Number:\n ")
|
||||||
|
append(cert.serialNumber?.toString(16)?.uppercase(Locale.US) ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Serial Number: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("SHA-256 Fingerprint:\n ")
|
||||||
|
append(computeFingerprint(cert, "SHA-256"))
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("SHA-256 Fingerprint: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("SHA-1 Fingerprint:\n ")
|
||||||
|
append(computeFingerprint(cert, "SHA-1"))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("SHA-1 Fingerprint: ${t.message ?: "Unavailable"}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append("No peer certificate information available.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onReject,
|
||||||
|
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.96f)
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚠️",
|
||||||
|
fontSize = 24.sp
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Untrusted SSL/TLS Certificate",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFFB450)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "The server certificate for $host:$port could not be verified.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable Certificate Details Box
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 280.dp)
|
||||||
|
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
|
||||||
|
.border(1.dp, Color(0xFF333333), RoundedCornerShape(6.dp))
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Text(
|
||||||
|
text = detailsText,
|
||||||
|
color = Color(0xFFDCDCDC),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
lineHeight = 15.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onReject) {
|
||||||
|
Text("Cancel Connection", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onAccept,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB46428))
|
||||||
|
) {
|
||||||
|
Text("Connect Anyway", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeFingerprint(cert: X509Certificate, algorithm: String): String {
|
||||||
|
return try {
|
||||||
|
val md = MessageDigest.getInstance(algorithm)
|
||||||
|
val digest = md.digest(cert.encoded)
|
||||||
|
digest.joinToString(":") { "%02X".format(it.toInt() and 0xFF) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
"Unable to compute fingerprint: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user