Refactor for consistency
Build and Test a3270 / Build Android APK (push) Successful in 6m54s

This commit is contained in:
2026-08-25 16:05:28 +00:00
parent aa008664bc
commit f6d69bf0ff
16 changed files with 71 additions and 71 deletions
@@ -0,0 +1,650 @@
package haus.nightmare.a3270
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.Context
import android.view.KeyCharacterMap
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar
import haus.nightmare.a3270.ui.SettingsDialog
import haus.nightmare.a3270.ui.TerminalInputView
import haus.nightmare.a3270.ui.TerminalView
import haus.nightmare.a3270.ui.TwoRowKeyBar
import haus.nightmare.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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
background = Color.Black,
surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
)
) {
MainScreen(
viewModel = viewModel,
isShiftPressed = isShiftPressedState.value,
onClearShift = { isShiftPressedState.value = false }
)
}
}
}
private val log = java.util.logging.Logger.getLogger("a3270-MainActivity")
private var deadKey: Int = 0
private fun getEffectiveUnicodeChar(event: KeyEvent): Int {
val cleanMeta = event.metaState and (
KeyEvent.META_SHIFT_ON or
KeyEvent.META_SHIFT_LEFT_ON or
KeyEvent.META_SHIFT_RIGHT_ON or
KeyEvent.META_CAPS_LOCK_ON
)
// 1. Try getUnicodeChar with cleanMeta
var u = event.getUnicodeChar(cleanMeta)
if (u > 0) return u
// 2. Try unicodeChar property
u = event.unicodeChar
if (u > 0) return u
// 3. Try KeyCharacterMap with cleanMeta
val kcm = event.keyCharacterMap ?: KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
u = kcm.get(event.keyCode, cleanMeta)
if (u > 0) return u
// 4. Try KeyCharacterMap with meta = 0
u = kcm.get(event.keyCode, 0)
if (u > 0) {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (shift) {
val c = u.toChar()
if (c.isLowerCase()) return c.uppercaseChar().code
}
return u
}
// 5. Try displayLabel as fallback
val label = event.displayLabel
if (label >= ' ') {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
return if (shift) label.uppercaseChar().code else label.lowercaseChar().code
}
return 0
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift
}
// Handle string / multi-character input (e.g. adb input text, barcode scanners, fast text input)
if (event.action == KeyEvent.ACTION_MULTIPLE) {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
val chars = event.characters
if (!chars.isNullOrEmpty()) {
viewModel.typeString(chars)
return true
}
} else {
val unicode = getEffectiveUnicodeChar(event)
if (unicode >= 32) {
val count = maxOf(1, event.repeatCount)
viewModel.typeString(unicode.toChar().toString().repeat(count))
return true
}
}
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
val ctrl = event.isCtrlPressed || (event.metaState and KeyEvent.META_CTRL_ON != 0)
val alt = event.isAltPressed || (event.metaState and KeyEvent.META_ALT_ON != 0)
// 1. Function Keys (F1..F12 -> PF1..PF12 or PF13..PF24 with Shift or Alt)
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift || alt) fNum + 12 else fNum
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
}
// 2. Ctrl Shortcuts
if (ctrl) {
when (keyCode) {
KeyEvent.KEYCODE_V -> {
pasteFromClipboard()
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_Z -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
}
}
// 3. Alt Shortcuts (PA keys, Clear, Reset)
if (alt) {
when (keyCode) {
KeyEvent.KEYCODE_1 -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_2 -> {
viewModel.sendAid(AID_PA2)
return true
}
KeyEvent.KEYCODE_3 -> {
viewModel.sendAid(AID_PA3)
return true
}
KeyEvent.KEYCODE_C -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.toggleLightPen()
val isLp = viewModel.isLightPenMode.value
Toast.makeText(this, "Light Pen: " + (if (isLp) "ON" else "OFF"), Toast.LENGTH_SHORT).show()
return true
}
}
}
// 4. Shift Shortcuts
if (shift && keyCode == KeyEvent.KEYCODE_INSERT) {
pasteFromClipboard()
return true
}
// 5. Standard Navigation & Special Keys
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.cursorLeft()
return true
}
KeyEvent.KEYCODE_DPAD_UP -> {
viewModel.cursorUp()
return true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.cursorDown()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.cursorRight()
return true
}
KeyEvent.KEYCODE_MOVE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
KeyEvent.KEYCODE_PAGE_UP -> {
viewModel.sendAid(AID_PF7)
return true
}
KeyEvent.KEYCODE_PAGE_DOWN -> {
viewModel.sendAid(AID_PF8)
return true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
viewModel.sendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_TAB -> {
if (shift) {
viewModel.backTab()
isShiftPressedState.value = false
} else {
viewModel.tab()
}
return true
}
KeyEvent.KEYCODE_DEL -> {
viewModel.backspace()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL -> {
viewModel.deleteChar()
return true
}
KeyEvent.KEYCODE_ESCAPE -> {
if (shift) {
viewModel.sendAid(AID_CLEAR)
isShiftPressedState.value = false
} else {
viewModel.resetKeyboard()
}
return true
}
KeyEvent.KEYCODE_BREAK -> {
viewModel.sendAid(AID_PA1)
return true
}
}
// 6. Alphanumeric & Symbol Character Input
val unicode = getEffectiveUnicodeChar(event)
log.info("dispatchKeyEvent DOWN: keyCode=$keyCode, metaState=${event.metaState}, unicode=$unicode, char='${if (unicode > 0) unicode.toChar() else ' '}'")
if (unicode > 0) {
if ((unicode and KeyCharacterMap.COMBINING_ACCENT) != 0) {
deadKey = unicode and KeyCharacterMap.COMBINING_ACCENT_MASK
return true
}
val charToType = if (deadKey != 0) {
val combined = KeyCharacterMap.getDeadChar(deadKey, unicode)
deadKey = 0
if (combined != 0) combined.toChar() else unicode.toChar()
} else {
unicode.toChar()
}
if (charToType >= ' ') {
log.info("dispatchKeyEvent typing char: '$charToType'")
viewModel.typeString(charToType.toString())
return true
}
} else {
log.info("dispatchKeyEvent unhandled DOWN keyCode=$keyCode, metaState=${event.metaState}")
}
}
if (event.action == KeyEvent.ACTION_UP) {
when (event.keyCode) {
KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> {
// Let super handle modifier tracking
}
else -> {
return true
}
}
}
return super.dispatchKeyEvent(event)
}
private fun pasteFromClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
val clip = clipboard?.primaryClip
if (clip != null && clip.itemCount > 0) {
val text = clip.getItemAt(0)?.coerceToText(this)?.toString()
if (!text.isNullOrEmpty()) {
viewModel.pasteString(text)
}
}
}
}
@Composable
fun MainScreen(
viewModel: TerminalViewModel,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {}
) {
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsState()
val screenBuffer by viewModel.screenBuffer.collectAsState()
val rows by viewModel.rows.collectAsState()
val cols by viewModel.cols.collectAsState()
val cursorAddr by viewModel.cursorAddress.collectAsState()
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
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()
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.isAppInBackground = true
} else if (event == Lifecycle.Event.ON_START) {
viewModel.isAppInBackground = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode
)
}
}
LaunchedEffect(connectionState) {
if (connectionState.isConnected()) {
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
TerminalService.start(context, activeHost)
} else {
TerminalService.stop(context)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Main 3270 Terminal Screen View
TerminalView(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
gocaDecoder = viewModel.getGocaDecoder(),
isLightPenMode = isLightPenMode,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { px, py ->
viewModel.handleGraphicTouch(px, py)
},
onPasteText = { text ->
viewModel.pasteString(text)
},
modifier = Modifier.weight(1f)
)
// Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
AndroidView(
factory = { ctx ->
TerminalInputView(ctx).apply {
onInputText = { text ->
viewModel.typeString(text)
onClearShift()
}
onSendAid = { aid ->
viewModel.sendAid(aid)
}
onBackspace = {
viewModel.backspace()
onClearShift()
}
onTab = {
viewModel.tab()
}
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
)
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
terminalInputViewRef?.showSoftKeyboard()
},
onReset = {
viewModel.resetKeyboard()
terminalInputViewRef?.showSoftKeyboard()
},
onTab = {
viewModel.tab()
terminalInputViewRef?.showSoftKeyboard()
},
onBackTab = {
viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorLeft = {
viewModel.cursorLeft()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorUp = {
viewModel.cursorUp()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorDown = {
viewModel.cursorDown()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorRight = {
viewModel.cursorRight()
terminalInputViewRef?.showSoftKeyboard()
}
)
// OIA Status Bar
OiaStatusBar(
connectionState = connectionState,
currentHost = viewModel.currentHost,
isKeyboardLocked = isKeyboardLocked,
oiaText = oiaText,
cursorAddr = cursorAddr,
rows = rows,
cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode,
isLightPenMode = isLightPenMode
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFtDialog) {
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error ->
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
}
)
}
if (showSettingsDialog) {
SettingsDialog(
initialMaskHiddenInput = maskHiddenInput,
initialCursorBlink = cursorBlink,
initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode,
onDismiss = { showSettingsDialog = false },
onSave = { mask, blink, haptic, verify, gfx ->
showSettingsDialog = false
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)
}
)
}
}
@@ -0,0 +1,739 @@
package haus.nightmare.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 haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.listener.ConnectionListener
import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.a3270.ft.FileTransfer
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.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 DeleteChar : 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 CursorHome : TerminalInputAction
data object EraseEof : TerminalInputAction
data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(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()
private val _isLightPenMode = MutableStateFlow(false)
val isLightPenMode: StateFlow<Boolean> = _isLightPenMode.asStateFlow()
fun toggleLightPen() {
_isLightPenMode.value = !_isLightPenMode.value
}
fun setLightPenMode(enabled: Boolean) {
_isLightPenMode.value = enabled
}
// File Transfer State
private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
@Volatile
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 -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
ip.isKeyboardLocked = false
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'")
}
}
}
is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false
ip.backspace()
}
is TerminalInputAction.DeleteChar -> {
ip.isKeyboardLocked = false
ip.deleteChar()
}
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.CursorHome -> {
ip.cursorHome()
}
is TerminalInputAction.EraseEof -> {
ip.isKeyboardLocked = false
ip.eraseEof()
}
is TerminalInputAction.EraseInput -> {
ip.isKeyboardLocked = false
ip.eraseInput()
}
is TerminalInputAction.Newline -> {
ip.newline()
}
is TerminalInputAction.Reset -> {
ip.reset()
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
}
}
is TerminalInputAction.LightPenSelect -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
val res = ip.lightPenSelect(action.baddr)
log.info("LightPenSelect at addr ${action.baddr}, result=$res")
}
}
}
} 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,
tn3270e: 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 effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = haus.nightmare.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
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
}
config.certificateVerifier = haus.nightmare.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 deleteChar() {
inputChannel.trySend(TerminalInputAction.DeleteChar)
}
fun tab() {
inputChannel.trySend(TerminalInputAction.Tab)
}
fun backTab() {
inputChannel.trySend(TerminalInputAction.BackTab)
}
fun resetKeyboard() {
inputChannel.trySend(TerminalInputAction.Reset)
}
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
}
fun eraseInput() {
inputChannel.trySend(TerminalInputAction.EraseInput)
}
fun newline() {
inputChannel.trySend(TerminalInputAction.Newline)
}
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)
}
fun cursorHome() {
inputChannel.trySend(TerminalInputAction.CursorHome)
}
fun selectLightPen(baddr: Int) {
inputChannel.trySend(TerminalInputAction.LightPenSelect(baddr))
}
fun getGocaDecoder(): haus.nightmare.lib3270j.graphics.GocaDecoder? = client?.gocaDecoder
fun handleGraphicTouch(px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) {
val c = client ?: return
val goca = c.gocaDecoder ?: return
if (goca.isGraphicsCursorActive) {
goca.setGraphicCursorFromPixel(px, py)
c.inputProcessor.sendGraphicMouseAid(
AID_ENTER,
button,
shift,
ctrl
)
_screenVersion.value = System.currentTimeMillis()
}
}
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()
}
}
@@ -0,0 +1,248 @@
package haus.nightmare.a3270.ft
import android.os.Handler
import android.os.Looper
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.FTConstants.FTState
import haus.nightmare.lib3270j.ft.FTCut
import haus.nightmare.lib3270j.ft.FTDft
import java.io.File
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.logging.Logger
/**
* Android coordinator for IND$FILE file transfers.
* Manages the transfer state machine, timeouts, and coordinates between
* UI, Telnet3270Client, and lower-level CUT/DFT protocol handlers.
*/
class FileTransfer(
private val client: Telnet3270Client,
private val callback: FileTransferCallback
) : FTCut.FTCutListener, FTDft.FTDftListener {
private val log = Logger.getLogger(FileTransfer::class.java.name)
private val mainHandler = Handler(Looper.getMainLooper())
enum class FTMode {
UNKNOWN, CUT, DFT
}
interface FileTransferCallback {
fun onTransferStarted()
fun onTransferRunning()
fun onBytesTransferred(bytes: Long)
fun onTransferComplete(message: String)
fun onTransferAborted(error: String)
}
private var currentConfig: FTConfig? = null
private var localFile: File? = null
private var state = FTState.NONE
private var activeMode = FTMode.UNKNOWN
private var cutHandler: FTCut? = null
private var dftHandler: FTDft? = null
private var timeoutTimer: Timer? = null
private val startTimeoutMs = 30000L // 30 seconds
fun startTransfer(config: FTConfig): String? {
if (state != FTState.NONE) {
return "A transfer is already in progress."
}
val validationError = config.validate()
if (validationError != null) {
return validationError
}
this.currentConfig = config
this.localFile = File(config.localFilename)
this.activeMode = FTMode.UNKNOWN
// Check overwrite
if (config.isReceive && !config.isAppend && !config.isOverwrite) {
if (localFile?.exists() == true) {
return "Local file already exists and overwrite is not permitted."
}
}
// Initialize protocol handlers lazily
if (cutHandler == null) {
cutHandler = FTCut(
client.screenBuffer,
client.inputProcessor,
client.translator,
this
)
}
if (dftHandler == null) {
dftHandler = FTDft(
client.inputProcessor,
client.translator,
this
)
client.dataStreamProcessor.setFTDft(dftHandler)
}
// Build and type the IND$FILE command
val command = config.buildCommand()
log.info("Starting IND\$FILE transfer with command: $command")
// Erase field and verify it can hold the command
val capacity = client.inputProcessor.kybdPrime()
if (capacity < 0) {
cleanupHandlers(false)
return when (capacity) {
-1 -> "Keyboard is locked."
-3 -> "No unprotected input field found on screen."
else -> "Cannot start transfer from current screen state."
}
}
if (capacity < command.length) {
cleanupHandlers(false)
return "Current input field is too small for IND\$FILE command ($capacity chars max)."
}
setState(FTState.AWAIT_ACK)
client.emulateInput(command + "\n")
startTimeout()
mainHandler.post { callback.onTransferStarted() }
return null
}
fun cancel() {
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
log.info("User cancelled transfer")
setState(FTState.ABORT_WAIT)
} else if (state != FTState.NONE) {
log.info("Forcing cancel from state $state")
completeTransfer("Transfer cancelled.")
mainHandler.post { callback.onTransferAborted("Cancelled by user.") }
}
}
fun reset() {
log.info("Force resetting FileTransfer state from $state")
completeTransfer("Reset")
}
fun isTransferActive(): Boolean = state != FTState.NONE
fun getActiveMode(): FTMode = activeMode
fun onScreenUpdated() {
if ((activeMode == FTMode.CUT || activeMode == FTMode.UNKNOWN) &&
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
cutHandler?.processScreenUpdate()
}
}
private fun startTimeout() {
cancelTimeout()
timeoutTimer = Timer("FTTimeout", true).apply {
schedule(object : TimerTask() {
override fun run() {
mainHandler.post {
if (state == FTState.AWAIT_ACK) {
log.warning("Transfer start timeout")
completeTransfer("Transfer failed to start within 30 seconds.")
callback.onTransferAborted("Transfer start timeout.")
}
}
}
}, startTimeoutMs)
}
}
private fun cancelTimeout() {
timeoutTimer?.cancel()
timeoutTimer = null
}
private fun cleanupHandlers(success: Boolean) {
cutHandler?.cleanup()
dftHandler?.cleanup()
if (!success && currentConfig != null && currentConfig?.isReceive == true && !currentConfig!!.isAppend) {
if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile!!.exists()) {
log.info("Cleaning up incomplete download: ${localFile!!.absolutePath}")
localFile!!.delete()
}
}
}
private fun completeTransfer(errorMessage: String?) {
cancelTimeout()
val success = (errorMessage == null)
cleanupHandlers(success)
setState(FTState.NONE)
activeMode = FTMode.UNKNOWN
currentConfig = null
}
override fun onCutRunning() {
handleTransferRunning(FTMode.CUT)
}
override fun onDftRunning() {
handleTransferRunning(FTMode.DFT)
}
private fun handleTransferRunning(mode: FTMode) {
if (activeMode == FTMode.UNKNOWN) {
activeMode = mode
log.info("FT mode established: $activeMode")
try {
if (activeMode == FTMode.DFT) {
dftHandler?.initTransfer(localFile)
} else {
cutHandler?.initTransfer(localFile)
}
} catch (e: IOException) {
log.warning("Failed to open local file for $activeMode: ${e.message}")
onTransferAborted("Failed to open local file: ${e.message}")
return
}
}
cancelTimeout()
setState(FTState.RUNNING)
mainHandler.post { callback.onTransferRunning() }
}
override fun onTransferComplete(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post {
if (errorMessage == null) {
callback.onTransferComplete("File transfer complete.")
} else {
callback.onTransferAborted(errorMessage)
}
}
}
override fun onTransferAborted(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post { callback.onTransferAborted(errorMessage ?: "Transfer aborted") }
}
override fun onBytesTransferred(bytes: Long) {
mainHandler.post { callback.onBytesTransferred(bytes) }
}
override fun getCurrentState(): FTState = state
override fun setState(state: FTState) {
this.state = state
}
override fun getConfig(): FTConfig? = currentConfig
override fun getLocalFile(): File? = localFile
}
@@ -0,0 +1,176 @@
package haus.nightmare.a3270.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import haus.nightmare.a3270.MainActivity
import haus.nightmare.a3270.R
class TerminalService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
if (action == ACTION_STOP) {
try {
stopForeground(STOP_FOREGROUND_REMOVE)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.cancel(NOTIFICATION_ID)
} catch (e: Exception) {
e.printStackTrace()
}
stopSelf()
return START_NOT_STICKY
}
val host = intent?.getStringExtra(EXTRA_HOST) ?: "Mainframe"
createNotificationChannels()
val notificationIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification: Notification = NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
.setContentTitle("a3270 Active Session")
.setContentText("Connected to $host (Socket Active)")
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
try {
if (Build.VERSION.SDK_INT >= 34) {
startForeground(
NOTIFICATION_ID,
notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
}
return START_STICKY
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val manager = getSystemService(NotificationManager::class.java) ?: return
// Delete obsolete channels so system settings reset cleanly
try {
manager.deleteNotificationChannel("a3270_connection_channel")
manager.deleteNotificationChannel("a3270_connection_v2")
} catch (_: Exception) {}
// 1. Service Persistent Channel
val serviceChannel = NotificationChannel(
SERVICE_CHANNEL_ID,
"a3270 Terminal Connection",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Keeps the 3270 TCP socket connection alive in the background"
setShowBadge(true)
}
manager.createNotificationChannel(serviceChannel)
// 2. Screen Update Notification Channel
val updateChannel = NotificationChannel(
UPDATE_CHANNEL_ID,
"a3270 Screen Updates",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifies when mainframe screen updates occur in the background"
enableVibration(true)
}
manager.createNotificationChannel(updateChannel)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
companion object {
private const val SERVICE_CHANNEL_ID = "a3270_connection_v3"
private const val UPDATE_CHANNEL_ID = "a3270_screen_updates"
private const val NOTIFICATION_ID = 32701
private const val UPDATE_NOTIFICATION_ID = 32702
const val EXTRA_HOST = "extra_host"
const val ACTION_STOP = "action_stop"
fun start(context: Context, host: String) {
try {
val intent = Intent(context, TerminalService::class.java).apply {
putExtra(EXTRA_HOST, host)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun stop(context: Context) {
try {
val intent = Intent(context, TerminalService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun notifyScreenUpdate(context: Context, host: String, textSnippet: String) {
try {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val displayHost = if (host.isNotBlank()) host else "Mainframe"
val notification = NotificationCompat.Builder(context, UPDATE_CHANNEL_ID)
.setContentTitle("$displayHost has an alert.")
.setContentText(textSnippet)
.setStyle(NotificationCompat.BigTextStyle().bigText(textSnippet))
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentIntent(pendingIntent)
.build()
manager.notify(UPDATE_NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
@@ -0,0 +1,58 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
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)
}
fun isMaskHiddenInputEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
}
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
}
fun isCursorBlinkEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
}
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()
}
}
@@ -0,0 +1,126 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
data class SavedHost(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int = 23,
val model: Int = 2,
val luName: String = "",
val autoConnect: Boolean = false,
val hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH"
) {
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("name", name)
put("host", host)
put("port", port)
put("model", model)
put("luName", luName)
put("autoConnect", autoConnect)
put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
}
}
companion object {
fun fromJson(json: JSONObject): SavedHost {
return SavedHost(
id = json.optString("id", UUID.randomUUID().toString()),
name = json.optString("name", "Mainframe"),
host = json.optString("host", "127.0.0.1"),
port = json.optInt("port", 23),
model = json.optInt("model", 2),
luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH")
)
}
}
}
object HostStorage {
private const val PREFS_NAME = "a3270_host_prefs"
private const val KEY_SAVED_HOSTS = "saved_hosts"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getSavedHosts(context: Context): List<SavedHost> {
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
val list = mutableListOf<SavedHost>()
try {
val jsonArray = JSONArray(jsonStr)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
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()
}
return list
}
fun saveHost(context: Context, host: SavedHost) {
val currentHosts = getSavedHosts(context).toMutableList()
val index = currentHosts.indexOfFirst { it.id == host.id }
// If this host is set to autoConnect, unset autoConnect on all others
val updatedHost = if (host.autoConnect) {
currentHosts.indices.forEach { i ->
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
}
host
} else {
host
}
if (index >= 0) {
currentHosts[index] = updatedHost
} else {
currentHosts.add(updatedHost)
}
saveAll(context, currentHosts)
}
fun deleteHost(context: Context, hostId: String) {
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
saveAll(context, currentHosts)
}
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
private fun saveAll(context: Context, hosts: List<SavedHost>) {
val array = JSONArray()
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
}
@@ -0,0 +1,569 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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 haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun ConnectDialog(
onDismiss: () -> Unit,
onConnect: (
host: String,
port: Int,
model: Int,
luName: String,
hostType: String,
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String
) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("127.0.0.1") }
var portStr by remember { mutableStateOf("23") }
var modelNum by remember { mutableIntStateOf(2) }
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 tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
profileName = saved.name
host = saved.host
portStr = saved.port.toString()
modelNum = saved.model
luName = saved.luName
autoConnect = saved.autoConnect
hostType = saved.hostType
useTls = saved.useTls
tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
}
fun clearFields() {
selectedHostId = null
profileName = ""
host = "127.0.0.1"
portStr = "23"
modelNum = 2
luName = ""
autoConnect = false
hostType = "TSO"
useTls = false
tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH"
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val trimmedHost = host.trim()
if (trimmedHost.isBlank()) return null
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = trimmedHost,
port = port,
model = modelNum,
luName = luName.trim(),
autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
selectedHostId = hostToSave.id
return hostToSave
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Connect / Manage Hosts",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
TextButton(
onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
// Scrollable Content Container
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Text(
"Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
savedHosts.forEach { saved ->
val isSelected = (saved.id == selectedHostId)
val tlsTag = if (saved.useTls) {
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
} else ""
val eTag = if (!saved.tn3270e) " [Non-E]" else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface(
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier
.fillMaxWidth()
.clickable { loadProfile(saved) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = saved.name,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = Color.White
)
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp,
color = Color.LightGray
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = {
loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved
onConnect(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode
)
},
modifier = Modifier.size(32.dp)
) {
Text("▶", fontSize = 14.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == saved.id) {
clearFields()
}
},
modifier = Modifier.size(32.dp)
) {
Text("✕", fontSize = 13.sp, color = Color(0xFFFF6B6B))
}
}
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
}
Text(
text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
label = { Text("Host / IP Address") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier
.weight(0.68f)
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
label = { Text("Port") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f)
)
}
// Terminal Model Selector
Column(modifier = Modifier.fillMaxWidth()) {
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
2 to "M2",
3 to "M3",
4 to "M4",
5 to "M5"
).forEach { (m, label) ->
val isSelected = (modelNum == m)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { modelNum = m }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = label,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
}
}
}
}
}
// Host System Type (TSO, CMS, CICS)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Host System Type:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("TSO", "CMS", "CICS").forEach { ht ->
val isSelected = (hostType == ht)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { hostType = ht }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = ht,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
// Graphics Mode (Both, Vector, Symbols, Off)
Column(modifier = Modifier.fillMaxWidth()) {
Text("Graphics Mode:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = graphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { graphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center
)
}
}
}
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
// Protocol & Security Options
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// 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 (only when TLS is active)
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.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)
)
}
}
// TN3270E Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { tn3270e = !tn3270e }
) {
Checkbox(
checked = tn3270e,
onCheckedChange = { tn3270e = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TN3270E (Extended 3270)", fontSize = 12.sp, color = Color.White)
}
// Auto-connect Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { autoConnect = !autoConnect }
) {
Checkbox(
checked = autoConnect,
onCheckedChange = { autoConnect = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
}
}
}
// Action Buttons Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save")
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
val saved = saveCurrentProfile()
if (saved != null) {
onConnect(
saved.host,
saved.port,
saved.model,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode
)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -0,0 +1,274 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.a3270.FTProgressState
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf("") }
var blksizeStr by remember { mutableStateOf("") }
var overwrite by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = localFile,
onValueChange = { localFile = it },
label = { Text("Local File Name / Path") },
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = Modifier.fillMaxWidth()
)
// Transfer Direction
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isReceive,
onClick = { isReceive = true },
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = { isReceive = false },
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Transfer Mode
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII (Text)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary", fontSize = 11.sp) }
)
}
}
// Host Type (TSO, CMS, CICS)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it.filter { c -> c.isDigit() } },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(1f)
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim())
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
}
}
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@@ -0,0 +1,248 @@
package haus.nightmare.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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 haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@Composable
fun TwoRowKeyBar(
connectionState: ConnectionState,
isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {},
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onFtClick: () -> Unit,
onToggleLightPen: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
onBackTab: () -> Unit,
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
onCursorRight: () -> Unit = {},
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF161719))
) {
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Light Pen/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "⋮",
color = Color(0xFF343A40),
modifier = Modifier.fillMaxWidth(),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = { menuExpanded = true }
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
if (!connectionState.isConnected()) {
DropdownMenuItem(
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onConnectClick()
}
)
} else {
DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onDisconnectClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onFtClick()
}
)
}
DropdownMenuItem(
text = {
Text(
text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF",
color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White,
fontWeight = FontWeight.SemiBold
)
},
onClick = {
menuExpanded = false
onToggleLightPen()
}
)
DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onSettingsClick()
}
)
}
}
// 2. TAB / BTAB depending on Shift state
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
KeyButton(
label = tabLabel,
color = Color(0xFF1C7ED6),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onBackTab()
onClearShift()
} else {
onTab()
}
}
)
// 3. RESET
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), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 5. 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), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
// 7. 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), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
// 9. Left Navigation
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
// 10. Up Navigation
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
// 11. Down Navigation
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
// 12. Right Navigation
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
}
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
val startPf = if (isShiftPressed) 13 else 1
val endPf = if (isShiftPressed) 24 else 12
for (i in startPf..endPf) {
val aid = pfAids[i - 1]
KeyButton(
label = "F$i",
color = Color(0xFF364FC7),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
onSendAid(aid)
if (isShiftPressed) {
onClearShift()
}
}
)
}
}
}
}
@Composable
private fun KeyButton(
label: String,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hapticFeedbackEnabled: Boolean = true,
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
) {
val view = LocalView.current
Surface(
onClick = {
if (hapticFeedbackEnabled) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
onClick()
},
shape = RoundedCornerShape(4.dp),
color = color,
shadowElevation = 1.dp,
modifier = modifier
.padding(horizontal = 1.dp, vertical = 1.dp)
.height(31.dp)
.focusProperties { canFocus = false }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
) {
Text(
text = label,
color = Color.White,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
@@ -0,0 +1,168 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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 haus.nightmare.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
isLightPenMode: Boolean = false,
modifier: Modifier = Modifier
) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) {
"OFFLINE"
} else if (isKeyboardLocked) {
"X SYSTEM"
} else {
"READY"
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
} else {
Color(0xFF51CF66) // Green for ready
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF161719))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = Color.White,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
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)
)
}
}
// Light Pen Badge
if (isLightPenMode) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFFFFFF50).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LP",
color = Color(0xFFFFFF50),
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)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position
Text(
text = "R$posStr",
color = Color(0xFF22B8CF),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -0,0 +1,268 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.clickable
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
@Composable
fun SettingsDialog(
initialMaskHiddenInput: Boolean,
initialCursorBlink: Boolean,
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
onDismiss: () -> 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,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
Text(
text = "Terminal Settings",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
// Setting 1: Mask Protected/Hidden Input with '*'
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show '*' for Hidden Fields",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Display asterisks for typed characters in non-display / password fields rather than blank spaces.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = maskHiddenInput,
onCheckedChange = { maskHiddenInput = 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 2: Blinking Cursor
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Blinking Cursor",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Blink the terminal cursor to clearly indicate current screen position.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = cursorBlink,
onCheckedChange = { cursorBlink = 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 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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { defaultGraphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Save", color = Color.White)
}
}
}
}
}
}
@@ -0,0 +1,114 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
*
* It avoids the destructive TextFieldValue("") resetting cycle which causes Android IME
* (Gboard/Samsung Keyboard) to reset symbol locks, cancel Caps Lock, and drop fast keystrokes.
*/
class TerminalInputConnection(
targetView: View,
private val onInputText: (String) -> Unit,
private val onSendAid: (Int) -> Unit,
private val onBackspace: () -> Unit,
private val onTab: () -> Unit,
private val onBackTab: () -> Unit
) : BaseInputConnection(targetView, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (!text.isNullOrEmpty()) {
onInputText(text.toString())
}
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onSendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_DEL -> {
onBackspace()
return true
}
KeyEvent.KEYCODE_TAB -> {
if (event.isShiftPressed) onBackTab() else onTab()
return true
}
}
val unicode = event.keyCharacterMap.get(event.keyCode, event.metaState)
if (unicode > 0) {
onInputText(unicode.toChar().toString())
return true
}
}
return super.sendKeyEvent(event)
}
}
/**
* Native Android View hosting the terminal InputConnection with Termux-style EditorInfo.
*
* TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + TYPE_CLASS_TEXT forces keyboards like Gboard
* to permanently display the alphanumeric number row across the top, disable intrusive
* autocorrect overlays, and preserve Shift/Caps Lock and Symbol mode (?123) state across typing.
*/
class TerminalInputView(context: Context) : View(context) {
var onInputText: (String) -> Unit = {}
var onSendAid: (Int) -> Unit = {}
var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {}
init {
isFocusable = true
isFocusableInTouchMode = true
}
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE
return TerminalInputConnection(this, onInputText, onSendAid, onBackspace, onTab, onBackTab)
}
fun showSoftKeyboard() {
requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
@@ -0,0 +1,592 @@
package haus.nightmare.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF5078FF), // 1: Blue
Color(0xFFFF3232), // 2: Red
Color(0xFFFF82B4), // 3: Pink
Color(0xFF32CD32), // 4: Green
Color(0xFF40E0D0), // 5: Turquoise
Color(0xFFFFFF50), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF1E3CB4), // 9: Deep Blue
Color(0xFFFFA500), // 10: Orange
Color(0xFFB482FF), // 11: Purple
Color(0xFF90EE90), // 12: Pale Green
Color(0xFFAFEEEE), // 13: Pale Turquoise
Color(0xFFAAAAAA), // 14: Grey
Color(0xFFFFFFFF) // 15: White
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap
showContextMenu = false
selectionStart = null
selectionEnd = null
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val clickAddr = row * cols + col
if (isGraphicCursor) {
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane?.canvasWidth ?: gridW.toInt()
val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt()
val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt()
val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt()
onGraphicTouch?.invoke(px, py)
} else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr)
} else {
var addr = clickAddr
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
}
) {
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth
val cellHeight = metrics.cellHeight
val offsetX = metrics.offsetX
val offsetY = metrics.offsetY
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textAlign = Paint.Align.CENTER
}
// Calculate optimal font size ensuring characters fit cleanly in cell without crowding
val baseTextSize = cellHeight * 0.78f
paint.textSize = baseTextSize
val measuredW = paint.measureText("W")
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = offsetX + c * cellWidth
val top = offsetY + r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
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
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer)
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// 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 as IntArray, 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
val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
textX,
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f
)
}
}
}
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.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(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
}
}
}
data class TerminalGridMetrics(
val cellWidth: Float,
val cellHeight: Float,
val gridWidth: Float,
val gridHeight: Float,
val offsetX: Float,
val offsetY: Float
)
fun calculateGridMetrics(
viewWidth: Float,
viewHeight: Float,
cols: Int,
rows: Int
): TerminalGridMetrics {
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
}
val rawCellW = viewWidth / cols
val rawCellH = viewHeight / rows
val currentRatio = rawCellW / rawCellH
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
val targetMinRatio = 0.48f
val targetMaxRatio = 0.60f
val cellW: Float
val cellH: Float
if (currentRatio > targetMaxRatio) {
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
cellH = rawCellH
cellW = rawCellH * targetMaxRatio
} else if (currentRatio < targetMinRatio) {
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
cellW = rawCellW
cellH = rawCellW / targetMinRatio
} else {
cellW = rawCellW
cellH = rawCellH
}
val gridW = cols * cellW
val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f
val offY = (viewHeight - gridH) / 2f
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val viewW = displayMetrics.widthPixels.toFloat()
val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
for (c in minCol..maxCol) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}
@@ -0,0 +1,192 @@
package haus.nightmare.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}"
}
}