12 Commits

Author SHA1 Message Date
rudi 31b85f491c Overnight churning adaptations
Build and Test a3270 / Build Android APK (push) Successful in 7m23s
Release a3270 / Build & Publish Release (push) Successful in 7m48s
2026-09-01 10:11:29 -04:00
rudi 9f96555c9d Begin v1.0.1 commits
Build and Test a3270 / Build Android APK (push) Successful in 7m26s
2026-08-31 10:30:32 -04:00
rudi a7cc4b9d50 v1
Build and Test a3270 / Build Android APK (push) Successful in 7m29s
Release a3270 / Build & Publish Release (push) Successful in 7m18s
2026-08-31 00:28:47 +00:00
rudi 600b626e94 And now with more buttons
Build and Test a3270 / Build Android APK (push) Successful in 7m2s
2026-08-28 14:17:25 -04:00
rudi 18c4203c0d Add lib3270j updates
Build and Test a3270 / Build Android APK (push) Successful in 6m57s
2026-08-28 14:13:41 -04:00
rudi f6d69bf0ff Refactor for consistency
Build and Test a3270 / Build Android APK (push) Successful in 6m54s
2026-08-25 16:05:28 +00:00
rudi aa008664bc Fix bug with xedit
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
2026-08-25 01:12:09 +00:00
rudi 8344b9f4f7 j3270 updates applied for lightpen
Build and Test a3270 / Build Android APK (push) Successful in 4m34s
2026-08-24 20:46:09 +00:00
rudi 052a6619ae Add some cleaning
Build and Test a3270 / Build Android APK (push) Successful in 6m39s
2026-08-21 18:56:22 -04:00
rudi 2a22a636b6 Add screen ratio fixing
Build and Test a3270 / Build Android APK (push) Successful in 6m49s
Release a3270 / Build & Publish Release (push) Successful in 4m43s
2026-08-21 17:59:41 -04:00
rudi d3c149fe4a Add hardware keyboard support
Build and Test a3270 / Build Android APK (push) Successful in 6m43s
2026-08-21 17:51:42 -04:00
rudi 8c8e1f17db Fix graphics
Release a3270 / Build & Publish Release (push) Successful in 4m39s
Build and Test a3270 / Build Android APK (push) Successful in 6m36s
2026-08-21 16:39:14 -04:00
32 changed files with 5778 additions and 1264 deletions
+1 -9
View File
@@ -2,7 +2,7 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions)
[![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE)
[![License: Unlicense](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
@@ -93,14 +93,6 @@ build/outputs/apk/debug/a3270-debug.apk
---
## 🤝 Contributing
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
I have contributed no code to this project, it was entirely written by LLMs with my guidance only.
---
## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
+8 -4
View File
@@ -4,15 +4,15 @@ plugins {
}
android {
namespace 'org.pubvm.a3270'
namespace 'haus.nightmare.a3270'
compileSdk 34
defaultConfig {
applicationId "org.pubvm.a3270"
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 4
versionName "0.1.3"
versionCode 6
versionName "1.0.1"
}
buildTypes {
@@ -69,4 +69,8 @@ dependencies {
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
}
+6 -2
View File
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi
fi
@@ -19,6 +21,8 @@ fi
if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi
fi
+1
View File
@@ -1,3 +1,4 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
kotlin.compiler.execution.strategy=in-process
@@ -0,0 +1,792 @@
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.FieldInspectorDialog
import haus.nightmare.a3270.ui.FindDialog
import haus.nightmare.a3270.ui.ScreenExporterDialog
import haus.nightmare.a3270.ui.ScriptDialog
import haus.nightmare.a3270.ui.PrinterSessionDialog
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
}
@Suppress("DEPRECATION")
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_A -> {
viewModel.attn()
return true
}
KeyEvent.KEYCODE_S -> {
viewModel.sysReq()
return true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.wordLeft()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.wordRight()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> {
viewModel.deleteWord()
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_D -> {
viewModel.deleteWord()
return true
}
KeyEvent.KEYCODE_E -> {
viewModel.fieldEnd()
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 -> {
if (shift) {
viewModel.eraseEof()
isShiftPressedState.value = false
} else {
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.eraseInput()
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 codePage by viewModel.codePage.collectAsState()
val searchHighlightAddr by viewModel.searchHighlightAddr.collectAsState()
val searchHighlightLen by viewModel.searchHighlightLen.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 showFieldInspectorDialog by remember { mutableStateOf(false) }
var showFindDialog by remember { mutableStateOf(false) }
var showScreenExportDialog by remember { mutableStateOf(false) }
var showScriptDialog by remember { mutableStateOf(false) }
var showPrinterSessionDialog 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,
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
)
}
}
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,
searchHighlightAddr = searchHighlightAddr,
searchHighlightLen = searchHighlightLen,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { clickAddr, px, py ->
viewModel.handleGraphicTouch(clickAddr, 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 },
onFieldInspectorClick = { showFieldInspectorDialog = true },
onFindClick = { showFindDialog = true },
onScreenExportClick = { showScreenExportDialog = true },
onScriptClick = { showScriptDialog = true },
onPrinterSessionClick = { showPrinterSessionDialog = 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()
},
onEraseInput = {
viewModel.eraseInput()
terminalInputViewRef?.showSoftKeyboard()
},
onEraseEof = {
viewModel.eraseEof()
terminalInputViewRef?.showSoftKeyboard()
},
onAttn = {
viewModel.attn()
terminalInputViewRef?.showSoftKeyboard()
},
onSysReq = {
viewModel.sysReq()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorSelect = {
viewModel.cursorSelect()
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,
codePage = codePage,
isLightPenMode = isLightPenMode,
isInsertMode = viewModel.getClient()?.inputProcessor?.isInsertMode ?: false,
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: ""
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFtDialog) {
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
client = viewModel.getClient(),
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,
initialDefaultCodePage = codePage,
onDismiss = { showSettingsDialog = false },
onSave = { mask, blink, haptic, verify, gfx, defaultCp ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFieldInspectorDialog) {
FieldInspectorDialog(
client = viewModel.getClient(),
screenVersion = screenVersion,
onDismiss = { showFieldInspectorDialog = false },
onJumpToField = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFindDialog) {
FindDialog(
cols = cols,
onDismiss = { showFindDialog = false },
onFind = { query: String, matchCase: Boolean, forward: Boolean ->
viewModel.find(query, matchCase, forward)
},
onClearHighlight = {
viewModel.clearSearchHighlight()
}
)
}
if (showScreenExportDialog) {
ScreenExporterDialog(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHidden = maskHiddenInput,
onDismiss = { showScreenExportDialog = false }
)
}
if (showScriptDialog) {
ScriptDialog(
onDismiss = { showScriptDialog = false },
onExecuteScript = { script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit ->
viewModel.executeScript(script, onStatus, onComplete)
}
)
}
if (showPrinterSessionDialog) {
val activeHost = viewModel.currentHost.ifBlank { "127.0.0.1" }
PrinterSessionDialog(
initialHost = activeHost,
initialCodePage = codePage,
initialUseTls = isTlsActive,
onDismiss = { showPrinterSessionDialog = false }
)
}
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)
}
)
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270
package haus.nightmare.a3270
import android.app.Application
import androidx.lifecycle.AndroidViewModel
@@ -11,19 +11,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings
import 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.*
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
@@ -32,14 +31,30 @@ 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 object WordLeft : TerminalInputAction
data object WordRight : TerminalInputAction
data object FieldEnd : TerminalInputAction
data object DeleteWord : TerminalInputAction
data object CursorSelect : TerminalInputAction
data object Attn : TerminalInputAction
data object SysReq : TerminalInputAction
data object Dup : TerminalInputAction
data object FieldMark : TerminalInputAction
data object ToggleInsert : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
}
data class UntrustedCertPromptState(
@@ -110,6 +125,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
private val _codePage = MutableStateFlow(AppSettings.getDefaultCodePage(application))
val codePage: StateFlow<String> = _codePage.asStateFlow()
private val _searchHighlightAddr = MutableStateFlow(-1)
val searchHighlightAddr: StateFlow<Int> = _searchHighlightAddr.asStateFlow()
private val _searchHighlightLen = MutableStateFlow(0)
val searchHighlightLen: StateFlow<Int> = _searchHighlightLen.asStateFlow()
private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
@@ -119,11 +143,43 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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
}
fun setSearchHighlight(addr: Int, len: Int) {
_searchHighlightAddr.value = addr
_searchHighlightLen.value = len
}
fun clearSearchHighlight() {
_searchHighlightAddr.value = -1
_searchHighlightLen.value = 0
}
fun setCodePage(cp: String) {
_codePage.value = cp
val c = client
if (c != null) {
c.codePage = cp
c.screenBuffer?.translateToUnicode()
_screenVersion.value = System.currentTimeMillis()
}
}
// 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
@@ -158,68 +214,231 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
val isNvt = c.connectionState.isNvt || ip.isNvtMode
try {
when (action) {
is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false
if (isNvt) {
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
c.sendNVTString("\r\n")
} else {
c.sendNVTChar(ch)
}
}
} else {
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'")
}
}
}
}
is TerminalInputAction.SendAid -> {
if (isNvt) {
if (action.aidCode == AID_ENTER) {
c.sendNVTString("\r\n")
} else if (action.aidCode == AID_CLEAR) {
c.sendNVTChar('\u000C')
} else {
val pfNum = aidToPfNumber(action.aidCode)
if (pfNum > 0) {
val seq = c.nvtProcessor?.getFunctionKeySequence(pfNum) ?: ""
if (seq.isNotEmpty()) {
c.sendNVTString(seq)
}
}
}
} else {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
}
is TerminalInputAction.Backspace -> {
if (isNvt) {
c.sendNVTChar('\b')
} else {
ip.isKeyboardLocked = false
ip.backspace()
}
}
is TerminalInputAction.DeleteChar -> {
if (isNvt) {
c.sendNVTString("\u001B[3~")
} else {
ip.isKeyboardLocked = false
ip.deleteChar()
}
}
is TerminalInputAction.Tab -> {
if (isNvt) {
c.sendNVTChar('\t')
} else {
ip.tab()
}
}
is TerminalInputAction.BackTab -> {
if (isNvt) {
c.sendNVTString("\u001B[Z")
} else {
ip.backTab()
}
}
is TerminalInputAction.CursorLeft -> {
if (isNvt) {
c.sendNVTString("\u001B[D")
} else {
ip.cursorLeft()
}
}
is TerminalInputAction.CursorRight -> {
if (isNvt) {
c.sendNVTString("\u001B[C")
} else {
ip.cursorRight()
}
}
is TerminalInputAction.CursorUp -> {
if (isNvt) {
c.sendNVTString("\u001B[A")
} else {
ip.cursorUp()
}
}
is TerminalInputAction.CursorDown -> {
if (isNvt) {
c.sendNVTString("\u001B[B")
} else {
ip.cursorDown()
}
}
is TerminalInputAction.CursorHome -> {
if (isNvt) {
c.sendNVTString("\u001B[H")
} else {
ip.cursorHome()
}
}
is TerminalInputAction.EraseEof -> {
if (isNvt) {
c.sendNVTString("\u001B[F")
} else {
ip.isKeyboardLocked = false
ip.eraseEof()
}
}
is TerminalInputAction.EraseInput -> {
if (!isNvt) {
ip.isKeyboardLocked = false
ip.eraseInput()
}
}
is TerminalInputAction.Newline -> {
if (isNvt) {
c.sendNVTString("\r\n")
} else {
ip.newline()
}
}
is TerminalInputAction.Reset -> {
if (isNvt) {
c.sendNVTChar('\u001B')
} else {
ip.reset()
}
}
is TerminalInputAction.WordLeft -> {
if (isNvt) {
c.sendNVTString("\u001Bb")
} else {
ip.processWordLeft()
}
}
is TerminalInputAction.WordRight -> {
if (isNvt) {
c.sendNVTString("\u001Bf")
} else {
ip.processWordRight()
}
}
is TerminalInputAction.FieldEnd -> {
if (!isNvt) {
ip.processFieldEnd()
}
}
is TerminalInputAction.DeleteWord -> {
if (!isNvt) {
ip.isKeyboardLocked = false
ip.processDeleteWord()
}
}
is TerminalInputAction.CursorSelect -> {
if (!isNvt) {
ip.cursorSelect()
}
}
is TerminalInputAction.Attn -> {
if (!isNvt) {
ip.attn()
}
}
is TerminalInputAction.SysReq -> {
if (!isNvt) {
ip.sysReq()
}
}
is TerminalInputAction.Dup -> {
if (!isNvt) {
ip.dup()
}
}
is TerminalInputAction.FieldMark -> {
if (!isNvt) {
ip.fieldMark()
}
}
is TerminalInputAction.ToggleInsert -> {
if (!isNvt) {
ip.processToggleInsert()
}
}
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}")
}
}
private fun aidToPfNumber(aid: Int): Int {
return when (aid) {
AID_PF1 -> 1; AID_PF2 -> 2; AID_PF3 -> 3; AID_PF4 -> 4
AID_PF5 -> 5; AID_PF6 -> 6; AID_PF7 -> 7; AID_PF8 -> 8
AID_PF9 -> 9; AID_PF10 -> 10; AID_PF11 -> 11; AID_PF12 -> 12
AID_PF13 -> 13; AID_PF14 -> 14; AID_PF15 -> 15; AID_PF16 -> 16
AID_PF17 -> 17; AID_PF18 -> 18; AID_PF19 -> 19; AID_PF20 -> 20
AID_PF21 -> 21; AID_PF22 -> 22; AID_PF23 -> 23; AID_PF24 -> 24
else -> 0
}
}
fun resolveUntrustedCert(accept: Boolean) {
try {
val prompt = _untrustedCertPrompt.value
@@ -232,17 +451,92 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
}
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
fun updateSettings(
maskHidden: Boolean,
blink: Boolean,
haptic: Boolean,
verifyCertsVal: Boolean = true,
defaultGraphicsVal: String = "BOTH",
defaultCodePageVal: String = "037"
) {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
AppSettings.setDefaultCodePage(getApplication(), defaultCodePageVal)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
_hapticFeedback.value = haptic
_verifyCerts.value = verifyCertsVal
_defaultGraphicsMode.value = defaultGraphicsVal
_codePage.value = defaultCodePageVal
}
fun find(target: String, matchCase: Boolean = false, forward: Boolean = true): Int {
val c = client ?: return -1
val sb = c.screenBuffer ?: return -1
val curAddr = sb.cursorAddress
val cols = sb.cols
val rows = sb.rows
if (cols <= 0 || rows <= 0) return -1
val curRow = curAddr / cols
val curCol = curAddr % cols
val startRow: Int
val startCol: Int
if (forward) {
startCol = (curCol + 1) % cols
startRow = if (startCol == 0) (curRow + 1) % rows else curRow
} else {
startCol = (curCol - 1 + cols) % cols
startRow = if (startCol == cols - 1) (curRow - 1 + rows) % rows else curRow
}
val dir = if (forward) haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_FORWARD else haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_BACKWARD
val foundPos = c.ps.searchString(target, startRow, startCol, dir, !matchCase)
if (foundPos >= 0) {
sb.cursorAddress = foundPos
_cursorAddress.value = foundPos
setSearchHighlight(foundPos, target.length)
_screenVersion.value = System.currentTimeMillis()
}
return foundPos
}
fun executeScript(
script: String,
onStatus: (String) -> Unit = {},
onComplete: (String?) -> Unit = {}
) {
val c = client
if (c == null || !_connectionState.value.isConnected()) {
onComplete("Not connected to host.")
return
}
viewModelScope.launch(Dispatchers.IO) {
try {
val cleaned = script.replace("\r\n", "\n").replace("\r", "\n")
val lines = cleaned.split("\n")
for (line in lines) {
val trimmed = line.trim()
if (trimmed.isNotEmpty() && !trimmed.startsWith("#") && !trimmed.startsWith("//")) {
withContext(Dispatchers.Main) { onStatus("Sending: $trimmed") }
c.sendKeys(trimmed)
kotlinx.coroutines.delay(100)
}
}
_screenVersion.value = System.currentTimeMillis()
withContext(Dispatchers.Main) {
onComplete(null)
}
} catch (e: Exception) {
log.warning("Script execution error: ${e.message}")
withContext(Dispatchers.Main) {
onComplete(e.message ?: "Script execution error")
}
}
}
}
fun connect(
@@ -253,7 +547,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
hostType: String = "TSO",
useTls: Boolean = false,
tlsVerifyCert: Boolean = true,
graphicsModeStr: String = "BOTH"
tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH",
codePageStr: String = "037",
proxyTypeStr: String = "NONE",
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
proxyPass: String = ""
) {
currentHost = host
activeHostType = hostType
@@ -261,6 +562,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
hasInitialScreenLoaded = false
fileTransferCoordinator = null
_ftState.value = FTProgressState()
_codePage.value = codePageStr
clearSearchHighlight()
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -284,9 +587,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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 = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify
@@ -298,10 +602,22 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
codePage = codePageStr
val pType = try {
ConnectionConfig.ProxyType.valueOf(proxyTypeStr.uppercase())
} catch (_: Exception) {
ConnectionConfig.ProxyType.NONE
}
if (pType != ConnectionConfig.ProxyType.NONE && proxyHost.isNotBlank()) {
val defaultPort = if (pType == ConnectionConfig.ProxyType.HTTP) 8080 else 1080
setProxy(pType, proxyHost.trim(), if (proxyPort > 0) proxyPort else defaultPort, proxyUser.ifBlank { null }, proxyPass.ifBlank { null })
}
}
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
config.certificateVerifier = haus.nightmare.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true
@@ -329,6 +645,32 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient
newClient.setNvtClipboardHandler(object : haus.nightmare.lib3270j.nvt.NvtProcessor.ClipboardHandler {
override fun getClipboardText(): String {
return try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
} catch (e: Exception) {
""
}
}
override fun setClipboardText(text: String?) {
if (!text.isNullOrEmpty()) {
try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.setPrimaryClip(android.content.ClipData.newPlainText("3270 NVT", text))
} catch (ignored: Exception) {}
}
}
})
newClient.addNvtTitleListener { title ->
if (!title.isNullOrBlank()) {
_oiaText.value = title
}
}
newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState
@@ -542,6 +884,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Backspace)
}
fun deleteChar() {
inputChannel.trySend(TerminalInputAction.DeleteChar)
}
fun tab() {
inputChannel.trySend(TerminalInputAction.Tab)
}
@@ -554,6 +900,58 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Reset)
}
fun wordLeft() {
inputChannel.trySend(TerminalInputAction.WordLeft)
}
fun wordRight() {
inputChannel.trySend(TerminalInputAction.WordRight)
}
fun fieldEnd() {
inputChannel.trySend(TerminalInputAction.FieldEnd)
}
fun deleteWord() {
inputChannel.trySend(TerminalInputAction.DeleteWord)
}
fun cursorSelect() {
inputChannel.trySend(TerminalInputAction.CursorSelect)
}
fun attn() {
inputChannel.trySend(TerminalInputAction.Attn)
}
fun sysReq() {
inputChannel.trySend(TerminalInputAction.SysReq)
}
fun dup() {
inputChannel.trySend(TerminalInputAction.Dup)
}
fun fieldMark() {
inputChannel.trySend(TerminalInputAction.FieldMark)
}
fun toggleInsert() {
inputChannel.trySend(TerminalInputAction.ToggleInsert)
}
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))
}
@@ -578,6 +976,35 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
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(clickAddr: Int = -1, px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) {
val c = client ?: return
val goca = c.gocaDecoder ?: return
if (clickAddr >= 0) {
c.screenBuffer?.cursorAddress = clickAddr
_cursorAddress.value = clickAddr
}
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
@@ -1,12 +1,12 @@
package org.pubvm.a3270.ft
package haus.nightmare.a3270.ft
import android.os.Handler
import android.os.Looper
import org.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft
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
@@ -1,4 +1,4 @@
package org.pubvm.a3270.service
package haus.nightmare.a3270.service
import android.app.Notification
import android.app.NotificationChannel
@@ -10,8 +10,8 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity
import org.pubvm.a3270.R
import haus.nightmare.a3270.MainActivity
import haus.nightmare.a3270.R
class TerminalService : Service() {
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
@@ -11,6 +11,7 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -55,4 +56,12 @@ object AppSettings {
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
fun getDefaultCodePage(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_CODE_PAGE, "037") ?: "037"
}
fun setDefaultCodePage(context: Context, codePage: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.SharedPreferences
@@ -17,7 +17,14 @@ data class SavedHost(
val hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val graphicsMode: String = "BOTH"
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH",
val codePage: String = "037",
val proxyType: String = "NONE",
val proxyHost: String = "",
val proxyPort: Int = 0,
val proxyUsername: String = "",
val proxyPassword: String = ""
) {
fun toJson(): JSONObject {
return JSONObject().apply {
@@ -31,7 +38,14 @@ data class SavedHost(
put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
put("codePage", codePage)
put("proxyType", proxyType)
put("proxyHost", proxyHost)
put("proxyPort", proxyPort)
put("proxyUsername", proxyUsername)
put("proxyPassword", proxyPassword)
}
}
@@ -48,7 +62,14 @@ data class SavedHost(
hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
graphicsMode = json.optString("graphicsMode", "BOTH")
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH"),
codePage = json.optString("codePage", "037"),
proxyType = json.optString("proxyType", "NONE"),
proxyHost = json.optString("proxyHost", ""),
proxyPort = json.optInt("proxyPort", 0),
proxyUsername = json.optString("proxyUsername", ""),
proxyPassword = json.optString("proxyPassword", "")
)
}
}
@@ -111,6 +132,51 @@ object HostStorage {
saveAll(context, currentHosts)
}
val AVAILABLE_CODE_PAGES = listOf(
"037" to "037 - US / Canada / Brazil",
"1047" to "1047 - IBM Open Systems / z/OS Unix",
"500" to "500 - International Latin-1",
"273" to "273 - Germany / Austria",
"277" to "277 - Denmark / Norway",
"278" to "278 - Sweden / Finland",
"280" to "280 - Italy",
"284" to "284 - Spain / Latin America",
"285" to "285 - United Kingdom",
"297" to "297 - France",
"420" to "420 - Arabic Bilingual",
"424" to "424 - Hebrew (with Latin)",
"803" to "803 - Hebrew Character Set",
"838" to "838 - Thai Extended",
"1160" to "1160 - Thai Euro",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"880" to "880 - Cyrillic (Russian)",
"1025" to "1025 - Cyrillic Multilingual",
"1123" to "1123 - Cyrillic Ukraine",
"1154" to "1154 - Cyrillic Euro",
"905" to "905 - Turkey (Latin-5)",
"1026" to "1026 - Turkey (Turkish)",
"1155" to "1155 - Turkey Euro",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1142" to "1142 - Denmark / Norway (Euro €)",
"1143" to "1143 - Sweden / Finland (Euro €)",
"1144" to "1144 - Italy (Euro €)",
"1145" to "1145 - Spain / Latin America (Euro €)",
"1146" to "1146 - United Kingdom (Euro €)",
"1147" to "1147 - France (Euro €)",
"1148" to "1148 - International (Euro €)",
"1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS",
"935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS",
"1371" to "1371 - Traditional Chinese Extended DBCS",
"933" to "933 - Korean Mixed DBCS"
)
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
@@ -0,0 +1,771 @@
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,
codePage: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: 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") }
var codePage by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)) }
var proxyType by remember { mutableStateOf("NONE") }
var proxyHost by remember { mutableStateOf("") }
var proxyPortStr by remember { mutableStateOf("") }
var proxyUsername by remember { mutableStateOf("") }
var proxyPassword by remember { mutableStateOf("") }
var showProxySettings by remember { mutableStateOf(false) }
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
codePage = saved.codePage
proxyType = saved.proxyType
proxyHost = saved.proxyHost
proxyPortStr = if (saved.proxyPort > 0) saved.proxyPort.toString() else ""
proxyUsername = saved.proxyUsername
proxyPassword = saved.proxyPassword
showProxySettings = saved.proxyType != "NONE"
}
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"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
proxyType = "NONE"
proxyHost = ""
proxyPortStr = ""
proxyUsername = ""
proxyPassword = ""
showProxySettings = false
}
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,
codePage = codePage,
proxyType = proxyType,
proxyHost = proxyHost.trim(),
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
proxyUsername = proxyUsername.trim(),
proxyPassword = proxyPassword.trim()
)
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,
hostToConn.codePage,
hostToConn.proxyType,
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword
)
},
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
)
}
}
}
}
}
// Code Page Selector
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage"
Column(modifier = Modifier.fillMaxWidth()) {
Text("EBCDIC Code Page:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFF2C2D30),
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.clickable { codePageMenuExpanded = true }
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedCpLabel,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
Text("", color = Color.Gray, fontSize = 10.sp)
}
}
DropdownMenu(
expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 280.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontSize = 12.sp,
color = if (codePage == cpId) Color(0xFF339AF0) else Color.White,
fontWeight = if (codePage == cpId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
codePage = cpId
codePageMenuExpanded = false
}
)
}
}
}
}
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)
}
// Proxy Settings Expandable Section
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showProxySettings = !showProxySettings }
) {
Checkbox(
checked = showProxySettings || proxyType != "NONE",
onCheckedChange = { checked ->
showProxySettings = checked
if (!checked) proxyType = "NONE"
}
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (proxyType != "NONE") "Proxy Active ($proxyType)" else "Configure Network Proxy",
fontSize = 12.sp,
color = if (proxyType != "NONE") Color(0xFF339AF0) else Color.White
)
}
if (showProxySettings || proxyType != "NONE") {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 4.dp, top = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("Proxy Protocol:", fontSize = 11.sp, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf("NONE", "HTTP", "SOCKS4", "SOCKS5").forEach { pType ->
val isSel = proxyType == pType
FilterChip(
selected = isSel,
onClick = { proxyType = pType },
label = { Text(pType, fontSize = 10.sp) }
)
}
}
if (proxyType != "NONE") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyHost,
onValueChange = { proxyHost = it },
label = { Text("Proxy Host") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.7f)
)
OutlinedTextField(
value = proxyPortStr,
onValueChange = { proxyPortStr = it },
label = { Text("Port") },
placeholder = { Text(if (proxyType == "HTTP") "8080" else "1080") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.3f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyUsername,
onValueChange = { proxyUsername = it },
label = { Text("User (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
OutlinedTextField(
value = proxyPassword,
onValueChange = { proxyPassword = it },
label = { Text("Pass (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
}
}
}
}
}
}
}
}
// 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,
saved.codePage,
saved.proxyType,
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword
)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -0,0 +1,214 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ecl.ECLField
@Composable
fun FieldInspectorDialog(
client: Telnet3270Client?,
screenVersion: Long,
onDismiss: () -> Unit,
onJumpToField: (Int) -> Unit
) {
var fields by remember { mutableStateOf<List<ECLField>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var cols by remember { mutableIntStateOf(80) }
var rows by remember { mutableIntStateOf(24) }
fun refresh() {
if (client != null && client.connectionState.isFullSession) {
val fieldList = client.fieldList
fields = fieldList?.fields ?: emptyList()
cols = client.screenBuffer?.cols ?: 80
rows = client.screenBuffer?.rows ?: 24
} else {
fields = emptyList()
}
}
LaunchedEffect(screenVersion, client) {
refresh()
}
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
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Presentation Space Field Inspector",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Text(
"${fields.size} fields detected (${rows}x$cols)",
fontSize = 11.sp,
color = Color.Gray
)
}
TextButton(onClick = { refresh() }) {
Text("Refresh", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
if (fields.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No fields detected or terminal not in 3270 session.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("#", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("Pos", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text("Start", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("End", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("Len", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Prot", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("MDT", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Num", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Wrap", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Content Text", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp), textAlign = TextAlign.Start)
}
// Data Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(fields) { idx, field ->
val isSelected = (idx == selectedIndex)
val startRow = field.startRow + 1
val startCol = field.startCol + 1
val endRow = field.endRow + 1
val endCol = field.endCol + 1
val displayText = if (field.isHidden) "••••••••" else field.text
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedIndex = idx
onJumpToField(field.dataStart)
}
) {
Row(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("${idx + 1}", color = Color.White, fontSize = 11.sp, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("${field.start}", color = Color.Cyan, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", startRow, startCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", endRow, endCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("${field.length}", color = Color.White, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isProtected) "Y" else "-", color = if (field.isProtected) Color.Gray else Color(0xFF51CF66), fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isModified) "Y" else "-", color = if (field.isModified) Color(0xFFFFB450) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isNumeric) "Y" else "-", color = Color.LightGray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isHighIntensity) "Y" else "-", color = if (field.isHighIntensity) Color.White else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text(if (field.isHidden) "Y" else "-", color = if (field.isHidden) Color(0xFFFF6B6B) else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text(if (field.isPenSelectable) "Y" else "-", color = if (field.isPenSelectable) Color.Yellow else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isWrapped) "" else "-", color = if (field.isWrapped) Color(0xFF339AF0) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(displayText, color = if (field.isProtected) Color(0xFF90CAF9) else Color(0xFFA5D6A7), fontSize = 11.sp, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.width(220.dp))
}
}
}
}
}
}
// Bottom Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedIndex in fields.indices) {
Button(
onClick = {
onJumpToField(fields[selectedIndex].dataStart)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Jump to Field")
}
Spacer(modifier = Modifier.width(8.dp))
}
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
}
}
}
}
}
@@ -0,0 +1,535 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.platform.LocalContext
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
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
client: haus.nightmare.lib3270j.Telnet3270Client? = null,
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val context = LocalContext.current
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 localDisplayName 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) }
var showBrowseDialog by remember { mutableStateOf(false) }
var targetSaveUri by remember { mutableStateOf<Uri?>(null) }
var lastSavedFile by remember { mutableStateOf<File?>(null) }
// SAF Launcher for uploading (Send / PUT)
val openDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
try {
val displayName = getFileNameFromUri(context, uri).ifBlank { "upload.bin" }
val tempDir = File(context.cacheDir, "ft_upload").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
localFile = tempFile.absolutePath
localDisplayName = displayName
if (hostFile.isBlank()) {
val cleanBase = displayName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
hostFile = if (hostType == FTConfig.HostType.TSO) "'$cleanBase'" else "$cleanBase FILE A"
}
Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed to read file: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
// SAF Launcher for choosing download destination (Receive / GET)
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("*/*")
) { uri: Uri? ->
if (uri != null) {
targetSaveUri = uri
val displayName = getFileNameFromUri(context, uri).ifBlank { "download.bin" }
localDisplayName = displayName
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
localFile = tempFile.absolutePath
Toast.makeText(context, "Destination: $displayName", Toast.LENGTH_SHORT).show()
}
}
// Watch for completed transfers to export to destination URI if chosen
LaunchedEffect(ftProgressState.isActive, ftProgressState.isError) {
if (!ftProgressState.isActive && !ftProgressState.isError && ftProgressState.bytesTransferred > 0 && isReceive && localFile.isNotBlank()) {
val src = File(localFile)
if (src.exists()) {
lastSavedFile = src
val uri = targetSaveUri
if (uri != null) {
try {
context.contentResolver.openOutputStream(uri)?.use { out ->
src.inputStream().use { input ->
input.copyTo(out)
}
}
Toast.makeText(context, "Saved download to destination file", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed writing to destination: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
if (showBrowseDialog) {
HostDirectoryDialog(
client = client,
initialHostType = hostType,
initialQuery = hostFile,
onDismiss = { showBrowseDialog = false },
onSelectDataset = { selected ->
hostFile = selected
if (localFile.isBlank()) {
val base = selected.trim('\'').substringAfterLast('.').substringAfterLast(' ')
val safeName = if (base.isNotBlank()) "$base.txt" else "download.txt"
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath
localDisplayName = safeName
}
showBrowseDialog = false
}
)
}
fun exportToDownloads() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val targetName = (if (localDisplayName.isNotBlank()) localDisplayName else src.name).let {
val base = it.substringBeforeLast('.')
val ext = if (it.contains('.')) "." + it.substringAfterLast('.') else ""
"${base}_$timeStamp$ext"
}
val dst = File(downloadsDir, targetName)
src.copyTo(dst, overwrite = true)
Toast.makeText(context, "Exported to Downloads/${dst.name}", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareDownloadedFile() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val text = src.readText()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, localDisplayName.ifBlank { src.name })
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share Transferred File"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
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()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
if (!ftProgressState.isError && isReceive && lastSavedFile?.exists() == true) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedButton(
onClick = { exportToDownloads() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Save to Downloads 📥", fontSize = 11.sp)
}
OutlinedButton(
onClick = { shareDownloadedFile() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Share 📤", fontSize = 11.sp)
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
}
// 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
targetSaveUri = null
},
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = {
isReceive = false
targetSaveUri = null
},
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Host File Name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
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.weight(1f)
)
if (client != null && client.connectionState.isFullSession) {
OutlinedButton(
onClick = { showBrowseDialog = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text("Browse...", fontSize = 11.sp)
}
}
}
// Local File Selection (SAF Document Picker integration)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = if (localDisplayName.isNotBlank()) localDisplayName else localFile,
onValueChange = {
localDisplayName = it
localFile = it
},
label = { Text(if (isReceive) "Local Destination" else "Local File to Upload") },
placeholder = { Text(if (isReceive) "download.txt" else "sample.txt") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = Modifier.weight(1f)
)
Button(
onClick = {
if (isReceive) {
val defName = if (hostFile.isNotBlank()) {
val base = hostFile.trim('\'').substringAfterLast('.').substringAfterLast(' ')
if (base.isNotBlank()) "$base.txt" else "download.txt"
} else "download.txt"
createDocumentLauncher.launch(defName)
} else {
openDocumentLauncher.launch(arrayOf("*/*"))
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text(if (isReceive) "Save As..." else "Pick File...", fontSize = 11.sp)
}
}
Text(
text = if (isReceive) "Pick a destination or enter a file name to save in app storage" else "Use 'Pick File' to select any file from Downloads / Storage",
fontSize = 10.sp,
color = Color.LightGray,
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
)
}
// 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 = {
val resolvedLocal = if (localFile.isNotBlank()) {
localFile.trim()
} else if (localDisplayName.isNotBlank()) {
val tempDir = File(context.cacheDir, if (isReceive) "ft_download" else "ft_upload").apply { if (!exists()) mkdirs() }
File(tempDir, localDisplayName.trim()).absolutePath
} else ""
if (hostFile.isNotBlank() && resolvedLocal.isNotBlank()) {
localFile = resolvedLocal
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(resolvedLocal)
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() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
private fun getFileNameFromUri(context: Context, uri: Uri): String {
var name = ""
try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
name = it.getString(nameIndex) ?: ""
}
}
}
} catch (_: Exception) {}
if (name.isBlank()) {
name = uri.lastPathSegment ?: "file"
}
return name
}
@@ -0,0 +1,183 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.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 androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun FindDialog(
cols: Int = 80,
onDismiss: () -> Unit,
onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int,
onClearHighlight: () -> Unit
) {
var query by remember { mutableStateOf("") }
var matchCase by remember { mutableStateOf(false) }
var forward by remember { mutableStateOf(true) }
var statusMessage by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100)
focusRequester.requestFocus()
}
fun doFind(searchForward: Boolean) {
val trimmed = query.trim()
if (trimmed.isEmpty()) {
statusMessage = "Please enter search text."
isError = true
return
}
val pos = onFind(trimmed, matchCase, searchForward)
if (pos >= 0) {
val effectiveCols = if (cols > 0) cols else 80
val row = (pos / effectiveCols) + 1
val col = (pos % effectiveCols) + 1
statusMessage = "Found at position $pos ($row/$col)"
isError = false
} else {
statusMessage = "Text not found: \"$trimmed\""
isError = true
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.92f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Find on Screen",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
OutlinedTextField(
value = query,
onValueChange = {
query = it
if (statusMessage.isNotBlank()) statusMessage = ""
},
label = { Text("Find What") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { doFind(forward) }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// Options Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { matchCase = !matchCase }
) {
Checkbox(
checked = matchCase,
onCheckedChange = { matchCase = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Match Case", fontSize = 12.sp, color = Color.White)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
FilterChip(
selected = forward,
onClick = { forward = true },
label = { Text("Down ↓", fontSize = 11.sp) }
)
FilterChip(
selected = !forward,
onClick = { forward = false },
label = { Text("Up ↑", fontSize = 11.sp) }
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = if (isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = {
onClearHighlight()
onDismiss()
}) {
Text("Close", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = {
onClearHighlight()
statusMessage = ""
}) {
Text("Clear")
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = { doFind(forward) },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Find Next")
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
@@ -15,23 +15,35 @@ 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 org.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.*
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,
onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {},
onScriptClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
onBackTab: () -> Unit,
onEraseInput: () -> Unit = {},
onEraseEof: () -> Unit = {},
onAttn: () -> Unit = {},
onSysReq: () -> Unit = {},
onCursorSelect: () -> Unit = {},
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
@@ -53,7 +65,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "",
@@ -66,7 +78,9 @@ fun TwoRowKeyBar(
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 420.dp)
) {
if (!connectionState.isConnected()) {
DropdownMenuItem(
@@ -76,6 +90,13 @@ fun TwoRowKeyBar(
onConnectClick()
}
)
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
} else {
DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
@@ -84,14 +105,93 @@ fun TwoRowKeyBar(
onDisconnectClick()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
text = { Text("Find on Screen", color = Color.White) },
onClick = {
menuExpanded = false
onFindClick()
}
)
DropdownMenuItem(
text = { Text("Field Inspector", color = Color.White) },
onClick = {
menuExpanded = false
onFieldInspectorClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (IND\$FILE)", color = Color.White) },
onClick = {
menuExpanded = false
onFtClick()
}
)
DropdownMenuItem(
text = { Text("Save / Share Screen", color = Color.White) },
onClick = {
menuExpanded = false
onScreenExportClick()
}
)
DropdownMenuItem(
text = { Text("Run Keystroke Script", color = Color.White) },
onClick = {
menuExpanded = false
onScriptClick()
}
)
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("Attention (Attn)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onAttn()
}
)
DropdownMenuItem(
text = { Text("System Request (SysReq)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onSysReq()
}
)
DropdownMenuItem(
text = { Text("Erase Input (ErInp)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onEraseInput()
}
)
DropdownMenuItem(
text = { Text("Cursor Select (CurSel)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onCursorSelect()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
}
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 = {
@@ -120,14 +220,59 @@ fun TwoRowKeyBar(
}
)
// 3. RESET
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
// 3. RESET / ERASE (Erase Input) depending on Shift state
val resetLabel = if (isShiftPressed) "ERASE" else "RESET"
KeyButton(
label = resetLabel,
color = Color(0xFFE67700),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseInput()
onClearShift()
} else {
onReset()
}
}
)
// 4. ENTER
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 4. ENTER / EOF (Erase EOF) depending on Shift state
val enterLabel = if (isShiftPressed) "EOF" else "ENTER"
KeyButton(
label = enterLabel,
color = Color(0xFF2B8A3E),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseEof()
onClearShift()
} else {
onSendAid(AID_ENTER)
}
}
)
// 5. CLEAR
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
// 5. CLEAR / ATTN (Attention) depending on Shift state
val clearLabel = if (isShiftPressed) "ATTN" else "CLEAR"
KeyButton(
label = clearLabel,
color = Color(0xFFC92A2A),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onAttn()
onClearShift()
} else {
onSendAid(AID_CLEAR)
}
}
)
// 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
@@ -0,0 +1,310 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.FontFamily
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.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
@Composable
fun HostDirectoryDialog(
client: Telnet3270Client?,
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit
) {
var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
fun runQuery() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "Terminal not connected."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val q = query.trim()
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(q)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(q)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "${entries.size} entries found."
}
fun parseScreen() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "No active screen to parse."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val ps = c.ps
val screenText = ps.getString(0, ps.size)
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(screenText)
entries = list ?: emptyList()
} else {
val list = xfer.getTsoDirectory(screenText)
entries = list ?: emptyList()
}
selectedIndex = -1
statusMessage = "Parsed ${entries.size} entries from screen."
}
LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) {
runQuery()
} else {
parseScreen()
}
}
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
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header & Query Bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Host Catalog & Directory Browser",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
onClick = { hostType = FTConfig.HostType.TSO },
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
onClick = { hostType = FTConfig.HostType.CMS },
label = { Text("CMS", fontSize = 11.sp) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Query Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(onSearch = { runQuery() }),
modifier = Modifier.weight(1f)
)
Button(
onClick = { runQuery() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Query")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusMessage.ifBlank { "Enter query or parse screen." },
fontSize = 11.sp,
color = Color.Gray
)
TextButton(onClick = { parseScreen() }) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
if (entries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text("No entries found.", color = Color.Gray, fontSize = 12.sp)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (hostType == FTConfig.HostType.CMS) {
Text("Filename", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("Filetype", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(80.dp))
Text("Fm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Fmt", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else {
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Blksize", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Tracks", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Ext", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
// Entry Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(entries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable { selectedIndex = idx }
) {
Row(
modifier = Modifier.padding(vertical = 5.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item is CMSDirectoryEntry) {
Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(item.filetype, color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
Text(item.filemode, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.numRecords}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date} ${item.time}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) {
Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp))
Text(item.volume, color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
Text(item.recfm, color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("${item.lrecl}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.blksize}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.tracksAllocated}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("${item.extents}", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
}
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
if (selectedIndex in entries.indices) {
val selectedName = entries[selectedIndex].name
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in entries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@@ -13,7 +13,7 @@ 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 org.lib3270j.ConnectionState
import haus.nightmare.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
@@ -27,28 +27,49 @@ fun OiaStatusBar(
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
inhibitReason: Int = 0,
luName: String = "",
modifier: Modifier = Modifier
) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 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 connTypeBadge = when (connectionState) {
ConnectionState.CONNECTED_3270 -> "TN3270"
ConnectionState.CONNECTED_TN3270E -> "TN3270E"
ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
ConnectionState.NOT_CONNECTED -> "OFFLINE"
else -> connectionState.name
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
"OFFLINE" to Color(0xFF868E96)
} else if (inhibitReason != 0) {
val txt = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
else -> "X LOCKED"
}
txt to Color(0xFFFF6B6B)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
"X SYSTEM" to Color(0xFFFF6B6B)
} else if (isInsertMode) {
"INSERT" to Color(0xFF339AF0)
} else {
Color(0xFF51CF66) // Green for ready
"READY" to Color(0xFF51CF66)
}
Row(
@@ -119,6 +140,78 @@ fun OiaStatusBar(
)
}
}
// CodePage Badge
if (connectionState.isConnected() && codePage.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFFFA5252).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "CP$codePage",
color = Color(0xFFFF8787),
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)
)
}
}
// Connection Type Badge (TN3270, TN3270E, SSCP, NVT)
if (connectionState.isConnected()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF7950F2).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = connTypeBadge,
color = Color(0xFFB197FC),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// LU Name Badge
if (connectionState.isConnected() && luName.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF40C057).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LU:$luName",
color = Color(0xFF69DB7C),
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)
@@ -0,0 +1,487 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.printer.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun PrinterSessionDialog(
initialHost: String = "127.0.0.1",
initialPort: Int = 23,
initialCodePage: String = "037",
initialUseTls: Boolean = false,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var host by remember { mutableStateOf(initialHost.ifBlank { "127.0.0.1" }) }
var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "23") }
var printerLu by remember { mutableStateOf("") }
var assocDisplayLu by remember { mutableStateOf("") }
var codePage by remember { mutableStateOf(initialCodePage) }
var useTls by remember { mutableStateOf(initialUseTls) }
var pdtType by remember { mutableStateOf("DEFAULT") }
var isConnected by remember { mutableStateOf(false) }
var sessionStatus by remember { mutableStateOf("Disconnected") }
var spoolText by remember { mutableStateOf("") }
var pageCount by remember { mutableIntStateOf(0) }
var byteCount by remember { mutableIntStateOf(0) }
var printerClient by remember { mutableStateOf<Telnet3270EPClient?>(null) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
fun startSession() {
val port = portStr.trim().toIntOrNull() ?: 23
val config = PrinterConfig(host.trim(), port).apply {
this.isUseTls = useTls
this.codePage = codePage
if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim()
if (assocDisplayLu.isNotBlank()) this.associatedDisplayLuName = assocDisplayLu.trim()
this.destinationType = PrinterConfig.DestinationType.MEMORY
val pdt = when (pdtType) {
"PCL5" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPcl5PDT()
"EPSON_ESC_P" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createEpsonEscPPDT()
"POSTSCRIPT" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPostScriptPDT()
else -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPlainTextPDT()
}
if (pdt != null) {
this.printerDefinitionTable = pdt
}
}
val client = Telnet3270EPClient(config)
client.addPrintListener(object : PrintSessionListener {
override fun onPrintJobStarted(event: PrintSessionEvent) {
sessionStatus = "Printing job: ${event.jobName ?: "Job"}"
}
override fun onPrintJobData(event: PrintSessionEvent) {
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
byteCount = event.byteCount.toInt()
}
override fun onPrintJobPageComplete(event: PrintSessionEvent) {
pageCount = event.pageNumber
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
}
override fun onPrintJobComplete(event: PrintSessionEvent) {
pageCount = event.totalPages
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
sessionStatus = "Print Job Complete"
}
override fun onPrinterStatusChanged(event: PrintSessionEvent) {
val status = event.statusCode
isConnected = (status == PrinterConstants.STATUS_CONNECTED || status == PrinterConstants.STATUS_PRINTING)
sessionStatus = when (status) {
PrinterConstants.STATUS_CONNECTED -> "Connected"
PrinterConstants.STATUS_CONNECTING -> "Connecting..."
PrinterConstants.STATUS_PRINTING -> "Printing..."
else -> "Disconnected"
}
}
override fun onPrinterError(event: PrintSessionEvent) {
sessionStatus = "Error: ${event.message ?: "Code ${event.statusCode}"}"
}
})
printerClient = client
sessionStatus = "Connecting..."
val ok = client.connect()
if (!ok) {
sessionStatus = "Failed to initiate printer connection."
}
}
fun stopSession() {
printerClient?.disconnect()
printerClient = null
isConnected = false
sessionStatus = "Disconnected"
}
DisposableEffect(Unit) {
onDispose {
printerClient?.disconnect()
printerClient = null
}
}
fun printViaAndroidService() {
if (spoolText.isBlank()) {
Toast.makeText(context, "No printer spool to print.", Toast.LENGTH_SHORT).show()
return
}
try {
val printManager = context.getSystemService(Context.PRINT_SERVICE) as? PrintManager
if (printManager != null) {
val webView = WebView(context)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val printAdapter: PrintDocumentAdapter = webView.createPrintDocumentAdapter("3287_Print_Job")
printManager.print("3287 Print Job", printAdapter, PrintAttributes.Builder().build())
}
}
val htmlDoc = "<html><body style='font-family:monospace; font-size:12px; white-space:pre;'>${spoolText.replace("<", "&lt;").replace(">", "&gt;")}</body></html>"
webView.loadDataWithBaseURL(null, htmlDoc, "text/html", "UTF-8", null)
}
} catch (e: Exception) {
Toast.makeText(context, "Print failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun saveSpoolToFile() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(downloadsDir, "3287_spool_$timeStamp.txt")
file.writeText(spoolText)
Toast.makeText(context, "Saved to Downloads/${file.name}", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareSpool() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3287 Printer Spool")
putExtra(Intent.EXTRA_TEXT, spoolText)
}
context.startActivity(Intent.createChooser(intent, "Share 3287 Spool"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
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 = 700.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"IBM 3287 Printer Session",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"TN3270E SCS / 3270 DS Print Emulation",
fontSize = 11.sp,
color = Color.Gray
)
}
Surface(
color = if (isConnected) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFF6B6B).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = sessionStatus,
color = if (isConnected) Color(0xFF51CF66) else Color(0xFFFF6B6B),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Config Row 1: Host & Port
if (!isConnected) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.weight(2.5f)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}
// Config Row 2: Printer LU & Assoc Display LU
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = printerLu,
onValueChange = { printerLu = it },
label = { Text("Printer LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = assocDisplayLu,
onValueChange = { assocDisplayLu = it },
label = { Text("Display LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
// PDT, Code Page & TLS Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
var pdtExpanded by remember { mutableStateOf(false) }
val pdtList = listOf(
"DEFAULT" to "Plain Text",
"PCL5" to "HP PCL 5/6",
"EPSON_ESC_P" to "Epson ESC/P",
"POSTSCRIPT" to "PostScript"
)
val selectedPdtLabel = pdtList.firstOrNull { it.first == pdtType }?.second ?: "PDT"
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { pdtExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("PDT: $selectedPdtLabel", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = pdtExpanded,
onDismissRequest = { pdtExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
pdtList.forEach { (type, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
pdtType = type
pdtExpanded = false
}
)
}
}
}
var cpExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { cpExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("CP$codePage", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = cpExpanded,
onDismissRequest = { cpExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 240.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
codePage = cpId
cpExpanded = false
}
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { useTls = !useTls }
) {
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
Text("TLS", fontSize = 11.sp, color = Color.White)
}
Button(
onClick = { startSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", fontSize = 11.sp)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Pages: $pageCount | Bytes: $byteCount",
fontSize = 12.sp,
color = Color.LightGray,
fontFamily = FontFamily.Monospace
)
Button(
onClick = { stopSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Disconnect Printer")
}
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Spool Display Box
Text("Print Spool Buffer:", fontSize = 12.sp, color = Color.Gray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (spoolText.isBlank()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No printed output spooled yet.", color = Color.DarkGray, fontSize = 12.sp)
}
} else {
Text(
text = spoolText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
// Spool Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = {
spoolText = ""
pageCount = 0
byteCount = 0
printerClient?.pd?.resetCapture()
}
) {
Text("Clear", fontSize = 11.sp)
}
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(spoolText))
Toast.makeText(context, "Copied spool to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 11.sp)
}
Button(
onClick = { saveSpoolToFile() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save", fontSize = 11.sp)
}
Button(
onClick = { printViaAndroidService() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Print 🖨️", fontSize = 11.sp)
}
}
}
}
}
}
}
@@ -0,0 +1,471 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
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 androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenExporterDialog(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager? = null,
graphicsPlane: GraphicsPlane? = null,
maskHidden: Boolean = true,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
var statusMessage by remember { mutableStateOf("") }
val plainTextContent = remember(screenBuffer) {
generatePlainText(screenBuffer, rows, cols, maskHidden)
}
val htmlContent = remember(screenBuffer) {
generateHtml(screenBuffer, rows, cols, maskHidden)
}
fun shareText(text: String, isHtml: Boolean) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (isHtml) "text/html" else "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
} catch (e: Exception) {
statusMessage = "Share failed: ${e.message}"
}
}
fun sharePng(bmp: Bitmap) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(cacheDir, "screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
} catch (e: Exception) {
statusMessage = "PNG Share failed: ${e.message}"
}
}
fun saveFileToStorage(format: String): String {
return try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
when (format) {
"HTML" -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
file.writeText(htmlContent)
"Saved to Downloads/${file.name}"
}
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
"Saved to Downloads/${file.name}"
}
else -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
file.writeText(plainTextContent)
"Saved to Downloads/${file.name}"
}
}
} catch (e: Exception) {
"Save failed: ${e.message}"
}
}
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
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Export / Share Screen",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Row {
FilterChip(
selected = (exportFormat == "TEXT"),
onClick = { exportFormat = "TEXT" },
label = { Text("Plain Text", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "HTML"),
onClick = { exportFormat = "HTML" },
label = { Text("HTML", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "PNG"),
onClick = { exportFormat = "PNG" },
label = { Text("PNG", fontSize = 11.sp) }
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Preview Box
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (exportFormat == "PNG") {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
}
} else {
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
Text(
text = textToShow,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (exportFormat != "PNG") {
OutlinedButton(
onClick = {
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
clipboardManager.setText(AnnotatedString(content))
statusMessage = "Copied to clipboard."
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 12.sp)
}
}
Button(
onClick = {
val msg = saveFileToStorage(exportFormat)
statusMessage = msg
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save File", fontSize = 12.sp)
}
Button(
onClick = {
when (exportFormat) {
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
sharePng(bmp)
}
"HTML" -> shareText(htmlContent, true)
else -> shareText(plainTextContent, false)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Share 📤", fontSize = 12.sp)
}
}
}
}
}
}
}
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
val sb = StringBuilder()
var currentHidden = false
for (r in 0 until rows) {
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
} else {
val ch = cell.ucs4
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
}
}
}
sb.append(rowSb.toString().trimEnd()).append("\n")
}
return sb.toString()
}
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF66; font-family: monospace; font-size: 14px; margin: 20px; }\n")
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF66; }\n")
sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #3399FF; }\n")
sb.append(".c-red { color: #FF3333; }\n")
sb.append(".c-yellow { color: #FFFF33; }\n")
sb.append(".c-turquoise { color: #00E5FF; }\n")
sb.append(".c-pink { color: #FF66CC; }\n")
sb.append("</style>\n</head>\n<body>\n<pre>\n")
var currentHidden = false
var currentProt = false
var currentHi = false
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
sb.append(' ')
} else {
if (currentHidden && maskHidden) {
sb.append(' ')
} else {
val ch = cell.ucs4
val colorClass = when {
currentHi -> "c-white"
currentProt -> "c-blue"
else -> "c-green"
}
val escChar = when (ch) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
}
sb.append("<span class=\"$colorClass\">$escChar</span>")
}
}
}
sb.append("\n")
}
sb.append("</pre>\n</body>\n</html>")
return sb.toString()
}
private fun generateBitmap(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager?,
graphicsPlane: GraphicsPlane?,
maskHidden: Boolean
): Bitmap {
val charWidth = 14
val charHeight = 24
val width = cols * charWidth
val height = rows * charHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Fill background
val bgPaint = Paint().apply {
color = android.graphics.Color.parseColor("#0A0A0A")
style = Paint.Style.FILL
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap
val textPaint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = 18f
}
var currentHidden = false
var currentProt = false
var currentHi = false
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getCell(addr)
val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat()
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
} else {
val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.toInt() and 0xFF
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 fgArgb = when {
currentHi -> android.graphics.Color.WHITE
currentProt -> android.graphics.Color.parseColor("#3399FF")
else -> android.graphics.Color.parseColor("#00FF66")
}
val bgArgb = android.graphics.Color.parseColor("#0A0A0A")
val rgbArray = slot.getRgbPixels(fgArgb, bgArgb)
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null)
drawnAsPs = true
}
}
}
if (!drawnAsPs && (!currentHidden || !maskHidden)) {
val ch = cell.ucs4
if (ch in ' '..'~' || ch > '\u007F') {
textPaint.color = when {
currentHi -> android.graphics.Color.WHITE
currentProt -> android.graphics.Color.parseColor("#3399FF")
else -> android.graphics.Color.parseColor("#00FF66")
}
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
}
}
}
}
}
// Overlay vector graphics plane if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val rgb = graphicsPlane.rgbBuffer
val gWidth = graphicsPlane.canvasWidth
val gHeight = graphicsPlane.canvasHeight
if (rgb != null && gWidth > 0 && gHeight > 0) {
val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888)
val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true)
canvas.drawBitmap(scaledGfx, 0f, 0f, null)
}
}
return bitmap
}
@@ -0,0 +1,211 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.horizontalScroll
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.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun ScriptDialog(
initialScript: String = "",
onDismiss: () -> Unit,
onExecuteScript: (script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit) -> Unit
) {
var scriptText by remember { mutableStateOf(initialScript.ifBlank { "# Sample 3270 Script\nLOGON APPLID(TSO)[enter]\n[wait]\nUSERID[tab]PASSWORD[enter]" }) }
var isExecuting by remember { mutableStateOf(false) }
var executionStatus by remember { mutableStateOf("") }
var executionError by remember { mutableStateOf<String?>(null) }
fun insertToken(token: String) {
scriptText += token
}
Dialog(
onDismissRequest = { if (!isExecuting) 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
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Run Keystroke Script / Macro",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"ECL Mnemonic Tokens",
fontSize = 11.sp,
color = Color.Gray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Mnemonic Quick Insert Chips Row 1
Text("Insert Mnemonic Keys:", fontSize = 11.sp, color = Color.LightGray)
val tokenScroll1 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll1),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf(
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]",
"[wordleft]", "[wordright]", "[fieldend]", "[deleteword]", "[cursel]", "[lightpen]"
).forEach { tok ->
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text(tok, fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Mnemonic Quick Insert Chips Row 2 (PF Keys)
val tokenScroll2 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll2),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
(1..24).forEach { pf ->
val tok = "[pf$pf]"
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text("PF$pf", fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Script Editor Box
OutlinedTextField(
value = scriptText,
onValueChange = { scriptText = it },
label = { Text("Script Text / Mnemonic Commands") },
placeholder = { Text("Type characters or tokens like [enter], [pf3], etc.") },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)
)
// Status or Error
if (isExecuting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = executionStatus.ifBlank { "Executing script..." },
fontSize = 12.sp,
color = Color(0xFF339AF0)
)
}
} else if (executionError != null) {
Text(
text = "Error: $executionError",
fontSize = 12.sp,
color = Color(0xFFFF6B6B)
)
} else if (executionStatus.isNotBlank()) {
Text(
text = executionStatus,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = onDismiss,
enabled = !isExecuting
) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = { scriptText = "" },
enabled = !isExecuting
) {
Text("Clear")
}
Button(
onClick = {
if (scriptText.isNotBlank()) {
isExecuting = true
executionError = null
executionStatus = "Starting..."
onExecuteScript(
scriptText,
{ status -> executionStatus = status },
{ error ->
isExecuting = false
if (error != null) {
executionError = error
} else {
executionStatus = "Script completed successfully."
}
}
)
}
},
enabled = !isExecuting && scriptText.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Execute Script ▶")
}
}
}
}
}
}
}
@@ -1,5 +1,7 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,6 +16,8 @@ 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,
@@ -21,22 +25,28 @@ fun SettingsDialog(
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
initialDefaultCodePage: String = "037",
onDismiss: () -> Unit,
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String, defaultCodePage: 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) }
var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) }
Dialog(onDismissRequest = onDismiss) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(12.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
@@ -200,23 +210,115 @@ fun SettingsDialog(
)
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "None"
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = defaultGraphicsMode.equals(modeKey, ignoreCase = true),
onClick = { defaultGraphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
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(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 6: Default EBCDIC Code Page
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage"
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Default EBCDIC Code Page",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Character encoding translation table for new sessions.",
fontSize = 12.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFF2C2D30),
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.clickable { codePageMenuExpanded = true }
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedCpLabel,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
Text("", color = Color.Gray, fontSize = 10.sp)
}
}
DropdownMenu(
expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 280.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontSize = 12.sp,
color = if (defaultCodePage == cpId) Color(0xFF339AF0) else Color.White,
fontWeight = if (defaultCodePage == cpId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
defaultCodePage = cpId
codePageMenuExpanded = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
@@ -231,7 +333,7 @@ fun SettingsDialog(
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode, defaultCodePage)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.content.Context
import android.text.InputType
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
@@ -25,9 +25,9 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
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(
@@ -58,11 +58,17 @@ fun TerminalView(
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
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,
searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
@@ -70,6 +76,9 @@ fun TerminalView(
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) }
@@ -94,7 +103,7 @@ fun TerminalView(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
@@ -103,6 +112,7 @@ fun TerminalView(
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) {
val event = awaitPointerEvent()
@@ -113,7 +123,7 @@ fun TerminalView(
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
@@ -130,22 +140,34 @@ fun TerminalView(
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) {
// Stationary Long Press (held >= 600ms without dragging)
} 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 (Move Cursor)
// Short Tap
showContextMenu = false
selectionStart = null
selectionEnd = null
val cellWidth = size.width / cols
val cellHeight = size.height / rows
if (cellWidth > 0 && cellHeight > 0) {
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col
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()
screenBuffer?.cursorAddress = clickAddr
onGraphicTouch?.invoke(clickAddr, 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)
@@ -159,18 +181,32 @@ fun TerminalView(
}
}
}
}
) {
val width = size.width
val height = size.height
val cellWidth = width / cols
val cellHeight = height / rows
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas
if (cellWidth <= 0 || cellHeight <= 0) 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
textSize = cellHeight * 0.85f
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
@@ -187,11 +223,11 @@ fun TerminalView(
val start = selectionStart
val end = selectionEnd
if (start != null && end != null) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
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)
@@ -204,8 +240,8 @@ fun TerminalView(
val addr = r * cols + c
if (addr >= totalCells) break
val left = c * cellWidth
val top = r * cellHeight
val left = offsetX + c * cellWidth
val top = offsetY + r * cellHeight
var charVal = ' '
var fgColor: Color
@@ -279,9 +315,18 @@ fun TerminalView(
)
}
// Highlight selected block range
// Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 &&
addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen)
if (isSearchHighlighted) {
drawRect(
color = Color(0x99FFC107), // Amber / Gold highlight for search matches
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
} else if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
@@ -290,7 +335,7 @@ fun TerminalView(
}
// Cursor indicator (respects blinking toggle & timer)
if (addr == cursorAddr && !isSelected && cursorVisible) {
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
@@ -323,11 +368,12 @@ fun TerminalView(
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
left + (cellWidth / 4),
textX,
textY,
paint
)
@@ -336,8 +382,8 @@ fun TerminalView(
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2),
end = Offset(left + cellWidth, top + cellHeight - 2),
start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f
)
}
@@ -346,8 +392,8 @@ fun TerminalView(
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = (cols * cellWidth).toInt()
val gridH = (rows * cellHeight).toInt()
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
@@ -356,7 +402,7 @@ fun TerminalView(
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(0f, 0f, gridW.toFloat(), gridH.toFloat()),
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
@@ -418,6 +464,57 @@ fun TerminalView(
}
}
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?,
@@ -429,13 +526,15 @@ private fun copySelection(
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
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 / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
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)
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -1,416 +0,0 @@
package org.pubvm.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 org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() {
companion object {
init {
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = java.util.logging.Level.ALL
for (h in rootLogger.handlers) {
rootLogger.removeHandler(h)
}
rootLogger.addHandler(object : java.util.logging.Handler() {
override fun publish(record: java.util.logging.LogRecord?) {
if (record == null) return
val msg = record.message ?: ""
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
when {
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
android.util.Log.e(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
android.util.Log.w(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
android.util.Log.i(tag, msg, record.thrown)
else ->
android.util.Log.d(tag, msg, record.thrown)
}
}
override fun flush() {}
override fun close() {}
})
}
}
private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false)
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 }
)
}
}
}
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
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift) fNum + 12 else fNum
val pfAids = intArrayOf(
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
viewModel.cursorLeft()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
viewModel.cursorUp()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
viewModel.cursorDown()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
viewModel.cursorRight()
return true
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
viewModel.sendAid(AID_ENTER)
return true
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
if (event.isShiftPressed) {
viewModel.backTab()
} else {
viewModel.tab()
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DEL) {
viewModel.backspace()
return true
} else if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
viewModel.resetKeyboard()
return true
}
}
return super.dispatchKeyEvent(event)
}
}
@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()
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.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,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
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,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
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
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, graphicsMode ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, 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)
}
)
}
}
@@ -1,425 +0,0 @@
package org.pubvm.a3270.ui
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.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.platform.LocalContext
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost
@Composable
fun ConnectDialog(
onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String, useTls: Boolean, tlsVerifyCert: Boolean, graphicsMode: String) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
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 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
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
graphicsMode = "BOTH"
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = host.trim(),
port = port,
model = modelNum,
luName = luName.trim(),
autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
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) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.96f)
.wrapContentHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Title Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
TextButton(
onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("+ New Profile", fontSize = 12.sp)
}
}
// Scrollable Content Container
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.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/Unverified]"
} else ""
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
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 = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = saved.name,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = Color.White
)
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$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.graphicsMode
)
},
modifier = Modifier.size(28.dp)
) {
Text("", fontSize = 13.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == saved.id) {
clearFields()
}
},
modifier = Modifier.size(28.dp)
) {
Text("", fontSize = 12.sp, color = Color(0xFFFF6B6B))
}
}
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 2.dp))
}
Text(
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host / IP Address") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port (default 23 or 992)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Terminal Model
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Model:", fontSize = 11.sp, color = Color.Gray)
listOf(2, 3, 4, 5).forEach { m ->
FilterChip(
selected = (modelNum == m),
onClick = { modelNum = m },
label = { Text("M$m", fontSize = 11.sp) }
)
}
}
// Host System Type (TSO, CMS, CICS)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 11.sp, color = Color.Gray)
listOf("TSO", "CMS", "CICS").forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht, fontSize = 11.sp) }
)
}
}
// Graphics Mode
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Graphics:", fontSize = 11.sp, color = Color.Gray)
listOf(
"PROGRAMMED_SYMBOLS" to "Symbols",
"VECTOR_GRAPHICS" to "Vector",
"BOTH" to "Both",
"NONE" to "None"
).forEach { (modeKey, modeLabel) ->
FilterChip(
selected = (graphicsMode.equals(modeKey, ignoreCase = true)),
onClick = { graphicsMode = modeKey },
label = { Text(modeLabel, fontSize = 11.sp) }
)
}
}
// TLS / SSL Checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newTls = !useTls
useTls = newTls
if (newTls && portStr.trim() == "23") {
portStr = "992"
} else if (!newTls && portStr.trim() == "992") {
portStr = "23"
}
}
) {
Checkbox(
checked = useTls,
onCheckedChange = { checked ->
useTls = checked
if (checked && portStr.trim() == "23") {
portStr = "992"
} else if (!checked && portStr.trim() == "992") {
portStr = "23"
}
}
)
Spacer(modifier = Modifier.width(4.dp))
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
}
// Verify Certificate Checkbox
if (useTls) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
.clickable { tlsVerifyCert = !tlsVerifyCert }
) {
Checkbox(
checked = tlsVerifyCert,
onCheckedChange = { tlsVerifyCert = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Verify Server Certificate", fontSize = 12.sp, color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450))
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
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)
}
}
}
// Action Buttons Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(4.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save")
}
Spacer(modifier = Modifier.width(4.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.graphicsMode
)
}
}
) {
Text("Connect")
}
}
}
}
}
}
@@ -1,258 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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 org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
@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,
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,
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 },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
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.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.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,109 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
class CodePageAndStorageTest {
@Test
fun testSavedHostCodePageSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Test Mainframe",
host = "192.168.1.100",
port = 23,
model = 4,
luName = "LU3270",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.example.com",
proxyPort = 1080,
proxyUsername = "testuser",
proxyPassword = "secretpassword"
)
val json = host.toJson()
assertEquals("1047", json.getString("codePage"))
assertEquals("Test Mainframe", json.getString("name"))
assertTrue(json.getBoolean("autoConnect"))
assertEquals("SOCKS5", json.getString("proxyType"))
assertEquals("proxy.example.com", json.getString("proxyHost"))
assertEquals(1080, json.getInt("proxyPort"))
assertEquals("testuser", json.getString("proxyUsername"))
assertEquals("secretpassword", json.getString("proxyPassword"))
val deserialized = SavedHost.fromJson(json)
assertEquals(host.id, deserialized.id)
assertEquals(host.name, deserialized.name)
assertEquals(host.host, deserialized.host)
assertEquals(host.port, deserialized.port)
assertEquals(host.model, deserialized.model)
assertEquals(host.codePage, deserialized.codePage)
assertEquals(host.graphicsMode, deserialized.graphicsMode)
assertEquals(host.proxyType, deserialized.proxyType)
assertEquals(host.proxyHost, deserialized.proxyHost)
assertEquals(host.proxyPort, deserialized.proxyPort)
assertEquals(host.proxyUsername, deserialized.proxyUsername)
assertEquals(host.proxyPassword, deserialized.proxyPassword)
}
@Test
fun testSavedHostDefaultCodePageFallback() {
// Old JSON without codePage or proxy fields
val json = org.json.JSONObject().apply {
put("name", "Legacy")
put("host", "mvs.local")
}
val deserialized = SavedHost.fromJson(json)
assertEquals("037", deserialized.codePage)
assertEquals("NONE", deserialized.proxyType)
assertEquals("", deserialized.proxyHost)
assertEquals(0, deserialized.proxyPort)
}
@Test
fun testCodePageRegistryResolution() {
val cp037 = CodePageRegistry.getCodePage("037")
assertNotNull(cp037)
assertEquals("037", cp037.codePageId)
val cp1047 = CodePageRegistry.getCodePage("1047")
assertNotNull(cp1047)
assertEquals("1047", cp1047.codePageId)
val cp930 = CodePageRegistry.getCodePage("930")
assertNotNull(cp930)
assertTrue(cp930.isDBCS)
val cp1140 = CodePageRegistry.getCodePage("1140")
assertNotNull(cp1140)
}
@Test
fun testEbcdicTranslatorCodePageSwitching() {
val translator = EbcdicTranslator("037")
assertEquals("037", translator.codePageId)
val translator1047 = EbcdicTranslator("1047")
assertEquals("1047", translator1047.codePageId)
val model = TerminalModel.IBM_3279_2
val sb = ScreenBuffer(model, translator)
assertEquals('A', translator.ebcdicToUnicode(0xC1))
sb.getCell(0).ec = 0xC1.toByte()
sb.translateToUnicode()
assertEquals('A', sb.getCell(0).ucs4)
}
}
@@ -0,0 +1,102 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLFieldList
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class EclAndSearchTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: haus.nightmare.lib3270j.input.InputProcessor
private lateinit var ps: ECLPS
private lateinit var fieldList: ECLFieldList
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, translator, null)
ps = ECLPS(screenBuffer, inputProcessor, translator)
fieldList = ps.fieldList
}
@Test
fun testPresentationSpaceSearchForwardAndBackward() {
val text = "USERID ===> IBMUSER"
for (i in text.indices) {
screenBuffer.getCell(100 + i).ucs4 = text[i]
screenBuffer.getCell(100 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
// Search Forward for "IBMUSER" from row 0 col 0
val foundPos = ps.searchString("IBMUSER", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundPos)
// Case-sensitive search failure
val notFound = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, false)
assertEquals(-1, notFound)
// Case-insensitive search match
val foundCaseInsensitive = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundCaseInsensitive)
// Search Backward from bottom of screen
val foundBackward = ps.searchString("USERID", 23, 79, ECLConstants.SEARCH_BACKWARD, true)
assertEquals(100, foundBackward)
}
@Test
fun testFieldListExtractionAndFlags() {
// Position 0: Protected label
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
val label = "COMMAND ===>"
for (i in label.indices) {
screenBuffer.getCell(1 + i).ucs4 = label[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 20: Unprotected input field with MDT
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
val cmd = "TSO TEST"
for (i in cmd.indices) {
screenBuffer.getCell(21 + i).ucs4 = cmd[i]
screenBuffer.getCell(21 + i).ec = translator.unicodeToEbcdic(cmd[i]).toByte()
}
// Position 40: Hidden field (zero intensity)
screenBuffer.setCellFA(40, (FA_PRINTABLE or FA_PROTECT or FA_INT_ZERO_NSEL).toByte())
fieldList.refresh()
val fields = fieldList.fields
assertEquals(3, fields.size)
// Field 0: Label
val f0 = fields[0]
assertTrue(f0.isProtected)
assertFalse(f0.isModified)
assertEquals(0, f0.start)
assertEquals(1, f0.dataStart)
assertTrue(f0.text.startsWith("COMMAND ===>"))
// Field 1: Input command
val f1 = fields[1]
assertFalse(f1.isProtected)
assertTrue(f1.isModified)
assertEquals(20, f1.start)
assertEquals(21, f1.dataStart)
assertTrue(f1.text.startsWith("TSO TEST"))
// Field 2: Hidden field
val f2 = fields[2]
assertTrue(f2.isHidden)
}
}
@@ -0,0 +1,178 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.datastream.DataStreamProcessor
import haus.nightmare.lib3270j.graphics.GocaDecoder
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.protocol.TelnetConstants.*
import haus.nightmare.lib3270j.protocol.TN3270EConstants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.telnet.TelnetConnection
import haus.nightmare.lib3270j.telnet.TelnetFSM
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.ArrayList
import java.util.concurrent.atomic.AtomicReference
class GraphicsAndSysReqTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var graphicsPlane: GraphicsPlane
private lateinit var gocaDecoder: GocaDecoder
private class MockConnection(config: ConnectionConfig, fsm: TelnetFSM) : TelnetConnection(config, fsm) {
val sentData: MutableList<ByteArray> = ArrayList()
override fun sendRaw(data: ByteArray) {
sentData.add(data.clone())
}
override fun sendRaw(data: ByteArray, offset: Int, length: Int) {
val b = ByteArray(length)
System.arraycopy(data, offset, b, 0, length)
sentData.add(b)
}
}
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
graphicsPlane = GraphicsPlane(800, 600)
gocaDecoder = GocaDecoder(graphicsPlane)
}
@Test
fun testScreenBufferSetCursorPosition() {
screenBuffer.setCursorPosition(5, 10)
assertEquals(5 * 80 + 10, screenBuffer.cursorAddress)
assertEquals(5, screenBuffer.cursorRow)
assertEquals(10, screenBuffer.cursorCol)
// Clamping check
screenBuffer.setCursorPosition(-5, -10)
assertEquals(0, screenBuffer.cursorAddress)
screenBuffer.setCursorPosition(100, 200)
assertEquals(23 * 80 + 79, screenBuffer.cursorAddress)
}
@Test
fun testGraphicsCursorFramingOnSendAid() {
screenBuffer.erase(false)
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ec = 0xC1.toByte() // 'A'
screenBuffer.setCellFA(5, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 2
gocaDecoder.isGraphicsCursorActive = true
gocaDecoder.setGraphicCursorPosition(200, -100)
val sentRef = AtomicReference<ByteArray>()
val inputProcessor = object : InputProcessor(screenBuffer, translator, null) {
override fun sendAidResponse(data: ByteArray) {
sentRef.set(data)
}
}
inputProcessor.gocaDecoder = gocaDecoder
inputProcessor.sendAid(AID_ENTER)
val result = sentRef.get()
assertNotNull(result)
// Expected: AID_SF (0x88) + SF(56 bytes) + AID_ENTER(0x7D) + CursorAddr(2) + SBA(1) + FieldAddr(2) + Data 'A'(1) = 64 bytes
assertEquals(64, result.size)
assertEquals(AID_SF.toByte(), result[0])
assertEquals(0x00.toByte(), result[1])
assertEquals(0x34.toByte(), result[2]) // SF len 52
assertEquals(0x0F.toByte(), result[3])
assertEquals(0x0F.toByte(), result[4])
// Coordinates at index 25, 27
val gx = ((result[25].toInt() and 0xFF) shl 8) or (result[26].toInt() and 0xFF)
val gy = ((result[27].toInt() and 0xFF) shl 8) or (result[28].toInt() and 0xFF)
assertEquals(200, gx.toShort().toInt())
assertEquals(-100, gy.toShort().toInt())
// Trailing AID at index 57
assertEquals(AID_ENTER.toByte(), result[57])
}
@Test
fun testSysReqOutOfBandRoutingAndStateToggle() {
val config = ConnectionConfig("127.0.0.1", 23, TerminalModel.IBM_3279_2).apply {
isTn3270eEnabled = true
}
val dsProcessor = DataStreamProcessor(screenBuffer, translator)
val fsm = TelnetFSM(config, screenBuffer, dsProcessor)
val inputProcessor = InputProcessor(screenBuffer, translator, fsm)
dsProcessor.inputProcessor = inputProcessor
val connection = MockConnection(config, fsm)
fsm.setConnection(connection)
fsm.onConnected()
// 1. Complete TN3270E negotiation
fsm.feedBytes(byteArrayOf(IAC.toByte(), DO.toByte(), TELOPT_TN3270E.toByte()), 0, 3)
val devTypeIs = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
0x02, 0x04, 'I'.code.toByte(), 'B'.code.toByte(), 'M'.code.toByte(), '-'.code.toByte(),
'3'.code.toByte(), '2'.code.toByte(), '7'.code.toByte(), '9'.code.toByte(), '-'.code.toByte(),
'2'.code.toByte(), '-'.code.toByte(), 'E'.code.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(devTypeIs, 0, devTypeIs.size)
val funcsReq = byteArrayOf(
IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(),
OP_FUNCTIONS.toByte(), OP_REQUEST.toByte(),
FUNC_BIND_IMAGE.toByte(), FUNC_RESPONSES.toByte(), FUNC_SYSREQ.toByte(),
IAC.toByte(), SE.toByte()
)
fsm.feedBytes(funcsReq, 0, funcsReq.size)
// BIND image
val bindPacket = ByteArray(EH_SIZE + 35).apply {
this[0] = DT_BIND_IMAGE.toByte()
this[4] = 1
this[EH_SIZE + 24] = 0x02
}
val bStream = ByteArrayOutputStream().apply {
write(bindPacket)
write(IAC)
write(EOR)
}
val bindBytes = bStream.toByteArray()
fsm.feedBytes(bindBytes, 0, bindBytes.size)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
assertTrue(fsm.isTn3270eBound)
connection.sentData.clear()
// 2. Lock keyboard and send SYSREQ
inputProcessor.isKeyboardLocked = true
inputProcessor.sendAid(AID_SYSREQ)
assertFalse(inputProcessor.isKeyboardLocked)
var foundIacAo = false
for (pkt in connection.sentData) {
if (pkt.size == 2 && (pkt[0].toInt() and 0xFF) == IAC && (pkt[1].toInt() and 0xFF) == AO) {
foundIacAo = true
}
}
assertTrue("Expected IAC AO out-of-band telnet command", foundIacAo)
assertEquals(ConnectionState.CONNECTED_SSCP, fsm.connectionState)
// 3. Send second SYSREQ to toggle back
inputProcessor.sendAid(AID_SYSREQ)
assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState)
}
}
@@ -0,0 +1,241 @@
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testUnformattedTypingAndNavigation() {
assertEquals(0, screenBuffer.cursorAddress)
// Type alphanumeric string
val text = "LOGON APPLID(TSO)"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(text.length, screenBuffer.cursorAddress)
for (i in text.indices) {
assertEquals(text[i], screenBuffer.getCell(i).ucs4)
}
// Navigation
inputProcessor.cursorLeft()
assertEquals(text.length - 1, screenBuffer.cursorAddress)
inputProcessor.cursorRight()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorDown()
assertEquals(80 + text.length, screenBuffer.cursorAddress)
inputProcessor.cursorUp()
assertEquals(text.length, screenBuffer.cursorAddress)
inputProcessor.cursorHome()
assertEquals(0, screenBuffer.cursorAddress)
}
@Test
fun testBackspaceAndForwardDelete() {
val text = "HELLO"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
assertEquals(5, screenBuffer.cursorAddress)
// Backspace moves left and deletes char
inputProcessor.backspace()
assertEquals(4, screenBuffer.cursorAddress)
}
@Test
fun testFormattedFieldInputAndTab() {
// Create formatted screen with protected label and unprotected input field
// Position 0: Protected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
// Set label "USERID:" at positions 1..7
val label = "USERID:"
for (i in label.indices) {
screenBuffer.getCell(i + 1).ucs4 = label[i]
screenBuffer.getCell(i + 1).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 10: Unprotected FA
screenBuffer.setCellFA(10, FA_PRINTABLE.toByte())
// Position 20: Protected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 30: Unprotected FA
screenBuffer.setCellFA(30, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 11
// Type into first field
val userid = "IBMUSER"
for (ch in userid) {
inputProcessor.typeCharacter(ch)
}
assertEquals(18, screenBuffer.cursorAddress)
for (i in userid.indices) {
assertEquals(userid[i], screenBuffer.getCell(11 + i).ucs4)
}
// Tab should jump to position 31 (next unprotected field)
inputProcessor.tab()
assertEquals(31, screenBuffer.cursorAddress)
// BackTab should jump back to position 11
inputProcessor.backTab()
assertEquals(11, screenBuffer.cursorAddress)
// Home should jump to first unprotected field (position 11)
screenBuffer.cursorAddress = 50
inputProcessor.cursorHome()
assertEquals(11, screenBuffer.cursorAddress)
}
@Test
fun testFormattedDeleteCharAndEraseEof() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
screenBuffer.cursorAddress = 1
val text = "ABCDE"
for (ch in text) {
inputProcessor.typeCharacter(ch)
}
// Move cursor to 'C' at pos 3
screenBuffer.cursorAddress = 3
inputProcessor.deleteChar()
// "ABDE "
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals('D', screenBuffer.getCell(3).ucs4)
assertEquals('E', screenBuffer.getCell(4).ucs4)
assertEquals(0, screenBuffer.getCell(5).ec.toInt())
// Erase EOF from pos 3
inputProcessor.eraseEof()
assertEquals('A', screenBuffer.getCell(1).ucs4)
assertEquals('B', screenBuffer.getCell(2).ucs4)
assertEquals(0, screenBuffer.getCell(3).ec.toInt())
assertEquals(0, screenBuffer.getCell(4).ec.toInt())
}
@Test
fun testPfKeyAids() {
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
)
for (i in 1..24) {
val aid = pfAids[i - 1]
inputProcessor.sendAid(aid)
assertEquals(aid, inputProcessor.lastAid)
assertTrue(inputProcessor.isKeyboardLocked)
inputProcessor.reset()
assertFalse(inputProcessor.isKeyboardLocked)
}
}
@Test
fun testLightPenSelection() {
// Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH)
// Designator at pos 1: '?' (EBCDIC 0x6F)
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
assertTrue(haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF))
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '?'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte()
// Field 2 at pos 20: Non-selectable (normal intensity)
screenBuffer.setCellFA(20, FA_PRINTABLE.toByte())
screenBuffer.getCell(21).ucs4 = '?'
// Selecting field 1 ('?') toggles designator to '>' and sets modified bit
val result1 = inputProcessor.lightPenSelect(1)
assertTrue(result1)
assertEquals('>', screenBuffer.getCell(1).ucs4)
assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec)
val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF
assertTrue((fa1 and FA_MODIFY) != 0)
// Selecting field 1 again ('>') toggles back to '?' and clears modified bit
val result2 = inputProcessor.lightPenSelect(1)
assertTrue(result2)
assertEquals('?', screenBuffer.getCell(1).ucs4)
assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec)
// Selecting field 2 (non-selectable) returns false
val resultNonSelectable = inputProcessor.lightPenSelect(21)
assertFalse(resultNonSelectable)
}
@Test
fun testLightPenEnterSelection() {
// Field with '&' designator immediately sends AID_ENTER
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '&'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte()
val result = inputProcessor.lightPenSelect(1)
assertTrue(result)
assertEquals(AID_ENTER, inputProcessor.lastAid)
}
@Test
fun testEraseInputAndAttention() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 20: Unprotected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ucs4 = 'X'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('X').toByte()
screenBuffer.getCell(21).ucs4 = 'Y'
screenBuffer.getCell(21).ec = translator.unicodeToEbcdic('Y').toByte()
inputProcessor.eraseInput()
// Unprotected fields should be cleared and reset
assertEquals(0, screenBuffer.getCell(1).ec.toInt())
assertEquals(0, screenBuffer.getCell(21).ec.toInt())
assertEquals(1, screenBuffer.cursorAddress)
// Attention AID
inputProcessor.sendAid(AID_PA1)
assertEquals(AID_PA1, inputProcessor.lastAid)
}
}
@@ -0,0 +1,67 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.printer.PrinterConfig
import haus.nightmare.lib3270j.printer.PrinterConstants
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class ScriptAndPrinterTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testEmulateInputMnemonicTokens() {
// Position 0: Unprotected input field
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 1
inputProcessor.sendKeys("HELLO[enter]")
assertEquals('H', screenBuffer.getCell(1).ucs4)
assertEquals('E', screenBuffer.getCell(2).ucs4)
assertEquals('L', screenBuffer.getCell(3).ucs4)
assertEquals('L', screenBuffer.getCell(4).ucs4)
assertEquals('O', screenBuffer.getCell(5).ucs4)
assertEquals(AID_ENTER, inputProcessor.lastAid)
inputProcessor.reset()
inputProcessor.sendKeys("[pf3]")
assertEquals(AID_PF3, inputProcessor.lastAid)
}
@Test
fun testPrinterConfigParameters() {
val config = PrinterConfig("10.0.0.1", 992, "PRT01", true).apply {
associatedDisplayLuName = "DSP01"
codePage = "1047"
mpp = 132
mpl = 66
destinationType = PrinterConfig.DestinationType.MEMORY
}
assertEquals("10.0.0.1", config.host)
assertEquals(992, config.port)
assertEquals("PRT01", config.printerLuName)
assertEquals("DSP01", config.associatedDisplayLuName)
assertTrue(config.isUseTls)
assertEquals("1047", config.codePage)
assertEquals(132, config.mpp)
assertEquals(66, config.mpl)
assertEquals(PrinterConfig.DestinationType.MEMORY, config.destinationType)
}
}