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
|
||||
|
||||
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
||||
[](https://git.hugfreevikings.wtf/rudi/a3270/releases)
|
||||
[](https://developer.android.com)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -110,3 +109,6 @@ I have contributed no code to this project, it was entirely written by LLMs with
|
||||
- Claude Opus 4.6
|
||||
- Gemini 3.1 Pro
|
||||
- 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"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
versionCode 4
|
||||
versionName "0.1.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -42,6 +42,13 @@ android {
|
||||
if (findProject(':lib3270j') != null) {
|
||||
project(':lib3270j') {
|
||||
apply plugin: 'java-library'
|
||||
dependencies {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = 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.TwoRowKeyBar
|
||||
|
||||
import org.pubvm.a3270.ui.UntrustedCertificateDialog
|
||||
|
||||
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 isShiftPressedState = mutableStateOf(false)
|
||||
|
||||
@@ -147,6 +178,12 @@ fun MainScreen(
|
||||
val screenVersion by viewModel.screenVersion.collectAsState()
|
||||
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) }
|
||||
@@ -173,7 +210,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +249,8 @@ fun MainScreen(
|
||||
cols = cols,
|
||||
cursorAddr = cursorAddr,
|
||||
screenVersion = screenVersion,
|
||||
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
||||
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
||||
maskHiddenFields = maskHiddenInput,
|
||||
blinkCursor = cursorBlink,
|
||||
onTapAddress = { addr ->
|
||||
@@ -249,6 +297,7 @@ fun MainScreen(
|
||||
TwoRowKeyBar(
|
||||
connectionState = connectionState,
|
||||
isShiftPressed = isShiftPressed,
|
||||
hapticFeedbackEnabled = hapticFeedback,
|
||||
onClearShift = onClearShift,
|
||||
onConnectClick = { showConnectDialog = true },
|
||||
onDisconnectClick = { viewModel.disconnect() },
|
||||
@@ -296,7 +345,10 @@ fun MainScreen(
|
||||
oiaText = oiaText,
|
||||
cursorAddr = cursorAddr,
|
||||
rows = rows,
|
||||
cols = cols
|
||||
cols = cols,
|
||||
isTls = isTlsActive,
|
||||
isTlsVerified = isTlsVerified,
|
||||
graphicsMode = defaultGraphicsMode
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -304,9 +356,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()
|
||||
}
|
||||
)
|
||||
@@ -318,9 +370,10 @@ fun MainScreen(
|
||||
ftProgressState = ftState,
|
||||
onDismiss = { showFtDialog = false },
|
||||
onStartTransfer = { config ->
|
||||
val error = viewModel.startFileTransfer(config)
|
||||
if (error != null) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||
viewModel.startFileTransfer(config) { error ->
|
||||
if (error != null) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancelTransfer = {
|
||||
@@ -333,12 +386,31 @@ fun MainScreen(
|
||||
SettingsDialog(
|
||||
initialMaskHiddenInput = maskHiddenInput,
|
||||
initialCursorBlink = cursorBlink,
|
||||
initialHapticFeedback = hapticFeedback,
|
||||
initialVerifyCerts = verifyCerts,
|
||||
initialDefaultGraphicsMode = defaultGraphicsMode,
|
||||
onDismiss = { showSettingsDialog = false },
|
||||
onSave = { mask, blink ->
|
||||
onSave = { mask, blink, haptic, verify, gfx ->
|
||||
showSettingsDialog = false
|
||||
viewModel.updateSettings(mask, blink)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.lib3270j.ConnectionConfig
|
||||
import org.lib3270j.ConnectionState
|
||||
import org.lib3270j.Telnet3270Client
|
||||
@@ -41,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,
|
||||
@@ -92,6 +101,24 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
|
||||
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
|
||||
private var fileTransferCoordinator: FileTransfer? = null
|
||||
private val _ftState = MutableStateFlow(FTProgressState())
|
||||
@@ -103,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
|
||||
|
||||
@@ -191,15 +220,41 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSettings(maskHidden: Boolean, blink: Boolean) {
|
||||
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
|
||||
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
|
||||
_maskHiddenInput.value = maskHidden
|
||||
_cursorBlink.value = blink
|
||||
fun resolveUntrustedCert(accept: Boolean) {
|
||||
try {
|
||||
val prompt = _untrustedCertPrompt.value
|
||||
if (prompt != null) {
|
||||
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") {
|
||||
if (_connectionState.value.isConnected()) return
|
||||
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",
|
||||
useTls: Boolean = false,
|
||||
tlsVerifyCert: Boolean = true,
|
||||
graphicsModeStr: String = "BOTH"
|
||||
) {
|
||||
currentHost = host
|
||||
activeHostType = hostType
|
||||
lastScreenContentHash = 0
|
||||
@@ -209,7 +264,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
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) {
|
||||
3 -> TerminalModel.IBM_3279_3
|
||||
@@ -218,10 +280,50 @@ 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 = 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)
|
||||
@@ -230,8 +332,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 {
|
||||
@@ -292,10 +397,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
_screenBuffer.value = newClient.screenBuffer
|
||||
|
||||
newClient.connect()
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
log.severe("Failed to connect: ${e.message}")
|
||||
_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? {
|
||||
val c = client ?: return "Terminal is not connected."
|
||||
|
||||
// Resolve local path if relative (default to app files or download directory)
|
||||
val rawPath = config.localFilename
|
||||
if (!rawPath.startsWith("/")) {
|
||||
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
||||
val resolvedFile = File(appFilesDir, rawPath)
|
||||
config.localFilename = resolvedFile.absolutePath
|
||||
fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
|
||||
val c = client
|
||||
if (c == null || !_connectionState.value.isConnected()) {
|
||||
val err = "Terminal is not connected."
|
||||
_ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
|
||||
onResult(err)
|
||||
return
|
||||
}
|
||||
|
||||
if (fileTransferCoordinator == null) {
|
||||
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
|
||||
override fun onTransferStarted() {
|
||||
_ftState.value = FTProgressState(
|
||||
isActive = true,
|
||||
isRunning = false,
|
||||
bytesTransferred = 0L,
|
||||
statusMessage = "Starting IND\$FILE transfer..."
|
||||
)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Resolve local path if relative (default to app files or download directory)
|
||||
val rawPath = config.localFilename
|
||||
if (!rawPath.startsWith("/")) {
|
||||
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
||||
val resolvedFile = File(appFilesDir, rawPath)
|
||||
config.localFilename = resolvedFile.absolutePath
|
||||
}
|
||||
|
||||
override fun onTransferRunning() {
|
||||
_ftState.value = _ftState.value.copy(
|
||||
isRunning = true,
|
||||
statusMessage = "Transfer in progress..."
|
||||
)
|
||||
if (fileTransferCoordinator == null) {
|
||||
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
|
||||
override fun onTransferStarted() {
|
||||
_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) {
|
||||
_ftState.value = _ftState.value.copy(
|
||||
bytesTransferred = bytes,
|
||||
statusMessage = "Transferring: $bytes bytes"
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTransferComplete(message: String) {
|
||||
val err = fileTransferCoordinator?.startTransfer(config)
|
||||
if (err != null) {
|
||||
_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",
|
||||
statusMessage = "Error: $err",
|
||||
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() {
|
||||
fileTransferCoordinator?.cancel()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
fileTransferCoordinator?.cancel()
|
||||
} catch (e: Exception) {
|
||||
log.warning("Error cancelling transfer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun typeChar(ch: Char) {
|
||||
|
||||
@@ -7,6 +7,10 @@ object AppSettings {
|
||||
private const val PREFS_NAME = "a3270_settings"
|
||||
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
|
||||
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)
|
||||
@@ -27,4 +31,28 @@ object AppSettings {
|
||||
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
|
||||
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 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +69,13 @@ object HostStorage {
|
||||
val jsonArray = JSONArray(jsonStr)
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
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) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -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(
|
||||
"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(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import org.lib3270j.ConnectionState
|
||||
import org.lib3270j.protocol.DS3270Constants.*
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.lib3270j.protocol.DS3270Constants.*
|
||||
fun TwoRowKeyBar(
|
||||
connectionState: ConnectionState,
|
||||
isShiftPressed: Boolean = false,
|
||||
hapticFeedbackEnabled: Boolean = true,
|
||||
onClearShift: () -> Unit = {},
|
||||
onConnectClick: () -> Unit,
|
||||
onDisconnectClick: () -> Unit,
|
||||
@@ -58,6 +59,7 @@ fun TwoRowKeyBar(
|
||||
label = "⋮",
|
||||
color = Color(0xFF343A40),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = { menuExpanded = true }
|
||||
)
|
||||
@@ -106,6 +108,7 @@ fun TwoRowKeyBar(
|
||||
label = tabLabel,
|
||||
color = Color(0xFF1C7ED6),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
if (isShiftPressed) {
|
||||
@@ -118,34 +121,34 @@ fun TwoRowKeyBar(
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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)))
|
||||
@@ -174,6 +177,7 @@ fun TwoRowKeyBar(
|
||||
label = "F$i",
|
||||
color = Color(0xFF364FC7),
|
||||
modifier = Modifier.weight(1f),
|
||||
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||
innerPaddingHorizontal = 0.dp,
|
||||
onClick = {
|
||||
onSendAid(aid)
|
||||
@@ -193,10 +197,17 @@ private fun KeyButton(
|
||||
color: Color,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hapticFeedbackEnabled: Boolean = true,
|
||||
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||
) {
|
||||
val view = LocalView.current
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
onClick = {
|
||||
if (hapticFeedbackEnabled) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
}
|
||||
onClick()
|
||||
},
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = color,
|
||||
shadowElevation = 1.dp,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.pubvm.a3270.ui
|
||||
|
||||
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.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -16,11 +18,17 @@ import androidx.compose.ui.window.Dialog
|
||||
fun SettingsDialog(
|
||||
initialMaskHiddenInput: Boolean,
|
||||
initialCursorBlink: Boolean,
|
||||
initialHapticFeedback: Boolean = true,
|
||||
initialVerifyCerts: Boolean = true,
|
||||
initialDefaultGraphicsMode: String = "BOTH",
|
||||
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 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(
|
||||
@@ -33,6 +41,7 @@ fun SettingsDialog(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp)
|
||||
) {
|
||||
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))
|
||||
|
||||
// Action Buttons
|
||||
@@ -119,7 +231,7 @@ fun SettingsDialog(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(maskHiddenInput, cursorBlink)
|
||||
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||
) {
|
||||
|
||||
@@ -58,6 +58,8 @@ fun TerminalView(
|
||||
cols: Int,
|
||||
cursorAddr: Int,
|
||||
screenVersion: Long,
|
||||
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
|
||||
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
|
||||
maskHiddenFields: Boolean = true,
|
||||
blinkCursor: Boolean = true,
|
||||
onTapAddress: (Int) -> Unit,
|
||||
@@ -211,6 +213,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 +229,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 +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.isFakeBoldText = isBold
|
||||
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
|
||||
|
||||
@@ -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