9 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
30 changed files with 4282 additions and 428 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) [![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) [![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. 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 ## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine - [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
+5 -4
View File
@@ -4,15 +4,15 @@ plugins {
} }
android { android {
namespace 'org.pubvm.a3270' namespace 'haus.nightmare.a3270'
compileSdk 34 compileSdk 34
defaultConfig { defaultConfig {
applicationId "org.pubvm.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 4 versionCode 6
versionName "0.1.3" versionName "1.0.1"
} }
buildTypes { buildTypes {
@@ -71,5 +71,6 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' 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) # Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem" 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 elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current" export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi fi
fi fi
@@ -19,6 +21,8 @@ fi
if [ -z "$ANDROID_HOME" ]; then if [ -z "$ANDROID_HOME" ]; then
if [ -d "$HOME/Library/Android/sdk" ]; then if [ -d "$HOME/Library/Android/sdk" ]; then
export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_HOME="$HOME/Library/Android/sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
export ANDROID_HOME="$HOME/Android/Sdk"
fi fi
fi fi
+1
View File
@@ -1,3 +1,4 @@
android.useAndroidX=true android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
kotlin.compiler.execution.strategy=in-process
@@ -1,4 +1,4 @@
package org.pubvm.a3270 package haus.nightmare.a3270
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
@@ -23,18 +23,23 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import android.content.Context import android.content.Context
import android.view.KeyCharacterMap import android.view.KeyCharacterMap
import org.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.pubvm.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage import haus.nightmare.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog import haus.nightmare.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog import haus.nightmare.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.OiaStatusBar import haus.nightmare.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog import haus.nightmare.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView import haus.nightmare.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView import haus.nightmare.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar 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 org.pubvm.a3270.ui.UntrustedCertificateDialog import haus.nightmare.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -144,6 +149,7 @@ class MainActivity : ComponentActivity() {
return 0 return 0
} }
@Suppress("DEPRECATION")
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0) val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) { if (isShiftPressedState.value != shift) {
@@ -209,6 +215,26 @@ class MainActivity : ComponentActivity() {
viewModel.sendAid(AID_PA1) viewModel.sendAid(AID_PA1)
return true 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 -> { KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome() viewModel.cursorHome()
return true return true
@@ -243,6 +269,20 @@ class MainActivity : ComponentActivity() {
viewModel.resetKeyboard() viewModel.resetKeyboard()
return true 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
}
} }
} }
@@ -287,7 +327,12 @@ class MainActivity : ComponentActivity() {
return true return true
} }
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (shift) {
viewModel.eraseEof()
isShiftPressedState.value = false
} else {
viewModel.sendAid(AID_ENTER) viewModel.sendAid(AID_ENTER)
}
return true return true
} }
KeyEvent.KEYCODE_TAB -> { KeyEvent.KEYCODE_TAB -> {
@@ -309,7 +354,7 @@ class MainActivity : ComponentActivity() {
} }
KeyEvent.KEYCODE_ESCAPE -> { KeyEvent.KEYCODE_ESCAPE -> {
if (shift) { if (shift) {
viewModel.sendAid(AID_CLEAR) viewModel.eraseInput()
isShiftPressedState.value = false isShiftPressedState.value = false
} else { } else {
viewModel.resetKeyboard() viewModel.resetKeyboard()
@@ -395,14 +440,23 @@ fun MainScreen(
val hapticFeedback by viewModel.hapticFeedback.collectAsState() val hapticFeedback by viewModel.hapticFeedback.collectAsState()
val verifyCerts by viewModel.verifyCerts.collectAsState() val verifyCerts by viewModel.verifyCerts.collectAsState()
val defaultGraphicsMode by viewModel.defaultGraphicsMode.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 isTlsActive by viewModel.isTlsActive.collectAsState()
val isTlsVerified by viewModel.isTlsVerified.collectAsState() val isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState() val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState() val ftState by viewModel.ftState.collectAsState()
val isLightPenMode by viewModel.isLightPenMode.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog 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) } var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
@@ -433,7 +487,13 @@ fun MainScreen(
autoHost.useTls, autoHost.useTls,
autoHost.tlsVerifyCert, autoHost.tlsVerifyCert,
autoHost.tn3270e, autoHost.tn3270e,
autoHost.graphicsMode autoHost.graphicsMode,
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
) )
} }
} }
@@ -466,12 +526,23 @@ fun MainScreen(
screenVersion = screenVersion, screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager, programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane, graphicsPlane = viewModel.getClient()?.graphicsPlane,
gocaDecoder = viewModel.getGocaDecoder(),
isLightPenMode = isLightPenMode,
maskHiddenFields = maskHiddenInput, maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink, blinkCursor = cursorBlink,
searchHighlightAddr = searchHighlightAddr,
searchHighlightLen = searchHighlightLen,
onTapAddress = { addr -> onTapAddress = { addr ->
viewModel.setCursor(addr) viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onLightPenSelect = { addr ->
viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onGraphicTouch = { clickAddr, px, py ->
viewModel.handleGraphicTouch(clickAddr, px, py)
},
onPasteText = { text -> onPasteText = { text ->
viewModel.pasteString(text) viewModel.pasteString(text)
}, },
@@ -512,11 +583,18 @@ fun MainScreen(
TwoRowKeyBar( TwoRowKeyBar(
connectionState = connectionState, connectionState = connectionState,
isShiftPressed = isShiftPressed, isShiftPressed = isShiftPressed,
isLightPenMode = isLightPenMode,
hapticFeedbackEnabled = hapticFeedback, hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift, onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true }, onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() }, onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true }, onFtClick = { showFtDialog = true },
onFieldInspectorClick = { showFieldInspectorDialog = true },
onFindClick = { showFindDialog = true },
onScreenExportClick = { showScreenExportDialog = true },
onScriptClick = { showScriptDialog = true },
onPrinterSessionClick = { showPrinterSessionDialog = true },
onToggleLightPen = { viewModel.toggleLightPen() },
onSettingsClick = { showSettingsDialog = true }, onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid -> onSendAid = { aid ->
viewModel.sendAid(aid) viewModel.sendAid(aid)
@@ -534,6 +612,26 @@ fun MainScreen(
viewModel.backTab() viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard() 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 = { onCursorLeft = {
viewModel.cursorLeft() viewModel.cursorLeft()
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
@@ -563,7 +661,12 @@ fun MainScreen(
cols = cols, cols = cols,
isTls = isTlsActive, isTls = isTlsActive,
isTlsVerified = isTlsVerified, isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode,
isInsertMode = viewModel.getClient()?.inputProcessor?.isInsertMode ?: false,
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: ""
) )
} }
} }
@@ -571,9 +674,9 @@ fun MainScreen(
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode -> onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode) viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -583,6 +686,7 @@ fun MainScreen(
FileTransferDialog( FileTransferDialog(
initialHostType = viewModel.activeHostType, initialHostType = viewModel.activeHostType,
ftProgressState = ftState, ftProgressState = ftState,
client = viewModel.getClient(),
onDismiss = { showFtDialog = false }, onDismiss = { showFtDialog = false },
onStartTransfer = { config -> onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error -> viewModel.startFileTransfer(config) { error ->
@@ -604,15 +708,72 @@ fun MainScreen(
initialHapticFeedback = hapticFeedback, initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts, initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode, initialDefaultGraphicsMode = defaultGraphicsMode,
initialDefaultCodePage = codePage,
onDismiss = { showSettingsDialog = false }, onDismiss = { showSettingsDialog = false },
onSave = { mask, blink, haptic, verify, gfx -> onSave = { mask, blink, haptic, verify, gfx, defaultCp ->
showSettingsDialog = false showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx) viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
terminalInputViewRef?.showSoftKeyboard() 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 val prompt = untrustedCertPrompt
if (prompt != null) { if (prompt != null) {
UntrustedCertificateDialog( UntrustedCertificateDialog(
@@ -1,4 +1,4 @@
package org.pubvm.a3270 package haus.nightmare.a3270
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
@@ -11,19 +11,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig import haus.nightmare.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel import haus.nightmare.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener import haus.nightmare.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.protocol.DS3270Constants.faIsProtected import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.lib3270j.screen.ScreenBuffer import haus.nightmare.a3270.ft.FileTransfer
import org.pubvm.a3270.ft.FileTransfer import haus.nightmare.a3270.service.TerminalService
import org.pubvm.a3270.service.TerminalService import haus.nightmare.a3270.storage.AppSettings
import org.pubvm.a3270.storage.AppSettings
import java.io.File import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -44,7 +43,18 @@ sealed interface TerminalInputAction {
data object EraseInput : TerminalInputAction data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction data object Newline : TerminalInputAction
data object Reset : 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 SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
} }
data class UntrustedCertPromptState( data class UntrustedCertPromptState(
@@ -115,6 +125,15 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application)) private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow() 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) private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow() val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
@@ -124,6 +143,37 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null) private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow() 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 // File Transfer State
private var fileTransferCoordinator: FileTransfer? = null private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState()) private val _ftState = MutableStateFlow(FTProgressState())
@@ -164,88 +214,231 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val c = client ?: return val c = client ?: return
val ip = c.inputProcessor val ip = c.inputProcessor
val buf = c.screenBuffer val buf = c.screenBuffer
val isNvt = c.connectionState.isNvt || ip.isNvtMode
try { try {
when (action) { when (action) {
is TerminalInputAction.TypeText -> { is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}") log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false 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) { for (ch in action.text) {
if (ch == '\n' || ch == '\r') { if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false) ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER) ip.sendAid(AID_ENTER)
} else if (ch >= ' ') { } 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) ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'") log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'")
}
} }
} }
} }
is TerminalInputAction.SendAid -> { 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.setKeyboardLocked(false)
ip.sendAid(action.aidCode) ip.sendAid(action.aidCode)
} }
}
is TerminalInputAction.Backspace -> { is TerminalInputAction.Backspace -> {
if (isNvt) {
c.sendNVTChar('\b')
} else {
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
ip.backspace() ip.backspace()
} }
}
is TerminalInputAction.DeleteChar -> { is TerminalInputAction.DeleteChar -> {
if (isNvt) {
c.sendNVTString("\u001B[3~")
} else {
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
ip.deleteChar() ip.deleteChar()
} }
}
is TerminalInputAction.Tab -> { is TerminalInputAction.Tab -> {
if (isNvt) {
c.sendNVTChar('\t')
} else {
ip.tab() ip.tab()
} }
}
is TerminalInputAction.BackTab -> { is TerminalInputAction.BackTab -> {
if (isNvt) {
c.sendNVTString("\u001B[Z")
} else {
ip.backTab() ip.backTab()
} }
}
is TerminalInputAction.CursorLeft -> { is TerminalInputAction.CursorLeft -> {
if (isNvt) {
c.sendNVTString("\u001B[D")
} else {
ip.cursorLeft() ip.cursorLeft()
} }
}
is TerminalInputAction.CursorRight -> { is TerminalInputAction.CursorRight -> {
if (isNvt) {
c.sendNVTString("\u001B[C")
} else {
ip.cursorRight() ip.cursorRight()
} }
}
is TerminalInputAction.CursorUp -> { is TerminalInputAction.CursorUp -> {
if (isNvt) {
c.sendNVTString("\u001B[A")
} else {
ip.cursorUp() ip.cursorUp()
} }
}
is TerminalInputAction.CursorDown -> { is TerminalInputAction.CursorDown -> {
if (isNvt) {
c.sendNVTString("\u001B[B")
} else {
ip.cursorDown() ip.cursorDown()
} }
}
is TerminalInputAction.CursorHome -> { is TerminalInputAction.CursorHome -> {
if (isNvt) {
c.sendNVTString("\u001B[H")
} else {
ip.cursorHome() ip.cursorHome()
} }
}
is TerminalInputAction.EraseEof -> { is TerminalInputAction.EraseEof -> {
if (isNvt) {
c.sendNVTString("\u001B[F")
} else {
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
ip.eraseEof() ip.eraseEof()
} }
}
is TerminalInputAction.EraseInput -> { is TerminalInputAction.EraseInput -> {
if (!isNvt) {
ip.isKeyboardLocked = false ip.isKeyboardLocked = false
ip.eraseInput() ip.eraseInput()
} }
}
is TerminalInputAction.Newline -> { is TerminalInputAction.Newline -> {
if (isNvt) {
c.sendNVTString("\r\n")
} else {
ip.newline() ip.newline()
} }
}
is TerminalInputAction.Reset -> { is TerminalInputAction.Reset -> {
if (isNvt) {
c.sendNVTChar('\u001B')
} else {
ip.reset() 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 -> { is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) { if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr 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) { } catch (e: Exception) {
log.warning("Error processing input action: ${e.message}") 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) { fun resolveUntrustedCert(accept: Boolean) {
try { try {
val prompt = _untrustedCertPrompt.value val prompt = _untrustedCertPrompt.value
@@ -258,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.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink) AppSettings.setCursorBlinkEnabled(getApplication(), blink)
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic) AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal) AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal) AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
AppSettings.setDefaultCodePage(getApplication(), defaultCodePageVal)
_maskHiddenInput.value = maskHidden _maskHiddenInput.value = maskHidden
_cursorBlink.value = blink _cursorBlink.value = blink
_hapticFeedback.value = haptic _hapticFeedback.value = haptic
_verifyCerts.value = verifyCertsVal _verifyCerts.value = verifyCertsVal
_defaultGraphicsMode.value = defaultGraphicsVal _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( fun connect(
@@ -280,7 +548,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
useTls: Boolean = false, useTls: Boolean = false,
tlsVerifyCert: Boolean = true, tlsVerifyCert: Boolean = true,
tn3270e: Boolean = true, tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH" graphicsModeStr: String = "BOTH",
codePageStr: String = "037",
proxyTypeStr: String = "NONE",
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
proxyPass: String = ""
) { ) {
currentHost = host currentHost = host
activeHostType = hostType activeHostType = hostType
@@ -288,6 +562,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
fileTransferCoordinator = null fileTransferCoordinator = null
_ftState.value = FTProgressState() _ftState.value = FTProgressState()
_codePage.value = codePageStr
clearSearchHighlight()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -314,7 +590,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication()) val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr) val gMode = haus.nightmare.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_isTlsActive.value = effectiveTls _isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify _isTlsVerified.value = effectiveVerify
@@ -328,9 +604,20 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
isTlsVerifyCert = effectiveVerify isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode 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())) { if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings") log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true return@TlsCertificateVerifier true
@@ -358,6 +645,32 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val newClient = Telnet3270Client(config) val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient 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 { newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState _connectionState.value = newState
@@ -587,6 +900,46 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Reset) 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() { fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof) inputChannel.trySend(TerminalInputAction.EraseEof)
} }
@@ -627,6 +980,31 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.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 { private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received" if (buf == null) return "Mainframe screen update received"
val rows = buf.rows 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.Handler
import android.os.Looper import android.os.Looper
import org.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState import haus.nightmare.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut import haus.nightmare.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft import haus.nightmare.lib3270j.ft.FTDft
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Timer 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.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -10,8 +10,8 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity import haus.nightmare.a3270.MainActivity
import org.pubvm.a3270.R import haus.nightmare.a3270.R
class TerminalService : Service() { class TerminalService : Service() {
@@ -1,4 +1,4 @@
package org.pubvm.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
@@ -11,6 +11,7 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs" private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode" 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 { private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -55,4 +56,12 @@ object AppSettings {
fun setDefaultGraphicsMode(context: Context, mode: String) { fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply() 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
@@ -18,7 +18,13 @@ data class SavedHost(
val useTls: Boolean = false, val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true, val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true, val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH" 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 { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
@@ -34,6 +40,12 @@ data class SavedHost(
put("tlsVerifyCert", tlsVerifyCert) put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e) put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode) put("graphicsMode", graphicsMode)
put("codePage", codePage)
put("proxyType", proxyType)
put("proxyHost", proxyHost)
put("proxyPort", proxyPort)
put("proxyUsername", proxyUsername)
put("proxyPassword", proxyPassword)
} }
} }
@@ -51,7 +63,13 @@ data class SavedHost(
useTls = json.optBoolean("useTls", false), useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true), tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true), tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH") 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", "")
) )
} }
} }
@@ -114,6 +132,51 @@ object HostStorage {
saveAll(context, currentHosts) 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? { fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect } return getSavedHosts(context).firstOrNull { it.autoConnect }
} }
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -17,13 +18,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import org.pubvm.a3270.storage.HostStorage import haus.nightmare.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost import haus.nightmare.a3270.storage.SavedHost
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable @Composable
fun ConnectDialog( fun ConnectDialog(
@@ -37,7 +47,13 @@ fun ConnectDialog(
useTls: Boolean, useTls: Boolean,
tlsVerifyCert: Boolean, tlsVerifyCert: Boolean,
tn3270e: Boolean, tn3270e: Boolean,
graphicsMode: String graphicsMode: String,
codePage: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: String
) -> Unit ) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -55,6 +71,13 @@ fun ConnectDialog(
var tlsVerifyCert by remember { mutableStateOf(true) } var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) } var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") } 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) { fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id selectedHostId = saved.id
@@ -69,6 +92,13 @@ fun ConnectDialog(
tlsVerifyCert = saved.tlsVerifyCert tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode 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() { fun clearFields() {
@@ -84,16 +114,24 @@ fun ConnectDialog(
tlsVerifyCert = true tlsVerifyCert = true
tn3270e = true tn3270e = true
graphicsMode = "BOTH" graphicsMode = "BOTH"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
proxyType = "NONE"
proxyHost = ""
proxyPortStr = ""
proxyUsername = ""
proxyPassword = ""
showProxySettings = false
} }
fun saveCurrentProfile(): SavedHost? { fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23 val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null val trimmedHost = host.trim()
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" } if (trimmedHost.isBlank()) return null
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
val hostToSave = SavedHost( val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(), id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave, name = nameToSave,
host = host.trim(), host = trimmedHost,
port = port, port = port,
model = modelNum, model = modelNum,
luName = luName.trim(), luName = luName.trim(),
@@ -102,7 +140,13 @@ fun ConnectDialog(
useTls = useTls, useTls = useTls,
tlsVerifyCert = tlsVerifyCert, tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e, tn3270e = tn3270e,
graphicsMode = graphicsMode graphicsMode = graphicsMode,
codePage = codePage,
proxyType = proxyType,
proxyHost = proxyHost.trim(),
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
proxyUsername = proxyUsername.trim(),
proxyPassword = proxyPassword.trim()
) )
HostStorage.saveHost(context, hostToSave) HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
@@ -234,7 +278,13 @@ fun ConnectDialog(
hostToConn.useTls, hostToConn.useTls,
hostToConn.tlsVerifyCert, hostToConn.tlsVerifyCert,
hostToConn.tn3270e, hostToConn.tn3270e,
hostToConn.graphicsMode hostToConn.graphicsMode,
hostToConn.codePage,
hostToConn.proxyType,
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword
) )
}, },
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
@@ -273,6 +323,7 @@ fun ConnectDialog(
onValueChange = { profileName = it }, onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") }, label = { Text("Profile Name (Optional)") },
singleLine = true, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -282,18 +333,20 @@ fun ConnectDialog(
) { ) {
OutlinedTextField( OutlinedTextField(
value = host, value = host,
onValueChange = { host = it }, onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
label = { Text("Host / IP Address") }, label = { Text("Host / IP Address") },
singleLine = true, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier modifier = Modifier
.weight(0.68f) .weight(0.68f)
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
value = portStr, value = portStr,
onValueChange = { portStr = it }, onValueChange = { portStr = it.filter { c -> c.isDigit() } },
label = { Text("Port") }, label = { Text("Port") },
singleLine = true, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f) modifier = Modifier.weight(0.32f)
) )
} }
@@ -412,11 +465,73 @@ fun ConnectDialog(
} }
} }
// 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( OutlinedTextField(
value = luName, value = luName,
onValueChange = { luName = it }, onValueChange = { luName = it },
label = { Text("LU Name / Device Pool (Optional)") }, label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -506,6 +621,102 @@ fun ConnectDialog(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White) 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)
)
}
}
}
}
}
} }
} }
} }
@@ -539,7 +750,13 @@ fun ConnectDialog(
saved.useTls, saved.useTls,
saved.tlsVerifyCert, saved.tlsVerifyCert,
saved.tn3270e, saved.tn3270e,
saved.graphicsMode saved.graphicsMode,
saved.codePage,
saved.proxyType,
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword
) )
} }
}, },
@@ -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 android.view.HapticFeedbackConstants
import androidx.compose.foundation.background 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@Composable @Composable
fun TwoRowKeyBar( fun TwoRowKeyBar(
connectionState: ConnectionState, connectionState: ConnectionState,
isShiftPressed: Boolean = false, isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
hapticFeedbackEnabled: Boolean = true, hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {}, onClearShift: () -> Unit = {},
onConnectClick: () -> Unit, onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit, onDisconnectClick: () -> Unit,
onFtClick: () -> Unit, onFtClick: () -> Unit,
onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {},
onScriptClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {},
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit, onSendAid: (Int) -> Unit,
onReset: () -> Unit, onReset: () -> Unit,
onTab: () -> Unit, onTab: () -> Unit,
onBackTab: () -> Unit, onBackTab: () -> Unit,
onEraseInput: () -> Unit = {},
onEraseEof: () -> Unit = {},
onAttn: () -> Unit = {},
onSysReq: () -> Unit = {},
onCursorSelect: () -> Unit = {},
onCursorLeft: () -> Unit = {}, onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {}, onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {}, onCursorDown: () -> Unit = {},
@@ -53,7 +65,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp), .padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically 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)) { Box(modifier = Modifier.weight(1f)) {
KeyButton( KeyButton(
label = "", label = "",
@@ -66,7 +78,9 @@ fun TwoRowKeyBar(
DropdownMenu( DropdownMenu(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }, onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)) modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 420.dp)
) { ) {
if (!connectionState.isConnected()) { if (!connectionState.isConnected()) {
DropdownMenuItem( DropdownMenuItem(
@@ -76,6 +90,13 @@ fun TwoRowKeyBar(
onConnectClick() onConnectClick()
} }
) )
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
} else { } else {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) }, text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
@@ -84,14 +105,93 @@ fun TwoRowKeyBar(
onDisconnectClick() onDisconnectClick()
} }
) )
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem( 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 = { onClick = {
menuExpanded = false menuExpanded = false
onFtClick() 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( DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) }, text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = { onClick = {
@@ -120,14 +220,59 @@ fun TwoRowKeyBar(
} }
) )
// 3. RESET // 3. RESET / ERASE (Erase Input) depending on Shift state
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset) 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 // 4. ENTER / EOF (Erase EOF) depending on Shift state
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) }) 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 // 5. CLEAR / ATTN (Attention) depending on Shift state
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) }) 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 // 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_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.background
import androidx.compose.foundation.layout.* 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState import haus.nightmare.lib3270j.ConnectionState
@Composable @Composable
fun OiaStatusBar( fun OiaStatusBar(
@@ -27,28 +27,49 @@ fun OiaStatusBar(
isTls: Boolean = false, isTls: Boolean = false,
isTlsVerified: Boolean = true, isTlsVerified: Boolean = true,
graphicsMode: String = "NONE", graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
inhibitReason: Int = 0,
luName: String = "",
modifier: Modifier = Modifier 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 col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col) val posStr = String.format("%02d/%02d", row, col)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) { val connTypeBadge = when (connectionState) {
"OFFLINE" ConnectionState.CONNECTED_3270 -> "TN3270"
} else if (isKeyboardLocked) { ConnectionState.CONNECTED_TN3270E -> "TN3270E"
"X SYSTEM" ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
} else { ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
"READY" ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
ConnectionState.NOT_CONNECTED -> "OFFLINE"
else -> connectionState.name
} }
val lockStatusColor = if (!connectionState.isConnected()) { val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
Color(0xFF868E96) "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) { } else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked "X SYSTEM" to Color(0xFFFF6B6B)
} else if (isInsertMode) {
"INSERT" to Color(0xFF339AF0)
} else { } else {
Color(0xFF51CF66) // Green for ready "READY" to Color(0xFF51CF66)
} }
Row( 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) // 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,6 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -24,14 +25,16 @@ fun SettingsDialog(
initialHapticFeedback: Boolean = true, initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true, initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH", initialDefaultGraphicsMode: String = "BOTH",
initialDefaultCodePage: String = "037",
onDismiss: () -> Unit, 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 maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) } var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) } var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) } var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) } var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
var defaultCodePage by remember { mutableStateOf(initialDefaultCodePage) }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -242,6 +245,81 @@ fun SettingsDialog(
} }
} }
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)) Spacer(modifier = Modifier.height(24.dp))
// Action Buttons // Action Buttons
@@ -255,7 +333,7 @@ fun SettingsDialog(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode) onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode, defaultCodePage)
}, },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) 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.content.Context
import android.text.InputType import android.text.InputType
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager 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. * 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.Paint
import android.graphics.Typeface 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.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute import haus.nightmare.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette) // Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf( private val HOST_COLORS = arrayOf(
@@ -58,11 +58,17 @@ fun TerminalView(
cols: Int, cols: Int,
cursorAddr: Int, cursorAddr: Int,
screenVersion: Long, screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null, programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null, graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true, maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true, blinkCursor: Boolean = true,
searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {}, onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -97,7 +103,7 @@ fun TerminalView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(COLOR_BLACK) .background(COLOR_BLACK)
.pointerInput(cols, rows) { .pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
@@ -106,6 +112,7 @@ fun TerminalView(
var currentPos = startPos var currentPos = startPos
var isDragStarted = false var isDragStarted = false
val slop = viewConfiguration.touchSlop val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
@@ -116,7 +123,7 @@ fun TerminalView(
currentPos = change.position currentPos = change.position
val distance = (currentPos - startPos).getDistance() val distance = (currentPos - startPos).getDistance()
if (distance > slop) { if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) { if (!isDragStarted) {
isDragStarted = true isDragStarted = true
selectionStart = startPos selectionStart = startPos
@@ -133,12 +140,12 @@ fun TerminalView(
if (isDragStarted) { if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu // Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) { } else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) // Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos contextMenuOffset = startPos
showContextMenu = true showContextMenu = true
} else if (totalDistance <= slop) { } else if (totalDistance <= slop) {
// Short Tap (Move Cursor) // Short Tap
showContextMenu = false showContextMenu = false
selectionStart = null selectionStart = null
selectionEnd = null selectionEnd = null
@@ -146,8 +153,21 @@ fun TerminalView(
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) { if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1) 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 row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col 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 val buf = screenBuffer
if (buf != null && buf.isFormatted) { if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr) val faVal = buf.getFieldAttributeAt(addr)
@@ -161,6 +181,7 @@ fun TerminalView(
} }
} }
} }
}
) { ) {
val version = currentScreenVersion val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr val activeCursorAddr = currentCursorAddr
@@ -296,7 +317,16 @@ fun TerminalView(
// Draw selection box highlight if selected // Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol 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( drawRect(
color = Color(0x773399FF), color = Color(0x773399FF),
topLeft = Offset(left, top), topLeft = Offset(left, top),
@@ -322,7 +352,7 @@ fun TerminalView(
val symHeight = slot.height val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb()) val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) { if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888) val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap( drawContext.canvas.nativeCanvas.drawBitmap(
bmp, bmp,
null, null,
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui package haus.nightmare.a3270.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -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)
}
}
@@ -1,13 +1,13 @@
package org.pubvm.a3270 package haus.nightmare.a3270
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.lib3270j.TerminalModel import haus.nightmare.lib3270j.TerminalModel
import org.lib3270j.charset.EbcdicTranslator import haus.nightmare.lib3270j.charset.EbcdicTranslator
import org.lib3270j.input.InputProcessor import haus.nightmare.lib3270j.input.InputProcessor
import org.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ScreenBuffer import haus.nightmare.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest { class HardwareKeyboardInputTest {
@@ -165,4 +165,77 @@ class HardwareKeyboardInputTest {
assertFalse(inputProcessor.isKeyboardLocked) 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)
}
}