Bring up to date with Connection exporting and file transfer updates
This commit is contained in:
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -576,7 +578,13 @@ fun MainScreen(
|
||||
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) }
|
||||
@@ -621,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 -> {
|
||||
@@ -782,7 +777,11 @@ fun MainScreen(
|
||||
onClearShift = onClearShift,
|
||||
onConnectClick = {
|
||||
terminalInputViewRef?.hideSoftKeyboard()
|
||||
if (connectionState.isConnected()) {
|
||||
showConnectChoiceDialog = true
|
||||
} else {
|
||||
showConnectDialog = true
|
||||
}
|
||||
},
|
||||
onDisconnectClick = { viewModel.disconnect() },
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
)
|
||||
@@ -985,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)
|
||||
@@ -1020,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)
|
||||
@@ -196,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -308,3 +907,4 @@ fun HostDirectoryDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -129,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) }
|
||||
|
||||
@@ -658,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(),
|
||||
@@ -887,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()
|
||||
@@ -897,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,7 +992,8 @@ fun SettingsDialog(
|
||||
insertOffOnAid,
|
||||
fourColorOverride,
|
||||
numericFieldLock,
|
||||
autoSkipEnabled
|
||||
autoSkipEnabled,
|
||||
defaultHostType
|
||||
)
|
||||
|
||||
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
|
||||
@@ -1033,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()
|
||||
|
||||
Reference in New Issue
Block a user