2 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
13 changed files with 929 additions and 173 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 5
versionName "1.0.0"
versionCode 6
versionName "1.0.1"
}
buildTypes {
@@ -215,6 +215,26 @@ class MainActivity : ComponentActivity() {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_A -> {
viewModel.attn()
return true
}
KeyEvent.KEYCODE_S -> {
viewModel.sysReq()
return true
}
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.wordLeft()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.wordRight()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> {
viewModel.deleteWord()
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
@@ -249,6 +269,14 @@ class MainActivity : ComponentActivity() {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_D -> {
viewModel.deleteWord()
return true
}
KeyEvent.KEYCODE_E -> {
viewModel.fieldEnd()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.toggleLightPen()
val isLp = viewModel.isLightPenMode.value
@@ -460,7 +488,12 @@ fun MainScreen(
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode,
autoHost.codePage
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
)
}
}
@@ -588,15 +621,15 @@ fun MainScreen(
terminalInputViewRef?.showSoftKeyboard()
},
onAttn = {
viewModel.sendAid(AID_PA1)
viewModel.attn()
terminalInputViewRef?.showSoftKeyboard()
},
onSysReq = {
viewModel.sendAid(AID_SYSREQ)
viewModel.sysReq()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorSelect = {
viewModel.sendAid(AID_SELECT)
viewModel.cursorSelect()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorLeft = {
@@ -630,7 +663,10 @@ fun MainScreen(
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode,
codePage = codePage,
isLightPenMode = isLightPenMode
isLightPenMode = isLightPenMode,
isInsertMode = viewModel.getClient()?.inputProcessor?.isInsertMode ?: false,
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: ""
)
}
}
@@ -638,9 +674,9 @@ fun MainScreen(
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp ->
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp)
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
terminalInputViewRef?.showSoftKeyboard()
}
)
@@ -696,6 +732,7 @@ fun MainScreen(
if (showFindDialog) {
FindDialog(
cols = cols,
onDismiss = { showFindDialog = false },
onFind = { query: String, matchCase: Boolean, forward: Boolean ->
viewModel.find(query, matchCase, forward)
@@ -18,8 +18,7 @@ import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.listener.ConnectionListener
import haus.nightmare.lib3270j.listener.ScreenUpdateListener
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.a3270.ft.FileTransfer
import haus.nightmare.a3270.service.TerminalService
@@ -44,6 +43,16 @@ sealed interface TerminalInputAction {
data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction
data object WordLeft : TerminalInputAction
data object WordRight : TerminalInputAction
data object FieldEnd : TerminalInputAction
data object DeleteWord : TerminalInputAction
data object CursorSelect : TerminalInputAction
data object Attn : TerminalInputAction
data object SysReq : TerminalInputAction
data object Dup : TerminalInputAction
data object FieldMark : TerminalInputAction
data object ToggleInsert : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
data class LightPenSelect(val baddr: Int) : TerminalInputAction
}
@@ -205,12 +214,22 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
val isNvt = c.connectionState.isNvt || ip.isNvtMode
try {
when (action) {
is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt")
ip.isKeyboardLocked = false
if (isNvt) {
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
c.sendNVTString("\r\n")
} else {
c.sendNVTChar(ch)
}
}
} else {
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
@@ -221,53 +240,175 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
}
}
}
is TerminalInputAction.SendAid -> {
if (isNvt) {
if (action.aidCode == AID_ENTER) {
c.sendNVTString("\r\n")
} else if (action.aidCode == AID_CLEAR) {
c.sendNVTChar('\u000C')
} else {
val pfNum = aidToPfNumber(action.aidCode)
if (pfNum > 0) {
val seq = c.nvtProcessor?.getFunctionKeySequence(pfNum) ?: ""
if (seq.isNotEmpty()) {
c.sendNVTString(seq)
}
}
}
} else {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
}
is TerminalInputAction.Backspace -> {
if (isNvt) {
c.sendNVTChar('\b')
} else {
ip.isKeyboardLocked = false
ip.backspace()
}
}
is TerminalInputAction.DeleteChar -> {
if (isNvt) {
c.sendNVTString("\u001B[3~")
} else {
ip.isKeyboardLocked = false
ip.deleteChar()
}
}
is TerminalInputAction.Tab -> {
if (isNvt) {
c.sendNVTChar('\t')
} else {
ip.tab()
}
}
is TerminalInputAction.BackTab -> {
if (isNvt) {
c.sendNVTString("\u001B[Z")
} else {
ip.backTab()
}
}
is TerminalInputAction.CursorLeft -> {
if (isNvt) {
c.sendNVTString("\u001B[D")
} else {
ip.cursorLeft()
}
}
is TerminalInputAction.CursorRight -> {
if (isNvt) {
c.sendNVTString("\u001B[C")
} else {
ip.cursorRight()
}
}
is TerminalInputAction.CursorUp -> {
if (isNvt) {
c.sendNVTString("\u001B[A")
} else {
ip.cursorUp()
}
}
is TerminalInputAction.CursorDown -> {
if (isNvt) {
c.sendNVTString("\u001B[B")
} else {
ip.cursorDown()
}
}
is TerminalInputAction.CursorHome -> {
if (isNvt) {
c.sendNVTString("\u001B[H")
} else {
ip.cursorHome()
}
}
is TerminalInputAction.EraseEof -> {
if (isNvt) {
c.sendNVTString("\u001B[F")
} else {
ip.isKeyboardLocked = false
ip.eraseEof()
}
}
is TerminalInputAction.EraseInput -> {
if (!isNvt) {
ip.isKeyboardLocked = false
ip.eraseInput()
}
}
is TerminalInputAction.Newline -> {
if (isNvt) {
c.sendNVTString("\r\n")
} else {
ip.newline()
}
}
is TerminalInputAction.Reset -> {
if (isNvt) {
c.sendNVTChar('\u001B')
} else {
ip.reset()
}
}
is TerminalInputAction.WordLeft -> {
if (isNvt) {
c.sendNVTString("\u001Bb")
} else {
ip.processWordLeft()
}
}
is TerminalInputAction.WordRight -> {
if (isNvt) {
c.sendNVTString("\u001Bf")
} else {
ip.processWordRight()
}
}
is TerminalInputAction.FieldEnd -> {
if (!isNvt) {
ip.processFieldEnd()
}
}
is TerminalInputAction.DeleteWord -> {
if (!isNvt) {
ip.isKeyboardLocked = false
ip.processDeleteWord()
}
}
is TerminalInputAction.CursorSelect -> {
if (!isNvt) {
ip.cursorSelect()
}
}
is TerminalInputAction.Attn -> {
if (!isNvt) {
ip.attn()
}
}
is TerminalInputAction.SysReq -> {
if (!isNvt) {
ip.sysReq()
}
}
is TerminalInputAction.Dup -> {
if (!isNvt) {
ip.dup()
}
}
is TerminalInputAction.FieldMark -> {
if (!isNvt) {
ip.fieldMark()
}
}
is TerminalInputAction.ToggleInsert -> {
if (!isNvt) {
ip.processToggleInsert()
}
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
@@ -286,6 +427,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
}
private fun aidToPfNumber(aid: Int): Int {
return when (aid) {
AID_PF1 -> 1; AID_PF2 -> 2; AID_PF3 -> 3; AID_PF4 -> 4
AID_PF5 -> 5; AID_PF6 -> 6; AID_PF7 -> 7; AID_PF8 -> 8
AID_PF9 -> 9; AID_PF10 -> 10; AID_PF11 -> 11; AID_PF12 -> 12
AID_PF13 -> 13; AID_PF14 -> 14; AID_PF15 -> 15; AID_PF16 -> 16
AID_PF17 -> 17; AID_PF18 -> 18; AID_PF19 -> 19; AID_PF20 -> 20
AID_PF21 -> 21; AID_PF22 -> 22; AID_PF23 -> 23; AID_PF24 -> 24
else -> 0
}
}
fun resolveUntrustedCert(accept: Boolean) {
try {
val prompt = _untrustedCertPrompt.value
@@ -396,7 +549,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
tlsVerifyCert: Boolean = true,
tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH",
codePageStr: String = "037"
codePageStr: String = "037",
proxyTypeStr: String = "NONE",
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
proxyPass: String = ""
) {
currentHost = host
activeHostType = hostType
@@ -447,6 +605,16 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
codePage = codePageStr
val pType = try {
ConnectionConfig.ProxyType.valueOf(proxyTypeStr.uppercase())
} catch (_: Exception) {
ConnectionConfig.ProxyType.NONE
}
if (pType != ConnectionConfig.ProxyType.NONE && proxyHost.isNotBlank()) {
val defaultPort = if (pType == ConnectionConfig.ProxyType.HTTP) 8080 else 1080
setProxy(pType, proxyHost.trim(), if (proxyPort > 0) proxyPort else defaultPort, proxyUser.ifBlank { null }, proxyPass.ifBlank { null })
}
}
config.certificateVerifier = haus.nightmare.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
@@ -477,6 +645,32 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient
newClient.setNvtClipboardHandler(object : haus.nightmare.lib3270j.nvt.NvtProcessor.ClipboardHandler {
override fun getClipboardText(): String {
return try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
} catch (e: Exception) {
""
}
}
override fun setClipboardText(text: String?) {
if (!text.isNullOrEmpty()) {
try {
val cm = getApplication<Application>().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
cm?.setPrimaryClip(android.content.ClipData.newPlainText("3270 NVT", text))
} catch (ignored: Exception) {}
}
}
})
newClient.addNvtTitleListener { title ->
if (!title.isNullOrBlank()) {
_oiaText.value = title
}
}
newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState
@@ -706,6 +900,46 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
inputChannel.trySend(TerminalInputAction.Reset)
}
fun wordLeft() {
inputChannel.trySend(TerminalInputAction.WordLeft)
}
fun wordRight() {
inputChannel.trySend(TerminalInputAction.WordRight)
}
fun fieldEnd() {
inputChannel.trySend(TerminalInputAction.FieldEnd)
}
fun deleteWord() {
inputChannel.trySend(TerminalInputAction.DeleteWord)
}
fun cursorSelect() {
inputChannel.trySend(TerminalInputAction.CursorSelect)
}
fun attn() {
inputChannel.trySend(TerminalInputAction.Attn)
}
fun sysReq() {
inputChannel.trySend(TerminalInputAction.SysReq)
}
fun dup() {
inputChannel.trySend(TerminalInputAction.Dup)
}
fun fieldMark() {
inputChannel.trySend(TerminalInputAction.FieldMark)
}
fun toggleInsert() {
inputChannel.trySend(TerminalInputAction.ToggleInsert)
}
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
}
@@ -19,7 +19,12 @@ data class SavedHost(
val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH",
val codePage: String = "037"
val codePage: String = "037",
val proxyType: String = "NONE",
val proxyHost: String = "",
val proxyPort: Int = 0,
val proxyUsername: String = "",
val proxyPassword: String = ""
) {
fun toJson(): JSONObject {
return JSONObject().apply {
@@ -36,6 +41,11 @@ data class SavedHost(
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
put("codePage", codePage)
put("proxyType", proxyType)
put("proxyHost", proxyHost)
put("proxyPort", proxyPort)
put("proxyUsername", proxyUsername)
put("proxyPassword", proxyPassword)
}
}
@@ -54,7 +64,12 @@ data class SavedHost(
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH"),
codePage = json.optString("codePage", "037")
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", "")
)
}
}
@@ -117,6 +132,51 @@ object HostStorage {
saveAll(context, currentHosts)
}
val AVAILABLE_CODE_PAGES = listOf(
"037" to "037 - US / Canada / Brazil",
"1047" to "1047 - IBM Open Systems / z/OS Unix",
"500" to "500 - International Latin-1",
"273" to "273 - Germany / Austria",
"277" to "277 - Denmark / Norway",
"278" to "278 - Sweden / Finland",
"280" to "280 - Italy",
"284" to "284 - Spain / Latin America",
"285" to "285 - United Kingdom",
"297" to "297 - France",
"420" to "420 - Arabic Bilingual",
"424" to "424 - Hebrew (with Latin)",
"803" to "803 - Hebrew Character Set",
"838" to "838 - Thai Extended",
"1160" to "1160 - Thai Euro",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"880" to "880 - Cyrillic (Russian)",
"1025" to "1025 - Cyrillic Multilingual",
"1123" to "1123 - Cyrillic Ukraine",
"1154" to "1154 - Cyrillic Euro",
"905" to "905 - Turkey (Latin-5)",
"1026" to "1026 - Turkey (Turkish)",
"1155" to "1155 - Turkey Euro",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1142" to "1142 - Denmark / Norway (Euro €)",
"1143" to "1143 - Sweden / Finland (Euro €)",
"1144" to "1144 - Italy (Euro €)",
"1145" to "1145 - Spain / Latin America (Euro €)",
"1146" to "1146 - United Kingdom (Euro €)",
"1147" to "1147 - France (Euro €)",
"1148" to "1148 - International (Euro €)",
"1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS",
"935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS",
"1371" to "1371 - Traditional Chinese Extended DBCS",
"933" to "933 - Korean Mixed DBCS"
)
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
@@ -48,7 +48,12 @@ fun ConnectDialog(
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String,
codePage: String
codePage: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: String
) -> Unit
) {
val context = LocalContext.current
@@ -67,6 +72,12 @@ fun ConnectDialog(
var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
var codePage by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)) }
var proxyType by remember { mutableStateOf("NONE") }
var proxyHost by remember { mutableStateOf("") }
var proxyPortStr by remember { mutableStateOf("") }
var proxyUsername by remember { mutableStateOf("") }
var proxyPassword by remember { mutableStateOf("") }
var showProxySettings by remember { mutableStateOf(false) }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
@@ -82,6 +93,12 @@ fun ConnectDialog(
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
codePage = saved.codePage
proxyType = saved.proxyType
proxyHost = saved.proxyHost
proxyPortStr = if (saved.proxyPort > 0) saved.proxyPort.toString() else ""
proxyUsername = saved.proxyUsername
proxyPassword = saved.proxyPassword
showProxySettings = saved.proxyType != "NONE"
}
fun clearFields() {
@@ -98,6 +115,12 @@ fun ConnectDialog(
tn3270e = true
graphicsMode = "BOTH"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
proxyType = "NONE"
proxyHost = ""
proxyPortStr = ""
proxyUsername = ""
proxyPassword = ""
showProxySettings = false
}
fun saveCurrentProfile(): SavedHost? {
@@ -118,7 +141,12 @@ fun ConnectDialog(
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode,
codePage = codePage
codePage = codePage,
proxyType = proxyType,
proxyHost = proxyHost.trim(),
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
proxyUsername = proxyUsername.trim(),
proxyPassword = proxyPassword.trim()
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
@@ -251,7 +279,12 @@ fun ConnectDialog(
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode,
hostToConn.codePage
hostToConn.codePage,
hostToConn.proxyType,
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword
)
},
modifier = Modifier.size(32.dp)
@@ -434,30 +467,7 @@ fun ConnectDialog(
// Code Page Selector
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = 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",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"1026" to "1026 - Turkey (Turkish)",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1148" to "1148 - International (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",
"933" to "933 - Korean Mixed DBCS"
)
val codePagesList = HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage"
Column(modifier = Modifier.fillMaxWidth()) {
@@ -611,6 +621,102 @@ fun ConnectDialog(
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
// Proxy Settings Expandable Section
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showProxySettings = !showProxySettings }
) {
Checkbox(
checked = showProxySettings || proxyType != "NONE",
onCheckedChange = { checked ->
showProxySettings = checked
if (!checked) proxyType = "NONE"
}
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (proxyType != "NONE") "Proxy Active ($proxyType)" else "Configure Network Proxy",
fontSize = 12.sp,
color = if (proxyType != "NONE") Color(0xFF339AF0) else Color.White
)
}
if (showProxySettings || proxyType != "NONE") {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 4.dp, top = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("Proxy Protocol:", fontSize = 11.sp, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf("NONE", "HTTP", "SOCKS4", "SOCKS5").forEach { pType ->
val isSel = proxyType == pType
FilterChip(
selected = isSel,
onClick = { proxyType = pType },
label = { Text(pType, fontSize = 10.sp) }
)
}
}
if (proxyType != "NONE") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyHost,
onValueChange = { proxyHost = it },
label = { Text("Proxy Host") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.7f)
)
OutlinedTextField(
value = proxyPortStr,
onValueChange = { proxyPortStr = it },
label = { Text("Port") },
placeholder = { Text(if (proxyType == "HTTP") "8080" else "1080") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.3f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyUsername,
onValueChange = { proxyUsername = it },
label = { Text("User (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
OutlinedTextField(
value = proxyPassword,
onValueChange = { proxyPassword = it },
label = { Text("Pass (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
}
}
}
}
}
}
}
}
@@ -645,7 +751,12 @@ fun ConnectDialog(
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode,
saved.codePage
saved.codePage,
saved.proxyType,
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword
)
}
},
@@ -134,6 +134,7 @@ fun FieldInspectorDialog(
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)
}
@@ -176,6 +177,7 @@ fun FieldInspectorDialog(
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))
}
}
@@ -1,5 +1,13 @@
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
@@ -9,6 +17,7 @@ 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
@@ -17,6 +26,10 @@ 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,
@@ -33,6 +46,8 @@ fun FileTransferDialog(
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
@@ -41,6 +56,7 @@ fun FileTransferDialog(
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) }
@@ -50,6 +66,74 @@ fun FileTransferDialog(
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,
@@ -58,11 +142,61 @@ fun FileTransferDialog(
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 = {
@@ -113,13 +247,70 @@ fun FileTransferDialog(
}
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,
@@ -145,39 +336,52 @@ fun FileTransferDialog(
}
}
// 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 = 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) },
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.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) }
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
@@ -273,10 +477,18 @@ fun FileTransferDialog(
confirmButton = {
Button(
onClick = {
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
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(localFile.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)
@@ -290,7 +502,7 @@ fun FileTransferDialog(
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
enabled = hostFile.isNotBlank() && (localFile.isNotBlank() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
@@ -302,3 +514,22 @@ fun FileTransferDialog(
}
)
}
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
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.window.DialogProperties
@Composable
fun FindDialog(
cols: Int = 80,
onDismiss: () -> Unit,
onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int,
onClearHighlight: () -> Unit
@@ -49,8 +50,9 @@ fun FindDialog(
}
val pos = onFind(trimmed, matchCase, searchForward)
if (pos >= 0) {
val row = (pos / 80) + 1
val col = (pos % 80) + 1
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 {
@@ -29,6 +29,9 @@ fun OiaStatusBar(
graphicsMode: String = "NONE",
codePage: String = "037",
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
inhibitReason: Int = 0,
luName: String = "",
modifier: Modifier = Modifier
) {
val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1
@@ -37,20 +40,36 @@ fun OiaStatusBar(
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) {
"OFFLINE"
} else if (isKeyboardLocked) {
"X SYSTEM"
} else {
"READY"
val connTypeBadge = when (connectionState) {
ConnectionState.CONNECTED_3270 -> "TN3270"
ConnectionState.CONNECTED_TN3270E -> "TN3270E"
ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
ConnectionState.NOT_CONNECTED -> "OFFLINE"
else -> connectionState.name
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
"OFFLINE" to Color(0xFF868E96)
} else if (inhibitReason != 0) {
val txt = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
else -> "X LOCKED"
}
txt to Color(0xFFFF6B6B)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
"X SYSTEM" to Color(0xFFFF6B6B)
} else if (isInsertMode) {
"INSERT" to Color(0xFF339AF0)
} else {
Color(0xFF51CF66) // Green for ready
"READY" to Color(0xFF51CF66)
}
Row(
@@ -157,6 +176,42 @@ fun OiaStatusBar(
)
}
}
// 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)
@@ -56,6 +56,7 @@ fun PrinterSessionDialog(
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") }
@@ -65,24 +66,7 @@ fun PrinterSessionDialog(
var printerClient by remember { mutableStateOf<Telnet3270EPClient?>(null) }
val codePagesList = listOf(
"037" to "037 - US / Canada",
"1047" to "1047 - Open Systems / Unix",
"500" to "500 - International",
"273" to "273 - Germany",
"277" to "277 - Denmark / Norway",
"278" to "278 - Sweden / Finland",
"280" to "280 - Italy",
"284" to "284 - Spain",
"285" to "285 - United Kingdom",
"297" to "297 - France",
"870" to "870 - Eastern Europe",
"1140" to "1140 - US (Euro €)",
"1141" to "1141 - Germany (Euro €)",
"1148" to "1148 - Intl (Euro €)",
"930" to "930 - Japanese Katakana DBCS",
"939" to "939 - Japanese Latin DBCS"
)
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
fun startSession() {
val port = portStr.trim().toIntOrNull() ?: 23
@@ -92,6 +76,16 @@ fun PrinterSessionDialog(
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)
@@ -313,16 +307,51 @@ fun PrinterSessionDialog(
)
}
// Code Page & TLS Row
// PDT, Code Page & TLS Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
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 {
OutlinedButton(onClick = { cpExpanded = true }) {
Text("Code Page: CP$codePage", fontSize = 11.sp)
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { cpExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("CP$codePage", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = cpExpanded,
@@ -346,15 +375,14 @@ fun PrinterSessionDialog(
modifier = Modifier.clickable { useTls = !useTls }
) {
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
Spacer(modifier = Modifier.width(4.dp))
Text("TLS", fontSize = 12.sp, color = Color.White)
Text("TLS", fontSize = 11.sp, color = Color.White)
}
Button(
onClick = { startSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect Printer")
Text("Connect", fontSize = 11.sp)
}
}
} else {
@@ -85,7 +85,8 @@ fun ScriptDialog(
) {
listOf(
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]"
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]",
"[wordleft]", "[wordright]", "[fieldend]", "[deleteword]", "[cursel]", "[lightpen]"
).forEach { tok ->
SuggestionChip(
onClick = { insertToken(tok) },
@@ -251,30 +251,7 @@ fun SettingsDialog(
// Setting 6: Default EBCDIC Code Page
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = 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",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"1026" to "1026 - Turkey (Turkish)",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1148" to "1148 - International (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",
"933" to "933 - Korean Mixed DBCS"
)
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()) {
@@ -26,13 +26,23 @@ class CodePageAndStorageTest {
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "1047"
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)
@@ -42,17 +52,25 @@ class CodePageAndStorageTest {
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 field
// 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