3 Commits

Author SHA1 Message Date
rudi 79ce0a7dc6 Bring up to date with Connection exporting and file transfer updates
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
Release a3270 / Build & Publish Release (push) Successful in 1m46s
2026-09-24 12:47:50 +00:00
rudi fe185bd1f0 Bring up to date with Connection exporting and file transfer updates 2026-09-24 12:46:55 +00:00
rudi ee44759668 Update Status Bar, Settings, Terminal alignment and codepage testing
Build and Test a3270 / Build Android APK (push) Successful in 1m58s
2026-09-22 13:41:10 +00:00
18 changed files with 2042 additions and 173 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 7
versionName "1.2.2"
versionCode 8
versionName "1.2.3"
}
buildTypes {
+12 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="7"
android:versionName="1.2.2">
android:versionCode="8"
android:versionName="1.2.3">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -37,6 +37,16 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="3270 Terminal TCP socket connection persistence" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -29,6 +29,8 @@ import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.ui.ConnectChoice
import haus.nightmare.a3270.ui.ConnectChoiceDialog
import haus.nightmare.a3270.ui.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar
@@ -546,14 +548,43 @@ fun MainScreen(
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
if (screenBuffer != null && screenBuffer?.isFormatted == true) {
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte()
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
} else false
val isDbcsSession = remember(screenBuffer, screenVersion) {
screenBuffer?.translator?.isDBCS == true
}
val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val cell = buf.getCell(cursorAddr)
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
if (isDbcs) {
"DBCS"
} else {
val fa = buf.getFieldAttributeAt(cursorAddr)
if ((fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0) {
"NUM"
} else {
"ALPHA"
}
}
} else {
"ALPHA"
}
}
val isNumericField = remember(fieldStatus) {
fieldStatus == "NUM"
}
val activity = context as? ComponentActivity
val openConnectFromIntent = remember {
activity?.intent?.getBooleanExtra("OPEN_CONNECT_DIALOG", false) == true
}
var showConnectDialog by remember { mutableStateOf(false) }
var showConnectChoiceDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var showFieldInspectorDialog by remember { mutableStateOf(false) }
@@ -598,30 +629,17 @@ fun MainScreen(
}
LaunchedEffect(Unit) {
if (openConnectFromIntent) {
activity?.intent?.removeExtra("OPEN_CONNECT_DIALOG")
showConnectDialog = true
return@LaunchedEffect
}
val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.dynamicRows,
autoHost.dynamicCols,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode,
autoHost.codePage,
autoHost.proxyType,
autoHost.proxyHost,
autoHost.proxyPort,
autoHost.proxyUsername,
autoHost.proxyPassword
)
viewModel.connect(autoHost.toConnectionConfig())
}
}
AppSettings.StartupBehavior.SHOW_CONNECT -> {
@@ -717,6 +735,7 @@ fun MainScreen(
AndroidView(
factory = { ctx ->
TerminalInputView(ctx).apply {
this.isDbcsSession = isDbcsSession
onInputText = { text ->
viewModel.typeString(text)
onClearShift()
@@ -738,6 +757,7 @@ fun MainScreen(
}
},
update = { inputView ->
inputView.isDbcsSession = isDbcsSession
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
@@ -757,7 +777,11 @@ fun MainScreen(
onClearShift = onClearShift,
onConnectClick = {
terminalInputViewRef?.hideSoftKeyboard()
if (connectionState.isConnected()) {
showConnectChoiceDialog = true
} else {
showConnectDialog = true
}
},
onDisconnectClick = { viewModel.disconnect() },
onFtClick = {
@@ -880,6 +904,7 @@ fun MainScreen(
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
isNumericField = isNumericField,
fieldStatus = fieldStatus,
uiTheme = uiTheme,
onToggleDocMode = { viewModel.toggleDocMode() },
onToggleWordWrap = { viewModel.toggleWordWrap() },
@@ -889,15 +914,65 @@ fun MainScreen(
}
}
if (showConnectChoiceDialog) {
ConnectChoiceDialog(
currentTarget = viewModel.getConnectedTarget(),
onChoice = { choice ->
showConnectChoiceDialog = false
when (choice) {
ConnectChoice.NEW_WINDOW -> {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
putExtra("OPEN_CONNECT_DIALOG", true)
}
context.startActivity(intent)
}
ConnectChoice.ABORT_CURRENT -> {
viewModel.disconnect()
showConnectDialog = true
}
ConnectChoice.CANCEL -> {
terminalInputViewRef?.showSoftKeyboard()
}
}
},
onDismiss = {
showConnectChoiceDialog = false
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = {
showConnectDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass ->
onConnect = { host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass, savedConn, savedProfId, savedProfName ->
showConnectDialog = false
viewModel.connect(host, port, model, dynamicRows, dynamicCols, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode, cp, pType, pHost, pPort, pUser, pPass)
viewModel.connect(
host = host,
port = port,
modelNum = model,
dynamicRows = dynamicRows,
dynamicCols = dynamicCols,
luName = luName,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsModeStr = graphicsMode,
codePageStr = cp,
proxyTypeStr = pType,
proxyHost = pHost,
proxyPort = pPort,
proxyUser = pUser,
proxyPass = pPass,
savedConnection = savedConn,
savedProfileId = savedProfId,
savedProfileName = savedProfName
)
terminalInputViewRef?.showSoftKeyboard()
}
)
@@ -959,13 +1034,14 @@ fun MainScreen(
initialFourColorOverride = fourColorOverride,
initialNumericFieldLock = AppSettings.getNumericFieldLock(context),
initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context),
initialDefaultHostType = AppSettings.getDefaultHostType(context),
onDismiss = {
showSettingsDialog = false
terminalInputViewRef?.showSoftKeyboard()
},
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst,
sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices,
startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk ->
startup, dynRows, dynCols, doc, wrap, startCol, endCol, bell, bellCol, tabs, resetIns, fourCol, numLk, autoSk, defHostType ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme)
@@ -994,6 +1070,7 @@ fun MainScreen(
AppSettings.setFourColorOverride(context, fourCol)
AppSettings.setNumericFieldLock(context, numLk)
AppSettings.setAutoSkipEnabled(context, autoSk)
AppSettings.setDefaultHostType(context, defHostType)
viewModel.reloadSettings()
terminalInputViewRef?.showSoftKeyboard()
}
@@ -26,6 +26,7 @@ import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import java.io.File
import java.util.Locale
import java.util.concurrent.Executors
import java.util.logging.Logger
@@ -442,9 +443,21 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
var currentPort: Int = 23
private set
var currentProfileName: String? = null
private set
var activeHostType: String = "TSO"
private set
fun getConnectedTarget(): String {
val pName = currentProfileName
if (!pName.isNullOrBlank()) return pName
val h = currentHost
val p = currentPort
return if (h.isNotBlank()) "$h:$p" else "the host"
}
fun getClient(): Telnet3270Client? = client
fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer
@@ -891,9 +904,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
proxyHost: String = "",
proxyPort: Int = 0,
proxyUser: String = "",
proxyPass: String = ""
proxyPass: String = "",
savedConnection: Boolean = false,
savedProfileId: String? = null,
savedProfileName: String? = null
) {
currentHost = host
currentPort = port
currentProfileName = savedProfileName
activeHostType = hostType
lastScreenContentHash = 0
hasInitialScreenLoaded = false
@@ -902,6 +920,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_codePage.value = codePageStr
clearSearchHighlight()
if (savedConnection) {
val lastState = haus.nightmare.a3270.ui.getLastTransferState()
if (lastState != null) {
lastState.hostType = when (hostType.uppercase(Locale.US)) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
}
}
AppSettings.setDefaultHostType(getApplication(), hostType)
viewModelScope.launch(Dispatchers.IO) {
try {
// Always disconnect any existing client before establishing a new connection
@@ -939,6 +969,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
(if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
this.hostType = hostType
this.isSavedConnection = savedConnection
if (savedProfileId != null) this.savedProfileId = savedProfileId
if (savedProfileName != null) this.savedProfileName = savedProfileName
if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols)
}
@@ -1114,6 +1149,38 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
}
fun connect(config: ConnectionConfig) {
val modelNum = when (config.model) {
TerminalModel.IBM_DYNAMIC -> 0
TerminalModel.IBM_3279_3, TerminalModel.IBM_3278_3 -> 3
TerminalModel.IBM_3279_4, TerminalModel.IBM_3278_4 -> 4
TerminalModel.IBM_3279_5, TerminalModel.IBM_3278_5 -> 5
else -> 2
}
connect(
host = config.host,
port = config.port,
modelNum = modelNum,
dynamicRows = config.dynamicRows,
dynamicCols = config.dynamicCols,
luName = config.luName ?: "",
hostType = config.hostType ?: "TSO",
useTls = config.isUseTls,
tlsVerifyCert = config.isTlsVerifyCert,
tn3270e = config.isTn3270eEnabled,
graphicsModeStr = config.graphicsMode?.name ?: "BOTH",
codePageStr = config.codePage ?: "037",
proxyTypeStr = config.proxyType?.name ?: "NONE",
proxyHost = config.proxyHost ?: "",
proxyPort = config.proxyPort,
proxyUser = config.proxyUsername ?: "",
proxyPass = config.proxyPassword ?: "",
savedConnection = config.isSavedConnection,
savedProfileId = config.savedProfileId,
savedProfileName = config.savedProfileName
)
}
fun disconnect() {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -1,8 +1,10 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
@@ -19,6 +21,7 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
// New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme"
@@ -112,6 +115,15 @@ object AppSettings {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
}
fun getDefaultHostType(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_HOST_TYPE, "TSO") ?: "TSO"
}
fun setDefaultHostType(context: Context, hostType: String) {
val normalized = if (hostType.isNotBlank()) hostType.trim().uppercase(Locale.US) else "TSO"
getPrefs(context).edit().putString(KEY_DEFAULT_HOST_TYPE, normalized).apply()
}
// ========== UI Theme ==========
fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
@@ -378,6 +390,7 @@ object AppSettings {
p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
}
p.println("codePage = ${getDefaultCodePage(context)}")
p.println("hostType = ${getDefaultHostType(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
@@ -418,6 +431,30 @@ object AppSettings {
p.flush()
}
fun shareSettingsIni(context: Context) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_settings.ini")
file.bufferedWriter().use { writer ->
exportToIni(context, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Terminal Settings")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Terminal Settings"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader)
var currentSection = ""
@@ -462,6 +499,7 @@ object AppSettings {
} catch (_: Exception) {}
}
"codepage", "charset" -> setDefaultCodePage(context, value)
"hosttype", "host_type" -> setDefaultHostType(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
@@ -1,9 +1,21 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
data class SavedHost(
@@ -28,6 +40,47 @@ data class SavedHost(
val proxyUsername: String = "",
val proxyPassword: String = ""
) {
fun toConnectionConfig(): ConnectionConfig {
val tm = when (model) {
3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
0 -> TerminalModel.IBM_DYNAMIC
else -> TerminalModel.IBM_3279_2
}
val config = ConnectionConfig(host, port, tm, useTls)
config.isSavedConnection = true
config.savedProfileId = this.id
config.savedProfileName = this.name
config.hostType = if (this.hostType.isNotBlank()) this.hostType else "TSO"
if (model == 0 || tm.isDynamic) {
config.setDynamicDimensions(dynamicRows, dynamicCols)
}
if (luName.isNotBlank()) {
config.luName = luName.trim()
}
config.isTlsVerifyCert = tlsVerifyCert
config.isTn3270eEnabled = tn3270e
config.graphicsMode = GraphicsMode.fromString(graphicsMode)
if (codePage.isNotBlank()) {
config.codePage = codePage
}
if (!proxyType.equals("NONE", ignoreCase = true) && proxyHost.isNotBlank()) {
try {
val pt = ConnectionConfig.ProxyType.valueOf(proxyType.uppercase())
config.setProxy(
pt,
proxyHost,
if (proxyPort > 0) proxyPort else (if (pt == ConnectionConfig.ProxyType.HTTP) 8080 else 1080),
proxyUsername.ifBlank { null },
proxyPassword.ifBlank { null }
)
} catch (_: Exception) {}
}
return config
}
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
@@ -176,6 +229,10 @@ object HostStorage {
"1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS",
"290" to "290 - Japanese Katakana SBCS",
"1027" to "1027 - Japanese Latin Extended SBCS",
"1390" to "1390 - Japanese Katakana DBCS Euro",
"1399" to "1399 - Japanese Latin DBCS Euro",
"935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS",
@@ -192,4 +249,156 @@ object HostStorage {
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
// ================= INI Export / Import =================
fun exportToIni(hosts: List<SavedHost>, writer: Writer) {
val p = PrintWriter(writer)
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
p.println("; a3270 Saved Connections Configuration File")
p.println("; Exported on $timeStamp")
p.println("; Connections count: ${hosts.size}")
p.println()
for (h in hosts) {
val section = h.name.trim().ifBlank { h.host.trim().ifBlank { "Connection" } }
p.println("[$section]")
if (h.id.isNotBlank()) {
p.println("id = ${h.id}")
}
p.println("name = ${h.name}")
p.println("host = ${h.host}")
p.println("port = ${h.port}")
p.println("model = ${h.model}")
p.println("dynamicRows = ${h.dynamicRows}")
p.println("dynamicCols = ${h.dynamicCols}")
p.println("luName = ${h.luName}")
p.println("autoConnect = ${h.autoConnect}")
p.println("hostType = ${h.hostType.ifBlank { "TSO" }}")
p.println("useTls = ${h.useTls}")
p.println("tlsVerifyCert = ${h.tlsVerifyCert}")
p.println("tn3270e = ${h.tn3270e}")
p.println("graphicsMode = ${h.graphicsMode.ifBlank { "BOTH" }}")
p.println("codePage = ${h.codePage.ifBlank { "037" }}")
p.println("proxyType = ${h.proxyType.ifBlank { "NONE" }}")
p.println("proxyHost = ${h.proxyHost}")
p.println("proxyPort = ${h.proxyPort}")
p.println("proxyUsername = ${h.proxyUsername}")
p.println("proxyPassword = ${h.proxyPassword}")
p.println()
}
p.flush()
}
fun loadFromIni(reader: Reader): List<SavedHost> {
val bufferedReader = BufferedReader(reader)
val hosts = mutableListOf<SavedHost>()
var current: SavedHost? = null
bufferedReader.forEachLine { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
if (line.startsWith("[") && line.endsWith("]")) {
current?.let { hosts.add(it) }
var section = line.substring(1, line.length - 1).trim()
if (section.startsWith("connection:", ignoreCase = true)) {
section = section.substring("connection:".length).trim()
} else if (section.startsWith("host:", ignoreCase = true)) {
section = section.substring("host:".length).trim()
}
current = SavedHost(id = UUID.randomUUID().toString(), name = section, host = "")
} else {
val eq = line.indexOf('=')
if (eq >= 0) {
val key = line.substring(0, eq).trim().lowercase(Locale.US)
val value = line.substring(eq + 1).trim()
if (current == null) {
current = SavedHost(id = UUID.randomUUID().toString(), name = "Default", host = "")
}
current = applyIniProperty(current!!, key, value)
}
}
}
}
current?.let { hosts.add(it) }
return hosts
}
private fun applyIniProperty(h: SavedHost, key: String, value: String): SavedHost {
return when (key) {
"id" -> if (value.isNotBlank()) h.copy(id = value) else h
"name", "profilename", "profile" -> if (value.isNotBlank()) h.copy(name = value) else h
"host", "hostname", "address" -> h.copy(host = value)
"port" -> h.copy(port = value.toIntOrNull() ?: 23)
"model", "terminalmodel" -> h.copy(model = value.toIntOrNull() ?: 2)
"dynamicrows", "dynamic_rows", "rows" -> h.copy(dynamicRows = value.toIntOrNull() ?: 62)
"dynamiccols", "dynamic_cols", "cols" -> h.copy(dynamicCols = value.toIntOrNull() ?: 160)
"luname", "lu_name", "lu" -> h.copy(luName = value)
"autoconnect", "auto_connect" -> h.copy(autoConnect = value.toBoolean())
"hosttype", "host_type", "type" -> h.copy(hostType = value.uppercase(Locale.US))
"usetls", "use_tls", "tls", "ssl" -> h.copy(useTls = value.toBoolean())
"tlsverifycert", "verifycert", "tls_verify_cert" -> h.copy(tlsVerifyCert = value.toBoolean())
"tn3270e", "enabletn3270e" -> h.copy(tn3270e = value.toBoolean())
"graphicsmode", "graphics_mode", "graphics" -> h.copy(graphicsMode = value.uppercase(Locale.US))
"codepage", "code_page", "charset" -> h.copy(codePage = value)
"proxytype", "proxy_type" -> h.copy(proxyType = value.uppercase(Locale.US))
"proxyhost", "proxy_host" -> h.copy(proxyHost = value)
"proxyport", "proxy_port" -> h.copy(proxyPort = value.toIntOrNull() ?: 0)
"proxyusername", "proxy_username", "proxyuser" -> h.copy(proxyUsername = value)
"proxypassword", "proxy_password", "proxypass" -> h.copy(proxyPassword = value)
else -> h
}
}
fun importFromIni(context: Context, reader: Reader): List<SavedHost> {
val imported = loadFromIni(reader)
if (imported.isEmpty()) return getSavedHosts(context)
val current = getSavedHosts(context).toMutableList()
for (host in imported) {
val index = current.indexOfFirst {
(it.id.isNotBlank() && it.id == host.id) ||
(it.name.isNotBlank() && it.name.equals(host.name, ignoreCase = true))
}
if (host.autoConnect) {
current.indices.forEach { i ->
current[i] = current[i].copy(autoConnect = false)
}
}
if (index >= 0) {
val existingId = current[index].id
val toSet = if (host.id.isBlank()) host.copy(id = existingId) else host
current[index] = toSet
} else {
current.add(host)
}
}
saveAll(context, current)
return current
}
fun shareConnectionsIni(context: Context) {
try {
val hosts = getSavedHosts(context)
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_connections.ini")
file.bufferedWriter().use { writer ->
exportToIni(hosts, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Saved Connections")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Saved Connections"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,109 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.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
enum class ConnectChoice {
NEW_WINDOW,
ABORT_CURRENT,
CANCEL
}
@Composable
fun ConnectChoiceDialog(
currentTarget: String,
onChoice: (ConnectChoice) -> Unit,
onDismiss: () -> Unit
) {
val targetText = currentTarget.ifBlank { "the host" }
Dialog(
onDismissRequest = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
},
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.95f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Connection Already Active",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = Color.White
)
Text(
text = "a3270 is currently connected to $targetText.",
fontSize = 13.sp,
color = Color.LightGray
)
Text(
text = "Would you like to open the connection in a new window or abort the current connection?",
fontSize = 13.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
}
) {
Text("Cancel", color = Color.Gray)
}
Button(
onClick = {
onChoice(ConnectChoice.ABORT_CURRENT)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Abort Current Connection", fontSize = 11.sp)
}
Button(
onClick = {
onChoice(ConnectChoice.NEW_WINDOW)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -55,12 +55,16 @@ fun ConnectDialog(
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: String
proxyPass: String,
savedConnection: Boolean,
savedProfileId: String?,
savedProfileName: String?
) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var showImportConnectionsDialog by remember { mutableStateOf(false) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("") }
@@ -70,7 +74,7 @@ fun ConnectDialog(
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") }
var hostType by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)) }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
@@ -117,7 +121,7 @@ fun ConnectDialog(
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = ""
autoConnect = false
hostType = "TSO"
hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
useTls = false
tlsVerifyCert = true
tn3270e = true
@@ -231,13 +235,36 @@ fun ConnectDialog(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Saved Host Profiles:",
if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (savedHosts.isNotEmpty()) {
TextButton(
onClick = { HostStorage.shareConnectionsIni(context) },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Export (Share)", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
TextButton(
onClick = { showImportConnectionsDialog = true },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Import", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
}
if (savedHosts.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -301,7 +328,10 @@ fun ConnectDialog(
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword
hostToConn.proxyPassword,
true,
hostToConn.id,
hostToConn.name
)
},
modifier = Modifier.size(32.dp)
@@ -804,7 +834,10 @@ fun ConnectDialog(
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword
saved.proxyPassword,
true,
saved.id,
saved.name
)
}
},
@@ -816,4 +849,64 @@ fun ConnectDialog(
}
}
}
if (showImportConnectionsDialog) {
var importText by remember { mutableStateOf("") }
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
AlertDialog(
onDismissRequest = { showImportConnectionsDialog = false },
title = { Text("Import Host Connections") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Paste j3270 / a3270 INI connections below:", fontSize = 12.sp)
OutlinedTextField(
value = importText,
onValueChange = { importText = it },
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = {
val clip = clipboardManager.getText()?.text ?: ""
if (clip.isNotBlank()) {
importText = clip
}
}) {
Text("Paste Clipboard", fontSize = 11.sp)
}
}
}
},
confirmButton = {
TextButton(onClick = {
showImportConnectionsDialog = false
if (importText.isNotBlank()) {
try {
val countBefore = savedHosts.size
val merged = HostStorage.importFromIni(context, java.io.StringReader(importText))
savedHosts = merged
val countAfter = merged.size
val diff = countAfter - countBefore
val msg = if (diff > 0) "Imported $diff new connection(s)" else "Updated ${merged.size} connection(s)"
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = { showImportConnectionsDialog = false }) {
Text("Cancel")
}
}
)
}
}
@@ -38,19 +38,24 @@ private val TerminalKeyboardOptions = KeyboardOptions(
)
data class LastTransferState(
val hostFile: String = "",
val localFile: String = "",
val isReceive: Boolean = true,
val isAscii: Boolean = true,
val hostType: FTConfig.HostType = FTConfig.HostType.TSO,
val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
val lreclStr: String = "",
val blksizeStr: String = "",
val spaceStr: String = "",
val cmsOptions: String = ""
var hostFile: String = "",
var localFile: String = "",
var isReceive: Boolean = true,
var isAscii: Boolean = true,
var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
var lreclStr: String = "",
var blksizeStr: String = "",
var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
var spaceStr: String = "",
var dirBlocks: String = "",
var cmsOptions: String = ""
)
private var lastTransferState: LastTransferState? = null
fun getLastTransferState(): LastTransferState? = lastTransferState
fun resetSessionState() { lastTransferState = null }
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
if (hostType == FTConfig.HostType.CMS) {
val lastDot = localName.lastIndexOf('.')
@@ -104,16 +109,26 @@ fun FileTransferDialog(
else -> FTConfig.HostType.TSO
}
val clientConfig = client?.config
val configuredFtType = clientConfig?.ftHostType
val effectiveHostType = if (configuredFtType != null && (clientConfig.isSavedConnection || lastTransferState == null)) {
configuredFtType
} else {
lastTransferState?.hostType ?: initialTypeEnum
}
var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
var hostType by remember { mutableStateOf(lastTransferState?.hostType ?: initialTypeEnum) }
var hostType by remember { mutableStateOf(effectiveHostType) }
var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") }
var units by remember { mutableStateOf(lastTransferState?.units ?: FTConfig.AllocationUnit.TRACKS) }
var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
var overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) }
@@ -186,15 +201,34 @@ fun FileTransferDialog(
}
if (showBrowseDialog) {
val initialQuery = resolveDefaultQuery(hostType, hostFile)
HostDirectoryDialog(
client = client,
initialHostType = hostType,
initialQuery = hostFile,
initialQuery = initialQuery,
onDismiss = { showBrowseDialog = false },
onSelectDataset = { selected ->
hostFile = selected
if (localFile.isBlank()) {
val safeName = formatLocalFilenameFromHost(selected, hostType)
onSelectEntry = { selectedName, selectedEntry ->
hostFile = selectedName
if (selectedEntry != null) {
val r = selectedEntry.recfm
if (!r.isNullOrBlank()) {
val mappedRecfm = when (r.trim().uppercase(Locale.US)) {
"F", "FB", "FA", "FBA", "FBM" -> FTConfig.RecordFormat.FIXED
"V", "VB", "VA", "VBA", "VBM" -> FTConfig.RecordFormat.VARIABLE
else -> FTConfig.RecordFormat.DEFAULT
}
recfm = mappedRecfm
}
if (selectedEntry.lrecl > 0) {
lreclStr = selectedEntry.lrecl.toString()
}
if (selectedEntry.blksize > 0) {
blksizeStr = selectedEntry.blksize.toString()
}
}
val shouldAutofill = localFile.isBlank() || (lastTransferState != null && localFile == lastTransferState?.localFile)
if (shouldAutofill) {
val safeName = formatLocalFilenameFromHost(selectedName, hostType)
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath
@@ -521,17 +555,53 @@ fun FileTransferDialog(
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Units:", fontSize = 11.sp)
Row {
listOf(
FTConfig.AllocationUnit.TRACKS to "Tracks",
FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
FTConfig.AllocationUnit.AVBLOCK to "Avblock",
FTConfig.AllocationUnit.DEFAULT to "Default"
).forEach { (u, label) ->
FilterChip(
selected = (units == u),
onClick = { units = u },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = spaceStr,
onValueChange = { spaceStr = it },
label = { Text("SPACE (Allocation)", fontSize = 11.sp) },
placeholder = { Text("e.g. (1,1) cyl or 5,2") },
label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
placeholder = { Text("e.g. 5,2 or 1,1") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.62f)
)
OutlinedTextField(
value = dirBlocks,
onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
label = { Text("Dir (PDS)", fontSize = 11.sp) },
placeholder = { Text("e.g. 10") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.weight(0.38f)
)
}
}
// CMS Options
if (hostType == FTConfig.HostType.CMS) {
@@ -571,7 +641,9 @@ fun FileTransferDialog(
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
}
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim())
@@ -586,7 +658,9 @@ fun FileTransferDialog(
recfm = recfm,
lreclStr = lreclStr,
blksizeStr = blksizeStr,
units = units,
spaceStr = spaceStr,
dirBlocks = dirBlocks,
cmsOptions = cmsOptions
)
onStartTransfer(config)
@@ -1,5 +1,8 @@
package haus.nightmare.a3270.ui
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
@@ -15,6 +18,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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -28,8 +32,139 @@ 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.CMSDirectoryParser
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
fun resolveDefaultQuery(hostType: FTConfig.HostType, initialQuery: String?): String {
val defaultQuery = initialQuery?.trim() ?: ""
if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery == "* * *" || defaultQuery == "*")) {
return "* * A"
}
return defaultQuery
}
fun formatCMSFilter(pattern: String?): String {
if (pattern.isNullOrBlank()) {
return "* * A"
}
val clean = pattern.replace("'", "").replace("\"", "").trim()
if (clean == "* * *" || clean == "*") {
return "* * A"
} else if (clean.contains('.')) {
val dot = clean.indexOf('.')
return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"
} else {
val parts = clean.split(Regex("\\s+"))
return when (parts.size) {
1 -> "${parts[0]} * A"
2 -> "${parts[0]} ${parts[1]} A"
else -> "${parts[0]} ${parts[1]} ${parts[2]}"
}
}
}
fun matchesFilter(entry: HostDirectoryEntry, filter: String?): Boolean {
if (filter.isNullOrBlank()) return true
val cleanFilter = filter.replace("'", "").replace("\"", "").trim()
if (cleanFilter.isEmpty() || cleanFilter == "*" || cleanFilter == "* * *" || cleanFilter == "*.*") {
return true
}
return when (entry) {
is CMSDirectoryEntry -> matchesCmsFilter(entry, cleanFilter)
is TSODirectoryEntry -> matchesTsoFilter(entry, cleanFilter)
else -> matchesWildcard(entry.name, cleanFilter)
}
}
fun matchesCmsFilter(cms: CMSDirectoryEntry, filter: String): Boolean {
val fn = cms.filename?.trim() ?: ""
val ft = cms.filetype?.trim() ?: ""
val fm = cms.filemode?.trim() ?: ""
val tokens = filter.split(Regex("\\s+"))
return if (tokens.size >= 3) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1]) && matchesCmsFilemode(fm, tokens[2])
} else if (tokens.size == 2) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1])
} else {
val tok = tokens[0]
if (tok.contains('.')) {
val dot = tok.indexOf('.')
matchesWildcard(fn, tok.substring(0, dot)) && matchesWildcard(ft, tok.substring(dot + 1))
} else if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
true
} else {
matchesWildcard(cms.name, tok)
}
}
}
fun matchesCmsFilemode(actualFm: String?, fmPattern: String?): Boolean {
if (fmPattern.isNullOrEmpty() || fmPattern == "*") return true
if (actualFm.isNullOrEmpty()) return false
if (fmPattern.length == 1 && fmPattern[0].isLetter()) {
return actualFm.startsWith(fmPattern, ignoreCase = true)
}
return matchesWildcard(actualFm, fmPattern)
}
fun matchesTsoFilter(tso: TSODirectoryEntry, filter: String): Boolean {
val dsn = tso.datasetName?.trim() ?: ""
if (filter == "*" || filter == "* * *" || filter == "*.*") return true
if (filter.contains('*') || filter.contains('?')) {
return matchesWildcard(dsn, filter)
}
return dsn.contains(filter, ignoreCase = true)
}
fun matchesWildcard(text: String?, pattern: String?): Boolean {
val t = text ?: ""
if (pattern.isNullOrEmpty() || pattern == "*") return true
val regex = buildString {
append("^")
for (c in pattern) {
when (c) {
'*' -> append(".*")
'?' -> append(".")
in "\\.[]{}()+^$|" -> append("\\").append(c)
else -> append(Regex.escape(c.uppercaseChar().toString()))
}
}
append("$")
}
return t.uppercase(Locale.US).matches(Regex(regex))
}
private fun waitForKeyboardUnlock(client: Telnet3270Client?, timeoutMs: Long): Boolean {
if (client == null) return false
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var locked = false
if (client.inputProcessor != null && client.inputProcessor.isKeyboardLocked) {
locked = true
}
if (client.oia != null && (client.oia.isKeyLocked || client.oia.isXSystem)) {
locked = true
}
if (!locked) {
return true
}
try {
Thread.sleep(40)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return false
}
}
return false
}
@Composable
fun HostDirectoryDialog(
@@ -37,71 +172,475 @@ fun HostDirectoryDialog(
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit
onSelectDataset: (String) -> Unit = {},
onSelectEntry: ((String, HostDirectoryEntry?) -> Unit)? = null
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
var allEntries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
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
}
var inspectionEntry by remember { mutableStateOf<TSODirectoryEntry?>(null) }
var showInspectionDialog by remember { mutableStateOf(false) }
val q = query.trim()
if (hostType == FTConfig.HostType.CMS) {
val list = xfer.getCmsDirectory(q)
entries = list ?: emptyList()
// Client-side filtering
val currentEntries by remember(allEntries, query, hostType) {
derivedStateOf {
val filter = query.trim()
if (filter.isEmpty()) {
allEntries
} else {
val list = xfer.getTsoDirectory(q)
entries = list ?: emptyList()
allEntries.filter { matchesFilter(it, filter) }
}
}
}
fun parseTextData(text: String, source: String) {
allEntries = if (hostType == FTConfig.HostType.CMS) {
CMSDirectoryParser.parse(text)
} else {
TSODirectoryParser.parse(text)
}
selectedIndex = -1
statusMessage = "${entries.size} entries found."
statusMessage = if (allEntries.isEmpty()) {
"0 entries found from $source. Use 'Query Host' or 'Paste Clipboard'."
} else {
"Parsed ${allEntries.size} entries from $source."
}
}
fun parseScreen() {
val c = client
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "No active screen to parse."
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to parse."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
val rows = c.ps.rows
val cols = c.ps.cols
val screenText = c.ps.copyString(0, 0, rows - 1, cols - 1)
parseTextData(screenText, "presentation space")
}
fun parseClipboard() {
try {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (cm != null && cm.hasPrimaryClip()) {
val clip = cm.primaryClip
if (clip != null && clip.itemCount > 0) {
val clipText = clip.getItemAt(0).coerceToText(context)?.toString()
if (!clipText.isNullOrBlank()) {
parseTextData(clipText, "clipboard")
return
}
}
}
statusMessage = "Clipboard contains no text."
} catch (ex: Exception) {
statusMessage = "Error reading clipboard: ${ex.message}"
}
}
fun queryHost() {
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query."
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()
val pattern = query.trim()
val curHostType = hostType
statusMessage = "Querying host ($curHostType)..."
isLoading = true
coroutineScope.launch(Dispatchers.IO) {
try {
val allPages = StringBuilder()
val maxPages = 500
if (curHostType == FTConfig.HostType.CMS) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
val cmsFilter = formatCMSFilter(pattern)
c.ps.SendKeys("LISTFILE $cmsFilter ( nohead alloc date[enter]")
Thread.sleep(100)
for (page in 0 until maxPages) {
val startWait = System.currentTimeMillis()
val timeoutMs = 10000L
var screen = ""
var isMore = false
while (System.currentTimeMillis() - startWait < timeoutMs) {
waitForKeyboardUnlock(c, 500)
val rows = c.ps.rows
val cols = c.ps.cols
screen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val upper = screen.uppercase(Locale.US)
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
isMore = true
break
}
if (upper.contains("READY;") || upper.contains("READY(")) {
break
}
Thread.sleep(150)
}
allPages.append(screen).append("\n")
if (isMore) {
c.ps.SendKeys("[clear]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 3000)
} else {
break
}
}
if (c.ps != null) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
}
} else {
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
var tsoCmd = "TSO LISTCAT ALL"
if (pattern.isNotEmpty()) {
var dsnPattern = pattern.trim()
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length > 2) {
dsnPattern = dsnPattern.substring(1, dsnPattern.length - 1)
}
val paren = dsnPattern.indexOf('(')
if (paren > 0) {
dsnPattern = dsnPattern.substring(0, paren)
}
if (dsnPattern == "*" || dsnPattern == "* * *" || dsnPattern == "*.*") {
tsoCmd = "TSO LISTCAT ALL"
} else {
var level = dsnPattern
if (level.endsWith(".*")) {
level = level.substring(0, level.length - 2)
} else if (level.endsWith("*")) {
level = level.substring(0, level.length - 1)
}
tsoCmd = if (level.isEmpty()) "TSO LISTCAT ALL" else "TSO LISTCAT LEVEL($level) ALL"
}
}
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG")
|| u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS")
|| u.contains("***") || u.contains("READY") || u.contains("NOT FOUND")
|| u.contains("NO ENTRY") || s.contains(" --- ")
|| u.contains("--RECFM-") || u.contains("--VOLUMES--")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until maxPages) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
allPages.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
|| upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")
|| upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 1000)
val r2 = c.ps.rows
val col2 = c.ps.cols
val s2 = c.ps.copyString(0, 0, r2 - 1, col2 - 1)
if (s2.uppercase(Locale.US).contains("***")) {
prevScreen = s2
allPages.append(s2).append("\n")
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
continue
} else {
if (s2 != screen && s2.isNotBlank()) {
allPages.append(s2).append("\n")
}
break
}
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("NONVSAM") || currentScr.uppercase(Locale.US).contains("IDCAMS")
|| currentScr.contains(" --- ") || currentScr.uppercase(Locale.US).contains("--RECFM-")
|| (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
}
val collectedText = allPages.toString()
withContext(Dispatchers.Main) {
parseTextData(collectedText, "host query")
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusMessage = "Host query failed: ${e.message}"
isLoading = false
}
}
}
}
fun inspectSelectedDataset() {
val selected = currentEntries.getOrNull(selectedIndex)
if (selected == null || selected !is TSODirectoryEntry) {
statusMessage = "Please select a TSO dataset entry to inspect."
return
}
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query host."
return
}
val dsn = selected.datasetName
if (dsn.isNullOrBlank()) return
statusMessage = "Inspecting $dsn via TSO LISTDS..."
isLoading = true
coroutineScope.launch(Dispatchers.IO) {
try {
val output = StringBuilder()
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
val tsoCmd = "TSO LISTDS '$dsn' STATUS"
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("--RECFM-") || u.contains("--VOLUMES--") || u.contains("***")
|| u.contains("READY") || u.contains("NOT IN CATALOG") || u.contains("NOT FOUND")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until 5) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
output.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("--RECFM-") || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
val outText = output.toString()
val parsed = TSODirectoryParser.parse(outText)
withContext(Dispatchers.Main) {
isLoading = false
if (parsed.isNotEmpty()) {
val updated = parsed[0]
if (updated.recfm != null) selected.recfm = updated.recfm
if (updated.lrecl > 0) selected.lrecl = updated.lrecl
if (updated.blksize > 0) selected.blksize = updated.blksize
if (updated.dsorg != null) selected.dsorg = updated.dsorg
if (updated.volume != null && (selected.volume == null || selected.volume == "******" || selected.volume.isEmpty())) {
selected.volume = updated.volume
}
if (updated.tracksAllocated > 0) selected.tracksAllocated = updated.tracksAllocated
if (updated.extents > 0) selected.extents = updated.extents
statusMessage = "Attributes for $dsn: RECFM=${selected.recfm}, LRECL=${selected.lrecl}, BLKSIZE=${selected.blksize}, VOL=${selected.volume}"
inspectionEntry = selected
showInspectionDialog = true
} else {
statusMessage = "No VTOC attributes found for $dsn"
}
}
} catch (ex: Exception) {
withContext(Dispatchers.Main) {
isLoading = false
statusMessage = "Inspection failed: ${ex.message}"
}
}
}
selectedIndex = -1
statusMessage = "Parsed ${entries.size} entries from screen."
}
LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) {
runQuery()
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
}
if (showInspectionDialog && inspectionEntry != null) {
val entry = inspectionEntry!!
AlertDialog(
onDismissRequest = { showInspectionDialog = false },
title = { Text("Dataset Attributes (LISTDS)", fontWeight = FontWeight.Bold, fontSize = 16.sp) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Dataset: ${entry.datasetName}", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.White)
HorizontalDivider(color = Color(0xFF373A40))
Text("Volume: ${entry.volume ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Record Format (RECFM): ${entry.recfm ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Logical Record Length (LRECL): ${if (entry.lrecl > 0) entry.lrecl.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Block Size (BLKSIZE): ${if (entry.blksize > 0) entry.blksize.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Dataset Organization (DSORG): ${entry.dsorg ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
if (entry.tracksAllocated > 0) {
Text("Allocated Tracks: ${entry.tracksAllocated}", fontSize = 12.sp, color = Color.LightGray)
}
if (entry.extents > 0) {
Text("Extents: ${entry.extents}", fontSize = 12.sp, color = Color.LightGray)
}
}
},
confirmButton = {
Button(onClick = { showInspectionDialog = false }) {
Text("OK")
}
}
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -135,13 +674,33 @@ fun HostDirectoryDialog(
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
onClick = { hostType = FTConfig.HostType.TSO },
onClick = {
hostType = FTConfig.HostType.TSO
if (query == "* * A" || query == "* * *") {
query = ""
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
onClick = { hostType = FTConfig.HostType.CMS },
onClick = {
hostType = FTConfig.HostType.CMS
if (query.isBlank() || query == "* * *" || query == "*") {
query = "* * A"
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("CMS", fontSize = 11.sp) }
)
}
@@ -155,8 +714,8 @@ fun HostDirectoryDialog(
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Query Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") },
label = { Text("Filter / Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
@@ -164,14 +723,29 @@ fun HostDirectoryDialog(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(onSearch = { runQuery() }),
keyboardActions = KeyboardActions(onSearch = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else if (allEntries.isEmpty() && client != null && client.connectionState.isFullSession) {
queryHost()
}
}),
modifier = Modifier.weight(1f)
)
Button(
onClick = { runQuery() },
onClick = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else {
queryHost()
}
},
enabled = !isLoading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Query")
Text(if (isLoading) "Querying..." else "Query Host")
}
}
@@ -181,26 +755,36 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = statusMessage.ifBlank { "Enter query or parse screen." },
text = if (statusMessage.isNotBlank()) statusMessage else "${currentEntries.size} entries shown (${allEntries.size} total).",
fontSize = 11.sp,
color = Color.Gray
color = Color.Gray,
modifier = Modifier.weight(1f)
)
TextButton(onClick = { parseScreen() }) {
Row {
TextButton(onClick = { parseClipboard() }, enabled = !isLoading) {
Text("Paste Clipboard", fontSize = 11.sp, color = Color(0xFF339AF0))
}
TextButton(onClick = { parseScreen() }, enabled = !isLoading) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
if (entries.isEmpty()) {
if (currentEntries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text("No entries found.", color = Color.Gray, fontSize = 12.sp)
Text(
if (isLoading) "Loading entries from host..." else "No entries found.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
@@ -227,7 +811,7 @@ fun HostDirectoryDialog(
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("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(240.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)
@@ -242,7 +826,7 @@ fun HostDirectoryDialog(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(entries) { idx, item ->
itemsIndexed(currentEntries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
@@ -257,21 +841,21 @@ fun HostDirectoryDialog(
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))
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(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.numRecords > 0) "${item.numRecords}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date ?: ""} ${item.time ?: ""}".trim(), 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)
Text(item.datasetName ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(240.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(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.blksize > 0) "${item.blksize}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.extents > 0) "${item.extents}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
@@ -283,22 +867,37 @@ fun HostDirectoryDialog(
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row {
if (hostType == FTConfig.HostType.TSO) {
OutlinedButton(
onClick = { inspectSelectedDataset() },
enabled = selectedIndex in currentEntries.indices && !isLoading,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
) {
Text("Inspect (LISTDS)", fontSize = 11.sp)
}
}
}
Row(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
if (selectedIndex in currentEntries.indices) {
val entry = currentEntries[selectedIndex]
val selectedName = entry.name
onSelectEntry?.invoke(selectedName, entry)
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in entries.indices,
enabled = selectedIndex in currentEntries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
@@ -307,4 +906,5 @@ fun HostDirectoryDialog(
}
}
}
}
}
@@ -40,6 +40,7 @@ fun OiaStatusBar(
inhibitReason: Int = 0,
luName: String = "",
isNumericField: Boolean = false,
fieldStatus: String = "",
uiTheme: String = "DARK",
modifier: Modifier = Modifier
) {
@@ -163,16 +164,22 @@ fun OiaStatusBar(
}
}
// Field Status: NUM vs ALPHA
// Field Status: DBCS vs NUM vs ALPHA
if (connectionState.isConnected() && rows > 0 && cols > 0) {
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
val badgeColor = when (effectiveFieldStatus) {
"DBCS" -> sysAvailClr
"NUM" -> Color(0xFFFFD43B)
else -> Color(0xFF74C0FC)
}
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF339AF0).copy(alpha = 0.2f),
color = badgeColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isNumericField) "NUM" else "ALPHA",
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC),
text = effectiveFieldStatus,
color = badgeColor,
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -33,6 +33,7 @@ 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.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
@@ -296,6 +297,10 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
@@ -346,6 +351,10 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
currentFieldEa = cell
sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
sb.append(' ')
} else {
if (currentHidden && maskHidden) {
sb.append(' ')
@@ -52,6 +52,7 @@ fun SettingsDialog(
initialFourColorOverride: Boolean = false,
initialNumericFieldLock: Boolean = true,
initialAutoSkipEnabled: Boolean = true,
initialDefaultHostType: String = "TSO",
onDismiss: () -> Unit,
onSave: (
maskHiddenInput: Boolean,
@@ -85,7 +86,8 @@ fun SettingsDialog(
insertOffOnAid: Boolean,
fourColorOverride: Boolean,
numericFieldLock: Boolean,
autoSkipEnabled: Boolean
autoSkipEnabled: Boolean,
defaultHostType: String
) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -111,6 +113,10 @@ fun SettingsDialog(
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
val savedHosts = remember { haus.nightmare.a3270.storage.HostStorage.getSavedHosts(context) }
var selectedAutoHostId by remember {
mutableStateOf(haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)?.id ?: savedHosts.firstOrNull()?.id ?: "")
}
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
@@ -125,6 +131,7 @@ fun SettingsDialog(
var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) }
var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) }
var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) }
var defaultHostType by remember { mutableStateOf(initialDefaultHostType) }
var showImportDialog by remember { mutableStateOf(false) }
@@ -488,6 +495,47 @@ fun SettingsDialog(
)
}
}
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true) && savedHosts.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text("Auto-Connect Target Profile", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(4.dp))
var hostDropdownExpanded by remember { mutableStateOf(false) }
val selectedHost = savedHosts.find { it.id == selectedAutoHostId } ?: savedHosts.first()
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { hostDropdownExpanded = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "${selectedHost.name} (${selectedHost.host}:${selectedHost.port})",
fontSize = 12.sp,
color = Color.White
)
}
DropdownMenu(
expanded = hostDropdownExpanded,
onDismissRequest = { hostDropdownExpanded = false }
) {
savedHosts.forEach { host ->
DropdownMenuItem(
text = {
Text(
text = "${host.name} (${host.host}:${host.port})",
fontSize = 12.sp,
fontWeight = if (host.id == selectedAutoHostId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
selectedAutoHostId = host.id
hostDropdownExpanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
@@ -613,6 +661,29 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(12.dp))
// Default Host Type
Column(modifier = Modifier.fillMaxWidth()) {
Text("Default Host Type", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
Text("Target system environment for IND\$FILE transfers and directory queries.", fontSize = 12.sp, color = Color.LightGray)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("TSO", "CMS", "CICS").forEach { hostTypeKey ->
val isSelected = defaultHostType.equals(hostTypeKey, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = { defaultHostType = hostTypeKey },
label = { Text(hostTypeKey, fontSize = 10.sp) },
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Verify TLS Certs
Row(
modifier = Modifier.fillMaxWidth(),
@@ -842,6 +913,15 @@ fun SettingsDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
haus.nightmare.a3270.storage.AppSettings.shareSettingsIni(context)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1971C2)),
modifier = Modifier.weight(1f)
) {
Text("Share INI", fontSize = 12.sp)
}
OutlinedButton(
onClick = {
val sw = java.io.StringWriter()
@@ -852,13 +932,13 @@ fun SettingsDialog(
},
modifier = Modifier.weight(1f)
) {
Text("Export to INI", fontSize = 12.sp)
Text("Copy", fontSize = 12.sp)
}
OutlinedButton(
onClick = { showImportDialog = true },
modifier = Modifier.weight(1f)
) {
Text("Import from INI", fontSize = 12.sp)
Text("Import", fontSize = 12.sp)
}
}
@@ -912,8 +992,21 @@ fun SettingsDialog(
insertOffOnAid,
fourColorOverride,
numericFieldLock,
autoSkipEnabled
autoSkipEnabled,
defaultHostType
)
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
val targetHost = savedHosts.find { it.id == selectedAutoHostId }
if (targetHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, targetHost.copy(autoConnect = true))
}
} else {
val autoHost = haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)
if (autoHost != null) {
haus.nightmare.a3270.storage.HostStorage.saveHost(context, autoHost.copy(autoConnect = false))
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
@@ -976,6 +1069,7 @@ fun SettingsDialog(
fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context)
numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context)
autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(context)
defaultHostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
@@ -88,6 +88,14 @@ class TerminalInputView(context: Context) : View(context) {
var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {}
var isDbcsSession: Boolean = false
set(value) {
if (field != value) {
field = value
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.restartInput(this)
}
}
init {
isFocusable = true
@@ -97,9 +105,13 @@ class TerminalInputView(context: Context) : View(context) {
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
outAttrs.inputType = if (isDbcsSession) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
} else {
InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
}
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE
@@ -355,9 +355,9 @@ fun TerminalView(
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
val ea = if (buf != null) buf.getCell(addr) else null
if (ea != null) {
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
@@ -411,7 +411,10 @@ fun TerminalView(
bgColor = tmp
}
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) {
val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
@@ -449,19 +452,22 @@ fun TerminalView(
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f)
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(left, top + cellHeight - ulH),
size = Size(cellWidth, ulH)
topLeft = Offset(curLeft, top + cellHeight - ulH),
size = Size(curWidth, ulH)
)
} else {
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
topLeft = Offset(curLeft, top),
size = Size(curWidth, cellHeight)
)
}
}
@@ -487,12 +493,14 @@ fun TerminalView(
}
}
val suppressText = isBlink && !textBlinkVisible
val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
if (!drawnAsPs && charVal != ' ' && !suppressText) {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textX = left + cellWidth / 2f
val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
@@ -840,8 +848,8 @@ fun calculateGridMetrics(
val gridW = cols * cellW
val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f
val offY = (viewHeight - gridH) / 2f
val offX = maxOf(0f, (viewWidth - gridW) / 2f)
val offY = maxOf(0f, (viewHeight - gridH) / 2f)
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
@@ -882,11 +890,24 @@ private fun copySelection(
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
if (cell != null && !cell.isFieldAttribute) {
if (cell.db == ExtendedAttribute.DB_RIGHT) {
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
if (isSubsequent) {
continue // Character already added by DB_LEFT
} else {
line.append(' ')
}
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
line.append(' ')
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="exports" path="exports/" />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>
@@ -0,0 +1,318 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.a3270.ui.ConnectChoice
import haus.nightmare.a3270.ui.LastTransferState
import haus.nightmare.a3270.ui.formatCMSFilter
import haus.nightmare.a3270.ui.matchesCmsFilemode
import haus.nightmare.a3270.ui.matchesCmsFilter
import haus.nightmare.a3270.ui.matchesFilter
import haus.nightmare.a3270.ui.matchesTsoFilter
import haus.nightmare.a3270.ui.matchesWildcard
import haus.nightmare.a3270.ui.resolveDefaultQuery
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.StringReader
import java.util.UUID
class ConnectionAndHostDirectoryTest {
@Test
fun testSavedHostToConnectionConfigPreservesMetadata() {
val hostId = UUID.randomUUID().toString()
val host = SavedHost(
id = hostId,
name = "MVS Production LPAR",
host = "mvs.prod.corp",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU3270A",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "mvsadmin",
proxyPassword = "mypassword"
)
val config = host.toConnectionConfig()
assertEquals("mvs.prod.corp", config.host)
assertEquals(992, config.port)
assertEquals(TerminalModel.IBM_3279_4, config.model)
assertEquals("LU3270A", config.luName)
assertTrue(config.isUseTls)
assertTrue(config.isTlsVerifyCert)
assertTrue(config.isTn3270eEnabled)
assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode)
assertEquals("1047", config.codePage)
assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType)
assertEquals("proxy.corp", config.proxyHost)
assertEquals(1080, config.proxyPort)
assertEquals("mvsadmin", config.proxyUsername)
assertEquals("mypassword", config.proxyPassword)
// Metadata preservation added in connection hardening
assertTrue("savedConnection flag must be true", config.isSavedConnection)
assertEquals(hostId, config.savedProfileId)
assertEquals("MVS Production LPAR", config.savedProfileName)
assertEquals("TSO", config.hostType)
assertEquals(FTConfig.HostType.TSO, config.ftHostType)
}
@Test
fun testSavedHostToConnectionConfigCmsHostType() {
val host = SavedHost(
id = "cms-1",
name = "VM/CMS Guest",
host = "vm.local",
port = 23,
hostType = "CMS"
)
val config = host.toConnectionConfig()
assertEquals("CMS", config.hostType)
assertEquals(FTConfig.HostType.CMS, config.ftHostType)
}
@Test
fun testResolveDefaultQuery() {
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, ""))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " "))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*"))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"))
assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A"))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, ""))
assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*"))
}
@Test
fun testFormatCMSFilter() {
assertEquals("* * A", formatCMSFilter(null))
assertEquals("* * A", formatCMSFilter(""))
assertEquals("* * A", formatCMSFilter(" "))
assertEquals("* * A", formatCMSFilter("*"))
assertEquals("* * A", formatCMSFilter("* * *"))
assertEquals("MYFILE * A", formatCMSFilter("MYFILE"))
assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC"))
assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B"))
assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC"))
assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'"))
}
@Test
fun testMatchesWildcard() {
assertTrue(matchesWildcard("HELLO", "*"))
assertTrue(matchesWildcard("HELLO", "H*"))
assertTrue(matchesWildcard("HELLO", "H*O"))
assertTrue(matchesWildcard("HELLO", "H?LLO"))
assertTrue(matchesWildcard("HELLO", "?????"))
assertFalse(matchesWildcard("HELLO", "H?L"))
assertFalse(matchesWildcard("HELLO", "WORLD"))
assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME"))
assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*"))
assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*"))
}
@Test
fun testMatchesCmsFilterAndFilemode() {
val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply {
recfm = "F"
lrecl = 80
blksize = 80
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "* * *"))
assertTrue(matchesFilter(entry, "* * A"))
assertTrue(matchesFilter(entry, "PROFILE * A"))
assertTrue(matchesFilter(entry, "PROFILE EXEC *"))
assertTrue(matchesFilter(entry, "PROFILE EXEC A1"))
assertTrue(matchesFilter(entry, "PROFILE.EXEC"))
assertTrue(matchesFilter(entry, "PROFILE"))
assertTrue(matchesFilter(entry, "EXEC"))
assertFalse(matchesFilter(entry, "* * B"))
assertFalse(matchesFilter(entry, "TEST * *"))
assertFalse(matchesFilter(entry, "*.COBOL"))
assertFalse(matchesFilter(entry, "PROFILE EXEC A2"))
assertTrue(matchesCmsFilemode("A1", "A"))
assertTrue(matchesCmsFilemode("A1", "A1"))
assertTrue(matchesCmsFilemode("A1", "*"))
assertFalse(matchesCmsFilemode("A1", "B"))
assertFalse(matchesCmsFilemode("A1", "A2"))
}
@Test
fun testMatchesTsoFilter() {
val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply {
volume = "VOL001"
dsorg = "PO"
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "*"))
assertTrue(matchesFilter(entry, "*SOURCE*"))
assertTrue(matchesFilter(entry, "USER.*.COBOL"))
assertTrue(matchesFilter(entry, "COBOL")) // substring search
assertTrue(matchesFilter(entry, "USER.SOURCE"))
assertFalse(matchesFilter(entry, "SYS1.*"))
assertFalse(matchesFilter(entry, "*.PLI"))
assertFalse(matchesFilter(entry, "FORTRAN"))
}
@Test
fun testAppSettingsIniHostTypeParsing() {
val testIni = """
[Connection]
hostType = CMS
[General]
defaultHostType = CICS
""".trimIndent()
var parsedHostType: String? = null
AppSettings.parseIni(StringReader(testIni)) { _, key, value ->
if (key.equals("hostType", ignoreCase = true) ||
key.equals("hosttype", ignoreCase = true) ||
key.equals("host_type", ignoreCase = true) ||
key.equals("defaulthosttype", ignoreCase = true)
) {
parsedHostType = value
}
}
assertNotNull(parsedHostType)
// First or second value parsed
assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true))
}
@Test
fun testLastTransferStateUnitsAndDirBlocks() {
val state = LastTransferState()
assertEquals(FTConfig.HostType.TSO, state.hostType)
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("", state.dirBlocks)
state.units = FTConfig.AllocationUnit.TRACKS
state.dirBlocks = "20"
state.hostType = FTConfig.HostType.CMS
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("20", state.dirBlocks)
assertEquals(FTConfig.HostType.CMS, state.hostType)
}
@Test
fun testConnectChoiceEnum() {
val choices = ConnectChoice.values()
assertEquals(3, choices.size)
assertTrue(choices.contains(ConnectChoice.NEW_WINDOW))
assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT))
assertTrue(choices.contains(ConnectChoice.CANCEL))
}
@Test
fun testHostStorageExportAndLoadIniRoundtrip() {
val host1 = SavedHost(
id = "test-h1",
name = "Test Mainframe MVS",
host = "mvs.corp.local",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU01",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "admin",
proxyPassword = "secret"
)
val host2 = SavedHost(
id = "test-h2",
name = "VM CMS Guest",
host = "vm.corp.local",
port = 23,
hostType = "CMS"
)
val sw = java.io.StringWriter()
HostStorage.exportToIni(listOf(host1, host2), sw)
val exportedIni = sw.toString()
assertTrue(exportedIni.contains("[Test Mainframe MVS]"))
assertTrue(exportedIni.contains("host = mvs.corp.local"))
assertTrue(exportedIni.contains("port = 992"))
assertTrue(exportedIni.contains("autoConnect = true"))
assertTrue(exportedIni.contains("hostType = TSO"))
assertTrue(exportedIni.contains("useTls = true"))
assertTrue(exportedIni.contains("[VM CMS Guest]"))
assertTrue(exportedIni.contains("hostType = CMS"))
val loaded = HostStorage.loadFromIni(StringReader(exportedIni))
assertEquals(2, loaded.size)
val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" }
assertNotNull(loadedH1)
assertEquals("test-h1", loadedH1!!.id)
assertEquals("mvs.corp.local", loadedH1.host)
assertEquals(992, loadedH1.port)
assertEquals(4, loadedH1.model)
assertEquals(62, loadedH1.dynamicRows)
assertEquals(160, loadedH1.dynamicCols)
assertEquals("LU01", loadedH1.luName)
assertTrue(loadedH1.autoConnect)
assertEquals("TSO", loadedH1.hostType)
assertTrue(loadedH1.useTls)
assertFalse(loadedH1.tlsVerifyCert)
assertTrue(loadedH1.tn3270e)
assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode)
assertEquals("1047", loadedH1.codePage)
assertEquals("SOCKS5", loadedH1.proxyType)
assertEquals("proxy.corp", loadedH1.proxyHost)
assertEquals(1080, loadedH1.proxyPort)
assertEquals("admin", loadedH1.proxyUsername)
assertEquals("secret", loadedH1.proxyPassword)
val loadedH2 = loaded.find { it.name == "VM CMS Guest" }
assertNotNull(loadedH2)
assertEquals("test-h2", loadedH2!!.id)
assertEquals("vm.corp.local", loadedH2.host)
assertEquals(23, loadedH2.port)
assertEquals("CMS", loadedH2.hostType)
}
}
@@ -0,0 +1,125 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.lang.reflect.Method
class DBCSAndParityCompatibilityTest {
@Test
fun testNewJapaneseCodePagesAvailableAndResolvable() {
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
val cp290 = CodePageRegistry.getCodePage("290")
assertNotNull("Cp290 must resolve", cp290)
assertFalse("Cp290 is SBCS", cp290.isDBCS)
val cp1027 = CodePageRegistry.getCodePage("1027")
assertNotNull("Cp1027 must resolve", cp1027)
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
val cp1390 = CodePageRegistry.getCodePage("1390")
assertNotNull("Cp1390 must resolve", cp1390)
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
val cp1399 = CodePageRegistry.getCodePage("1399")
assertNotNull("Cp1399 must resolve", cp1399)
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
val trans1390 = EbcdicTranslator("1390")
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
}
@Test
fun testScreenExporterDBCSNoDuplication() {
val trans = EbcdicTranslator("930")
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
screen.erase(false)
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
val cell0 = screen.getCell(0)
cell0.db = ExtendedAttribute.DB_LEFT
cell0.ucs4 = '\u6771'
val cell1 = screen.getCell(1)
cell1.db = ExtendedAttribute.DB_RIGHT
cell1.ucs4 = '\u6771'
// Put SBCS 'X' at cell 2
val cell2 = screen.getCell(2)
cell2.db = ExtendedAttribute.DB_NONE
cell2.ucs4 = 'X'
// Invoke private generatePlainText via reflection
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
val method: Method = exporterClass.getDeclaredMethod(
"generatePlainText",
ScreenBuffer::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
)
method.isAccessible = true
val plainText = method.invoke(null, screen, 1, 80, true) as String
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
}
@Test
fun testTerminalGridMetricsCenteringClampedAtZero() {
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
val method = terminalViewClass.getDeclaredMethod(
"calculateGridMetrics",
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
method.isAccessible = true
// View is constrained / non-negative offsets
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
offXField.isAccessible = true
val offX = offXField.getFloat(constrainedMetrics)
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
offYField.isAccessible = true
val offY = offYField.getFloat(constrainedMetrics)
assertTrue("Constrained offX must be non-negative", offX >= 0f)
assertTrue("Constrained offY must be non-negative", offY >= 0f)
// Wide view (pillarbox with positive offX)
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
val wideOffX = offXField.getFloat(wideMetrics)
val wideOffY = offYField.getFloat(wideMetrics)
assertTrue("Wide view offX should be positive", wideOffX > 0f)
assertEquals(0f, wideOffY, 0.001f)
// Tall view (letterbox with positive offY)
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
val tallOffX = offXField.getFloat(tallMetrics)
val tallOffY = offYField.getFloat(tallMetrics)
assertEquals(0f, tallOffX, 0.001f)
assertTrue("Tall view offY should be positive", tallOffY > 0f)
}
@Test
fun testExtendedAttributeTransparencyConstants() {
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
}
}