Files
a3270/src/main/java/org/pubvm/a3270/TerminalViewModel.kt
T
rudi 17f8cae925
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
Release a3270 / Build & Publish Release (push) Successful in 6m53s
Debugging v0.2 features from j3270
2026-08-21 12:52:29 -04:00

659 lines
26 KiB
Kotlin

package org.pubvm.a3270
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
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
import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings
import java.io.File
import java.util.concurrent.Executors
import java.util.logging.Logger
sealed interface TerminalInputAction {
data class TypeText(val text: String) : TerminalInputAction
data class SendAid(val aidCode: Int) : TerminalInputAction
data object Backspace : TerminalInputAction
data object Tab : TerminalInputAction
data object BackTab : TerminalInputAction
data object CursorLeft : TerminalInputAction
data object CursorRight : TerminalInputAction
data object CursorUp : TerminalInputAction
data object CursorDown : TerminalInputAction
data object Reset : 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,
val bytesTransferred: Long = 0L,
val statusMessage: String = "",
val isError: Boolean = false
)
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
var isAppInBackground: Boolean = false
// Dedicated single-thread FIFO queue for all local keyboard and cursor actions
private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
// Non-blocking FIFO input channel buffer
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _screenBuffer = MutableStateFlow<ScreenBuffer?>(null)
val screenBuffer: StateFlow<ScreenBuffer?> = _screenBuffer.asStateFlow()
private val _rows = MutableStateFlow(24)
val rows: StateFlow<Int> = _rows.asStateFlow()
private val _cols = MutableStateFlow(80)
val cols: StateFlow<Int> = _cols.asStateFlow()
private val _cursorAddress = MutableStateFlow(0)
val cursorAddress: StateFlow<Int> = _cursorAddress.asStateFlow()
private val _oiaText = MutableStateFlow("Disconnected")
val oiaText: StateFlow<String> = _oiaText.asStateFlow()
private val _screenVersion = MutableStateFlow(0L)
val screenVersion: StateFlow<Long> = _screenVersion.asStateFlow()
private val _isKeyboardLocked = MutableStateFlow(false)
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
private val _maskHiddenInput = MutableStateFlow(AppSettings.isMaskHiddenInputEnabled(application))
val maskHiddenInput: StateFlow<Boolean> = _maskHiddenInput.asStateFlow()
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())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
var activeHostType: String = "TSO"
private set
fun getClient(): Telnet3270Client? = client
private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false
init {
// Start continuous background input buffer consumer
viewModelScope.launch(inputDispatcher) {
for (action in inputChannel) {
processInputAction(action)
// Drain any additional pending actions in the buffer before updating the UI
while (true) {
val next = inputChannel.tryReceive().getOrNull() ?: break
processInputAction(next)
}
val c = client
if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress
}
_screenVersion.value = System.currentTimeMillis()
}
}
}
private fun processInputAction(action: TerminalInputAction) {
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
try {
when (action) {
is TerminalInputAction.TypeText -> {
ip.isKeyboardLocked = false
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
}
}
}
is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false
ip.backspace()
}
is TerminalInputAction.Tab -> {
ip.tab()
}
is TerminalInputAction.BackTab -> {
ip.backTab()
}
is TerminalInputAction.CursorLeft -> {
ip.cursorLeft()
}
is TerminalInputAction.CursorRight -> {
ip.cursorRight()
}
is TerminalInputAction.CursorUp -> {
ip.cursorUp()
}
is TerminalInputAction.CursorDown -> {
ip.cursorDown()
}
is TerminalInputAction.Reset -> {
ip.reset()
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
}
}
}
} catch (e: Exception) {
log.warning("Error processing input action: ${e.message}")
}
}
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 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
hasInitialScreenLoaded = false
fileTransferCoordinator = null
_ftState.value = FTProgressState()
viewModelScope.launch(Dispatchers.IO) {
try {
// 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
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
else -> TerminalModel.IBM_3279_2
}
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)
this@TerminalViewModel.client = newClient
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 ($effectiveHost - $activeHostType)$tlsSuffix"
} else if (newState.isHalfConnected()) {
"Connecting..."
} else {
"Disconnected"
}
}
override fun onConnectionError(message: String) {
log.warning("Connection error: $message")
_oiaText.value = "Error: $message"
}
})
newClient.inputProcessor.setLockStateListener { locked ->
_isKeyboardLocked.value = locked
}
newClient.addScreenUpdateListener(object : ScreenUpdateListener {
override fun onScreenUpdated() {
val buf = newClient.screenBuffer
_screenBuffer.value = buf
if (buf != null) {
_rows.value = buf.rows
_cols.value = buf.cols
_cursorAddress.value = buf.cursorAddress
}
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated()
val newHash = computeScreenContentHash(buf)
val contentChanged = (newHash != lastScreenContentHash)
if (!hasInitialScreenLoaded) {
if (buf != null && newHash != 0) {
hasInitialScreenLoaded = true
lastScreenContentHash = newHash
}
} else if (contentChanged) {
lastScreenContentHash = newHash
if (isAppInBackground) {
val snippet = extractScreenSnippet(buf)
val activeHost = currentHost.ifBlank { "Mainframe" }
TerminalService.notifyScreenUpdate(getApplication(), activeHost, snippet)
}
}
}
override fun onSoundAlarm() {
log.fine("Sound Alarm")
}
})
_rows.value = model.defaultRows
_cols.value = model.defaultCols
_screenBuffer.value = newClient.screenBuffer
newClient.connect()
} catch (e: Throwable) {
log.severe("Failed to connect: ${e.message}")
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
}
}
}
fun disconnect() {
viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect()
client = null
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Disconnected"
_ftState.value = FTProgressState()
} catch (e: Exception) {
log.warning("Error disconnecting: ${e.message}")
}
}
}
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
}
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
}
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
)
}
})
}
val err = fileTransferCoordinator?.startTransfer(config)
if (err != null) {
_ftState.value = FTProgressState(
isActive = false,
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)
}
}
}
}
fun cancelFileTransfer() {
viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.cancel()
} catch (e: Exception) {
log.warning("Error cancelling transfer: ${e.message}")
}
}
}
fun typeChar(ch: Char) {
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
}
fun typeString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun pasteString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun backspace() {
inputChannel.trySend(TerminalInputAction.Backspace)
}
fun tab() {
inputChannel.trySend(TerminalInputAction.Tab)
}
fun backTab() {
inputChannel.trySend(TerminalInputAction.BackTab)
}
fun resetKeyboard() {
inputChannel.trySend(TerminalInputAction.Reset)
}
fun sendAid(aidCode: Int) {
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
}
fun setCursor(baddr: Int) {
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
}
fun cursorLeft() {
inputChannel.trySend(TerminalInputAction.CursorLeft)
}
fun cursorUp() {
inputChannel.trySend(TerminalInputAction.CursorUp)
}
fun cursorDown() {
inputChannel.trySend(TerminalInputAction.CursorDown)
}
fun cursorRight() {
inputChannel.trySend(TerminalInputAction.CursorRight)
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received"
val rows = buf.rows
val cols = buf.cols
if (rows <= 0 || cols <= 0) return "Mainframe screen update received"
val curAddr = _cursorAddress.value
val cursorRow = (curAddr / cols).coerceIn(0, rows - 1)
// 1. Check bottom 3 lines first (where 3270 status, errors, reader & I/S MSGs land)
val minBottomRow = (rows - 3).coerceAtLeast(1)
for (r in (rows - 1) downTo minBottomRow) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4 && !line.startsWith("***") && !line.startsWith("===")) {
return line
}
}
// 2. Check cursor row if not row 0
if (cursorRow > 0) {
val cursorLine = getLineText(buf, cursorRow, cols).trim()
if (cursorLine.length >= 4) {
return cursorLine
}
}
// 3. Scan rows from bottom to top, skipping static header row 0
for (r in (rows - 1) downTo 1) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4) {
return line
}
}
return getLineText(buf, 0, cols).trim().ifBlank { "Mainframe screen update received" }
}
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder()
for (c in 0 until cols) {
val addr = row * cols + c
val cell = buf.getCell(addr)
if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) {
sb.append(ch)
} else {
sb.append(' ')
}
} else {
sb.append(' ')
}
}
return sb.toString().replace(Regex("\\s+"), " ").trim()
}
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0
val sb = StringBuilder()
val total = buf.rows * buf.cols
for (i in 0 until total) {
val cell = buf.getCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4)
}
}
return sb.toString().hashCode()
}
override fun onCleared() {
super.onCleared()
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect()
inputChannel.close()
inputExecutor.shutdown()
}
}