659 lines
26 KiB
Kotlin
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()
|
|
}
|
|
}
|