Bring up to date with Connection exporting and file transfer updates

This commit is contained in:
2026-09-24 12:46:55 +00:00
parent ee44759668
commit fe185bd1f0
10 changed files with 1323 additions and 148 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 7 versionCode 8
versionName "1.2.2" versionName "1.2.3"
} }
buildTypes { buildTypes {
+12 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="7" android:versionCode="8"
android:versionName="1.2.2"> android:versionName="1.2.3">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -37,6 +37,16 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="3270 Terminal TCP socket connection persistence" /> android:value="3270 Terminal TCP socket connection persistence" />
</service> </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> </application>
</manifest> </manifest>
@@ -29,6 +29,8 @@ import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.a3270.service.TerminalService import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage 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.ConnectDialog
import haus.nightmare.a3270.ui.FileTransferDialog import haus.nightmare.a3270.ui.FileTransferDialog
import haus.nightmare.a3270.ui.OiaStatusBar import haus.nightmare.a3270.ui.OiaStatusBar
@@ -576,7 +578,13 @@ fun MainScreen(
fieldStatus == "NUM" 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 showConnectDialog by remember { mutableStateOf(false) }
var showConnectChoiceDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) }
var showFieldInspectorDialog by remember { mutableStateOf(false) } var showFieldInspectorDialog by remember { mutableStateOf(false) }
@@ -621,30 +629,17 @@ fun MainScreen(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (openConnectFromIntent) {
activity?.intent?.removeExtra("OPEN_CONNECT_DIALOG")
showConnectDialog = true
return@LaunchedEffect
}
val startupBehavior = AppSettings.getStartupBehavior(context) val startupBehavior = AppSettings.getStartupBehavior(context)
when (startupBehavior) { when (startupBehavior) {
AppSettings.StartupBehavior.AUTO_CONNECT -> { AppSettings.StartupBehavior.AUTO_CONNECT -> {
val autoHost = HostStorage.getAutoConnectHost(context) val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) { if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect( viewModel.connect(autoHost.toConnectionConfig())
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
)
} }
} }
AppSettings.StartupBehavior.SHOW_CONNECT -> { AppSettings.StartupBehavior.SHOW_CONNECT -> {
@@ -782,7 +777,11 @@ fun MainScreen(
onClearShift = onClearShift, onClearShift = onClearShift,
onConnectClick = { onConnectClick = {
terminalInputViewRef?.hideSoftKeyboard() terminalInputViewRef?.hideSoftKeyboard()
if (connectionState.isConnected()) {
showConnectChoiceDialog = true
} else {
showConnectDialog = true showConnectDialog = true
}
}, },
onDisconnectClick = { viewModel.disconnect() }, onDisconnectClick = { viewModel.disconnect() },
onFtClick = { onFtClick = {
@@ -915,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) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { onDismiss = {
showConnectDialog = false showConnectDialog = false
terminalInputViewRef?.showSoftKeyboard() 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 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() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -985,13 +1034,14 @@ fun MainScreen(
initialFourColorOverride = fourColorOverride, initialFourColorOverride = fourColorOverride,
initialNumericFieldLock = AppSettings.getNumericFieldLock(context), initialNumericFieldLock = AppSettings.getNumericFieldLock(context),
initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context), initialAutoSkipEnabled = AppSettings.getAutoSkipEnabled(context),
initialDefaultHostType = AppSettings.getDefaultHostType(context),
onDismiss = { onDismiss = {
showSettingsDialog = false showSettingsDialog = false
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst, onSave = { mask, blink, haptic, verify, gfx, defaultCp, theme, crosshair, style, blockSel, blockPst,
sizingHandles, remainAfterEdit, wordBreak, hsEnabled, hsUrl, hsPf, hsChoices, 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 showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp) viewModel.updateSettings(mask, blink, haptic, verify, gfx, defaultCp)
viewModel.setUiTheme(theme) viewModel.setUiTheme(theme)
@@ -1020,6 +1070,7 @@ fun MainScreen(
AppSettings.setFourColorOverride(context, fourCol) AppSettings.setFourColorOverride(context, fourCol)
AppSettings.setNumericFieldLock(context, numLk) AppSettings.setNumericFieldLock(context, numLk)
AppSettings.setAutoSkipEnabled(context, autoSk) AppSettings.setAutoSkipEnabled(context, autoSk)
AppSettings.setDefaultHostType(context, defHostType)
viewModel.reloadSettings() viewModel.reloadSettings()
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
} }
@@ -26,6 +26,7 @@ import haus.nightmare.a3270.service.TerminalService
import haus.nightmare.a3270.storage.AppSettings import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine import haus.nightmare.a3270.ui.HotspotEngine
import java.io.File import java.io.File
import java.util.Locale
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -442,9 +443,21 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private var client: Telnet3270Client? = null private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set private set
var currentPort: Int = 23
private set
var currentProfileName: String? = null
private set
var activeHostType: String = "TSO" var activeHostType: String = "TSO"
private set 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 getClient(): Telnet3270Client? = client
fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer fun getScreenHistoryBuffer(): ScreenHistoryBuffer? = client?.screenBuffer?.historyBuffer
@@ -891,9 +904,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
proxyHost: String = "", proxyHost: String = "",
proxyPort: Int = 0, proxyPort: Int = 0,
proxyUser: String = "", proxyUser: String = "",
proxyPass: String = "" proxyPass: String = "",
savedConnection: Boolean = false,
savedProfileId: String? = null,
savedProfileName: String? = null
) { ) {
currentHost = host currentHost = host
currentPort = port
currentProfileName = savedProfileName
activeHostType = hostType activeHostType = hostType
lastScreenContentHash = 0 lastScreenContentHash = 0
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
@@ -902,6 +920,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_codePage.value = codePageStr _codePage.value = codePageStr
clearSearchHighlight() 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) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Always disconnect any existing client before establishing a new connection // Always disconnect any existing client before establishing a new connection
@@ -939,6 +969,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
(if (effectiveTls) " [TLS]" else "") + "..." (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply { 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) { if (model == TerminalModel.IBM_DYNAMIC) {
setDynamicDimensions(dynamicRows, dynamicCols) 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() { fun disconnect() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -1,8 +1,10 @@
package haus.nightmare.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter import java.io.PrintWriter
import java.io.Reader import java.io.Reader
import java.io.Writer import java.io.Writer
@@ -19,6 +21,7 @@ object AppSettings {
private const val KEY_VERIFY_CERTS = "verify_certs" private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode" private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page" private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
// New settings aligned with j3270 // New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme" private const val KEY_UI_THEME = "ui_theme"
@@ -112,6 +115,15 @@ object AppSettings {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply() 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 ========== // ========== UI Theme ==========
fun getUiTheme(context: Context): String { fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK" return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
@@ -378,6 +390,7 @@ object AppSettings {
p.println("autoConnectTn3270e = ${autoHost.tn3270e}") p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
} }
p.println("codePage = ${getDefaultCodePage(context)}") p.println("codePage = ${getDefaultCodePage(context)}")
p.println("hostType = ${getDefaultHostType(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}") p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}") p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}") p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
@@ -418,6 +431,30 @@ object AppSettings {
p.flush() 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) { fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader) val bufferedReader = BufferedReader(reader)
var currentSection = "" var currentSection = ""
@@ -462,6 +499,7 @@ object AppSettings {
} catch (_: Exception) {} } catch (_: Exception) {}
} }
"codepage", "charset" -> setDefaultCodePage(context, value) "codepage", "charset" -> setDefaultCodePage(context, value)
"hosttype", "host_type" -> setDefaultHostType(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) } "dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) } "dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean()) "blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
@@ -1,9 +1,21 @@
package haus.nightmare.a3270.storage package haus.nightmare.a3270.storage
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences 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.JSONArray
import org.json.JSONObject 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 import java.util.UUID
data class SavedHost( data class SavedHost(
@@ -28,6 +40,47 @@ data class SavedHost(
val proxyUsername: String = "", val proxyUsername: String = "",
val proxyPassword: 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 { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
put("id", id) put("id", id)
@@ -196,4 +249,156 @@ object HostStorage {
hosts.forEach { array.put(it.toJson()) } hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply() 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()
}
}
} }
@@ -55,12 +55,16 @@ fun ConnectDialog(
proxyHost: String, proxyHost: String,
proxyPort: Int, proxyPort: Int,
proxyUser: String, proxyUser: String,
proxyPass: String proxyPass: String,
savedConnection: Boolean,
savedProfileId: String?,
savedProfileName: String?
) -> Unit ) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) } var selectedHostId by remember { mutableStateOf<String?>(null) }
var showImportConnectionsDialog by remember { mutableStateOf(false) }
var profileName by remember { mutableStateOf("") } var profileName by remember { mutableStateOf("") }
var host 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 dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") } var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) } 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 useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) } var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e 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() dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = "" luName = ""
autoConnect = false autoConnect = false
hostType = "TSO" hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
useTls = false useTls = false
tlsVerifyCert = true tlsVerifyCert = true
tn3270e = true tn3270e = true
@@ -231,13 +235,36 @@ fun ConnectDialog(
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Saved Profiles List Section // Saved Profiles List Section
if (savedHosts.isNotEmpty()) { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
"Saved Host Profiles:", if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Gray 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( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -301,7 +328,10 @@ fun ConnectDialog(
hostToConn.proxyHost, hostToConn.proxyHost,
hostToConn.proxyPort, hostToConn.proxyPort,
hostToConn.proxyUsername, hostToConn.proxyUsername,
hostToConn.proxyPassword hostToConn.proxyPassword,
true,
hostToConn.id,
hostToConn.name
) )
}, },
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
@@ -804,7 +834,10 @@ fun ConnectDialog(
saved.proxyHost, saved.proxyHost,
saved.proxyPort, saved.proxyPort,
saved.proxyUsername, 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( data class LastTransferState(
val hostFile: String = "", var hostFile: String = "",
val localFile: String = "", var localFile: String = "",
val isReceive: Boolean = true, var isReceive: Boolean = true,
val isAscii: Boolean = true, var isAscii: Boolean = true,
val hostType: FTConfig.HostType = FTConfig.HostType.TSO, var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
val recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT, var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
val lreclStr: String = "", var lreclStr: String = "",
val blksizeStr: String = "", var blksizeStr: String = "",
val spaceStr: String = "", var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
val cmsOptions: String = "" var spaceStr: String = "",
var dirBlocks: String = "",
var cmsOptions: String = ""
) )
private var lastTransferState: LastTransferState? = null private var lastTransferState: LastTransferState? = null
fun getLastTransferState(): LastTransferState? = lastTransferState
fun resetSessionState() { lastTransferState = null }
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String { fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
if (hostType == FTConfig.HostType.CMS) { if (hostType == FTConfig.HostType.CMS) {
val lastDot = localName.lastIndexOf('.') val lastDot = localName.lastIndexOf('.')
@@ -104,16 +109,26 @@ fun FileTransferDialog(
else -> FTConfig.HostType.TSO 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 hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") } var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") } var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) } var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: 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 recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") } var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") } 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 spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") } var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
var overwrite by remember { mutableStateOf(true) } var overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) } var showBrowseDialog by remember { mutableStateOf(false) }
@@ -186,15 +201,34 @@ fun FileTransferDialog(
} }
if (showBrowseDialog) { if (showBrowseDialog) {
val initialQuery = resolveDefaultQuery(hostType, hostFile)
HostDirectoryDialog( HostDirectoryDialog(
client = client, client = client,
initialHostType = hostType, initialHostType = hostType,
initialQuery = hostFile, initialQuery = initialQuery,
onDismiss = { showBrowseDialog = false }, onDismiss = { showBrowseDialog = false },
onSelectDataset = { selected -> onSelectEntry = { selectedName, selectedEntry ->
hostFile = selected hostFile = selectedName
if (localFile.isBlank()) { if (selectedEntry != null) {
val safeName = formatLocalFilenameFromHost(selected, hostType) 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 tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName) val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath 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( OutlinedTextField(
value = spaceStr, value = spaceStr,
onValueChange = { spaceStr = it }, onValueChange = { spaceStr = it },
label = { Text("SPACE (Allocation)", fontSize = 11.sp) }, label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
placeholder = { Text("e.g. (1,1) cyl or 5,2") }, 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, singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done), keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth() modifier = Modifier.weight(0.38f)
) )
} }
}
// CMS Options // CMS Options
if (hostType == FTConfig.HostType.CMS) { if (hostType == FTConfig.HostType.CMS) {
@@ -571,7 +641,9 @@ fun FileTransferDialog(
setRecfm(recfm) setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) } lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) } blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim()) if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
} }
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) { if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim()) setOptions(cmsOptions.trim())
@@ -586,7 +658,9 @@ fun FileTransferDialog(
recfm = recfm, recfm = recfm,
lreclStr = lreclStr, lreclStr = lreclStr,
blksizeStr = blksizeStr, blksizeStr = blksizeStr,
units = units,
spaceStr = spaceStr, spaceStr = spaceStr,
dirBlocks = dirBlocks,
cmsOptions = cmsOptions cmsOptions = cmsOptions
) )
onStartTransfer(config) onStartTransfer(config)
@@ -1,5 +1,8 @@
package haus.nightmare.a3270.ui 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
@@ -15,6 +18,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -28,8 +32,139 @@ import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry 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.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry 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 @Composable
fun HostDirectoryDialog( fun HostDirectoryDialog(
@@ -37,71 +172,475 @@ fun HostDirectoryDialog(
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO, initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "", initialQuery: String = "",
onDismiss: () -> Unit, 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 hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(initialQuery) } var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
var entries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) } var allEntries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) } var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") } var statusMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
fun runQuery() { var inspectionEntry by remember { mutableStateOf<TSODirectoryEntry?>(null) }
val c = client var showInspectionDialog by remember { mutableStateOf(false) }
if (c == null || !c.connectionState.isFullSession) {
statusMessage = "Terminal not connected."
return
}
val xfer = c.xfer
if (xfer == null) {
statusMessage = "ECL transfer interface not available."
return
}
val q = query.trim() // Client-side filtering
if (hostType == FTConfig.HostType.CMS) { val currentEntries by remember(allEntries, query, hostType) {
val list = xfer.getCmsDirectory(q) derivedStateOf {
entries = list ?: emptyList() val filter = query.trim()
if (filter.isEmpty()) {
allEntries
} else { } else {
val list = xfer.getTsoDirectory(q) allEntries.filter { matchesFilter(it, filter) }
entries = list ?: emptyList() }
}
}
fun parseTextData(text: String, source: String) {
allEntries = if (hostType == FTConfig.HostType.CMS) {
CMSDirectoryParser.parse(text)
} else {
TSODirectoryParser.parse(text)
} }
selectedIndex = -1 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() { fun parseScreen() {
val c = client val c = client
if (c == null || !c.connectionState.isFullSession) { if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active screen to parse." statusMessage = "No active session to parse."
return return
} }
val xfer = c.xfer val rows = c.ps.rows
if (xfer == null) { val cols = c.ps.cols
statusMessage = "ECL transfer interface not available." 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 return
} }
val ps = c.ps val pattern = query.trim()
val screenText = ps.getString(0, ps.size) val curHostType = hostType
if (hostType == FTConfig.HostType.CMS) { statusMessage = "Querying host ($curHostType)..."
val list = xfer.getCmsDirectory(screenText) isLoading = true
entries = list ?: emptyList()
} else { coroutineScope.launch(Dispatchers.IO) {
val list = xfer.getTsoDirectory(screenText) try {
entries = list ?: emptyList() 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) { LaunchedEffect(Unit) {
if (initialQuery.isNotBlank()) { if (client != null && client.connectionState.isFullSession) {
runQuery() queryHost()
} else { } else {
parseScreen() 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( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -135,13 +674,33 @@ fun HostDirectoryDialog(
Row { Row {
FilterChip( FilterChip(
selected = (hostType == FTConfig.HostType.TSO), 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) } label = { Text("TSO", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = (hostType == FTConfig.HostType.CMS), 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) } label = { Text("CMS", fontSize = 11.sp) }
) )
} }
@@ -155,8 +714,8 @@ fun HostDirectoryDialog(
OutlinedTextField( OutlinedTextField(
value = query, value = query,
onValueChange = { query = it }, onValueChange = { query = it },
label = { Text("Query Pattern") }, label = { Text("Filter / Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* EXEC *") }, placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@@ -164,14 +723,29 @@ fun HostDirectoryDialog(
keyboardType = KeyboardType.Ascii, keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search 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) modifier = Modifier.weight(1f)
) )
Button( 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)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) { ) {
Text("Query") Text(if (isLoading) "Querying..." else "Query Host")
} }
} }
@@ -181,26 +755,36 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( 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, 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)) Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
} }
} }
}
HorizontalDivider(color = Color(0xFF2C2D30)) HorizontalDivider(color = Color(0xFF2C2D30))
// Table View // Table View
if (entries.isEmpty()) { if (currentEntries.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
contentAlignment = Alignment.Center 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 { } else {
val horizScroll = rememberScrollState() 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("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)) Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else { } 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("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("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center) Text("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(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
itemsIndexed(entries) { idx, item -> itemsIndexed(currentEntries) { idx, item ->
val isSelected = (idx == selectedIndex) val isSelected = (idx == selectedIndex)
Surface( Surface(
@@ -257,21 +841,21 @@ fun HostDirectoryDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item is CMSDirectoryEntry) { if (item is CMSDirectoryEntry) {
Text(item.filename, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.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.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.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.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(if (item.lrecl > 0) "${item.lrecl}" else "", 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(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}", color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp)) Text("${item.date ?: ""} ${item.time ?: ""}".trim(), color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) { } else if (item is TSODirectoryEntry) {
Text(item.datasetName, color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(220.dp)) 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.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.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(if (item.lrecl > 0) "${item.lrecl}" else "", 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(if (item.blksize > 0) "${item.blksize}" else "", 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(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", 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(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 // Bottom Action Buttons
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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) { TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray) Text("Cancel", color = Color.LightGray)
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Button( Button(
onClick = { onClick = {
if (selectedIndex in entries.indices) { if (selectedIndex in currentEntries.indices) {
val selectedName = entries[selectedIndex].name val entry = currentEntries[selectedIndex]
val selectedName = entry.name
onSelectEntry?.invoke(selectedName, entry)
onSelectDataset(selectedName) onSelectDataset(selectedName)
onDismiss() onDismiss()
} }
}, },
enabled = selectedIndex in entries.indices, enabled = selectedIndex in currentEntries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) { ) {
Text("Select Dataset") Text("Select Dataset")
@@ -308,3 +907,4 @@ fun HostDirectoryDialog(
} }
} }
} }
}
@@ -52,6 +52,7 @@ fun SettingsDialog(
initialFourColorOverride: Boolean = false, initialFourColorOverride: Boolean = false,
initialNumericFieldLock: Boolean = true, initialNumericFieldLock: Boolean = true,
initialAutoSkipEnabled: Boolean = true, initialAutoSkipEnabled: Boolean = true,
initialDefaultHostType: String = "TSO",
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: ( onSave: (
maskHiddenInput: Boolean, maskHiddenInput: Boolean,
@@ -85,7 +86,8 @@ fun SettingsDialog(
insertOffOnAid: Boolean, insertOffOnAid: Boolean,
fourColorOverride: Boolean, fourColorOverride: Boolean,
numericFieldLock: Boolean, numericFieldLock: Boolean,
autoSkipEnabled: Boolean autoSkipEnabled: Boolean,
defaultHostType: String
) -> Unit ) -> Unit
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@@ -129,6 +131,7 @@ fun SettingsDialog(
var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) } var fourColorOverride by remember { mutableStateOf(initialFourColorOverride) }
var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) } var numericFieldLock by remember { mutableStateOf(initialNumericFieldLock) }
var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) } var autoSkipEnabled by remember { mutableStateOf(initialAutoSkipEnabled) }
var defaultHostType by remember { mutableStateOf(initialDefaultHostType) }
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
@@ -658,6 +661,29 @@ fun SettingsDialog(
Spacer(modifier = Modifier.height(12.dp)) 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 // Verify TLS Certs
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -887,6 +913,15 @@ fun SettingsDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) 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( OutlinedButton(
onClick = { onClick = {
val sw = java.io.StringWriter() val sw = java.io.StringWriter()
@@ -897,13 +932,13 @@ fun SettingsDialog(
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Export to INI", fontSize = 12.sp) Text("Copy", fontSize = 12.sp)
} }
OutlinedButton( OutlinedButton(
onClick = { showImportDialog = true }, onClick = { showImportDialog = true },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Import from INI", fontSize = 12.sp) Text("Import", fontSize = 12.sp)
} }
} }
@@ -957,7 +992,8 @@ fun SettingsDialog(
insertOffOnAid, insertOffOnAid,
fourColorOverride, fourColorOverride,
numericFieldLock, numericFieldLock,
autoSkipEnabled autoSkipEnabled,
defaultHostType
) )
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) { if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
@@ -1033,6 +1069,7 @@ fun SettingsDialog(
fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context) fourColorOverride = haus.nightmare.a3270.storage.AppSettings.getFourColorOverride(context)
numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context) numericFieldLock = haus.nightmare.a3270.storage.AppSettings.getNumericFieldLock(context)
autoSkipEnabled = haus.nightmare.a3270.storage.AppSettings.getAutoSkipEnabled(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() android.widget.Toast.makeText(context, "Settings imported from INI", android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) { } catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()