Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8608babb36
|
|||
|
1f98c0d70f
|
|||
|
3843e0fd13
|
|||
|
4754732642
|
|||
|
79ce0a7dc6
|
|||
|
fe185bd1f0
|
|||
|
ee44759668
|
+2
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "haus.nightmare.a3270"
|
applicationId "haus.nightmare.a3270"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 7
|
versionCode 9
|
||||||
versionName "1.2.2"
|
versionName "1.2.4"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -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="9"
|
||||||
android:versionName="1.2.2">
|
android:versionName="1.2.4">
|
||||||
|
|
||||||
<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
|
||||||
@@ -546,14 +548,43 @@ fun MainScreen(
|
|||||||
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
|
val hotspotsPfEnabled by viewModel.hotspotsPfEnabled.collectAsState()
|
||||||
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
|
val hotspotsMenuChoicesEnabled by viewModel.hotspotsMenuChoicesEnabled.collectAsState()
|
||||||
|
|
||||||
val isNumericField = remember(screenBuffer, cursorAddr, screenVersion) {
|
val isDbcsSession = remember(screenBuffer, screenVersion) {
|
||||||
if (screenBuffer != null && screenBuffer?.isFormatted == true) {
|
screenBuffer?.translator?.isDBCS == true
|
||||||
val fa = screenBuffer?.getFieldAttributeAt(cursorAddr) ?: 0.toByte()
|
}
|
||||||
(fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0
|
|
||||||
} else false
|
val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
|
||||||
|
val buf = screenBuffer
|
||||||
|
if (buf != null && buf.isFormatted) {
|
||||||
|
val cell = buf.getDisplayCell(cursorAddr)
|
||||||
|
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
|
||||||
|
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
|
||||||
|
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
|
||||||
|
if (isDbcs) {
|
||||||
|
"DBCS"
|
||||||
|
} else {
|
||||||
|
val fa = buf.getFieldAttributeAt(cursorAddr)
|
||||||
|
if ((fa.toInt() and haus.nightmare.lib3270j.protocol.DS3270Constants.FA_NUMERIC) != 0) {
|
||||||
|
"NUM"
|
||||||
|
} else {
|
||||||
|
"ALPHA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"ALPHA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isNumericField = remember(fieldStatus) {
|
||||||
|
fieldStatus == "NUM"
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = context as? ComponentActivity
|
||||||
|
val openConnectFromIntent = remember {
|
||||||
|
activity?.intent?.getBooleanExtra("OPEN_CONNECT_DIALOG", false) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var showConnectDialog by remember { mutableStateOf(false) }
|
var 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) }
|
||||||
@@ -598,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 -> {
|
||||||
@@ -717,6 +735,7 @@ fun MainScreen(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
TerminalInputView(ctx).apply {
|
TerminalInputView(ctx).apply {
|
||||||
|
this.isDbcsSession = isDbcsSession
|
||||||
onInputText = { text ->
|
onInputText = { text ->
|
||||||
viewModel.typeString(text)
|
viewModel.typeString(text)
|
||||||
onClearShift()
|
onClearShift()
|
||||||
@@ -738,6 +757,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { inputView ->
|
update = { inputView ->
|
||||||
|
inputView.isDbcsSession = isDbcsSession
|
||||||
terminalInputViewRef = inputView
|
terminalInputViewRef = inputView
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(1.dp)
|
modifier = Modifier.size(1.dp)
|
||||||
@@ -757,7 +777,11 @@ fun MainScreen(
|
|||||||
onClearShift = onClearShift,
|
onClearShift = onClearShift,
|
||||||
onConnectClick = {
|
onConnectClick = {
|
||||||
terminalInputViewRef?.hideSoftKeyboard()
|
terminalInputViewRef?.hideSoftKeyboard()
|
||||||
showConnectDialog = true
|
if (connectionState.isConnected()) {
|
||||||
|
showConnectChoiceDialog = true
|
||||||
|
} else {
|
||||||
|
showConnectDialog = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDisconnectClick = { viewModel.disconnect() },
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
onFtClick = {
|
onFtClick = {
|
||||||
@@ -880,6 +904,7 @@ fun MainScreen(
|
|||||||
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
|
inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0,
|
||||||
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
|
luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "",
|
||||||
isNumericField = isNumericField,
|
isNumericField = isNumericField,
|
||||||
|
fieldStatus = fieldStatus,
|
||||||
uiTheme = uiTheme,
|
uiTheme = uiTheme,
|
||||||
onToggleDocMode = { viewModel.toggleDocMode() },
|
onToggleDocMode = { viewModel.toggleDocMode() },
|
||||||
onToggleWordWrap = { viewModel.toggleWordWrap() },
|
onToggleWordWrap = { viewModel.toggleWordWrap() },
|
||||||
@@ -889,15 +914,65 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showConnectChoiceDialog) {
|
||||||
|
ConnectChoiceDialog(
|
||||||
|
currentTarget = viewModel.getConnectedTarget(),
|
||||||
|
onChoice = { choice ->
|
||||||
|
showConnectChoiceDialog = false
|
||||||
|
when (choice) {
|
||||||
|
ConnectChoice.NEW_WINDOW -> {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||||
|
putExtra("OPEN_CONNECT_DIALOG", true)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
ConnectChoice.ABORT_CURRENT -> {
|
||||||
|
viewModel.disconnect()
|
||||||
|
showConnectDialog = true
|
||||||
|
}
|
||||||
|
ConnectChoice.CANCEL -> {
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showConnectChoiceDialog = false
|
||||||
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showConnectDialog) {
|
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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -959,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)
|
||||||
@@ -994,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
|
||||||
@@ -468,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
val c = client
|
val c = client
|
||||||
if (c != null) {
|
if (c != null) {
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
_cursorAddress.value = c.screenBuffer.displayCursorAddress
|
||||||
|
val snapVersion = c.screenBuffer.snapshotVersion
|
||||||
|
_screenVersion.value = if (snapVersion > 0) snapVersion else System.currentTimeMillis()
|
||||||
|
} else {
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,9 +907,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 +923,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 +972,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)
|
||||||
}
|
}
|
||||||
@@ -1061,16 +1099,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val buf = newClient.screenBuffer
|
val buf = newClient.screenBuffer
|
||||||
_screenBuffer.value = buf
|
_screenBuffer.value = buf
|
||||||
if (buf != null) {
|
if (buf != null) {
|
||||||
_rows.value = buf.rows
|
val snap = buf.displaySnapshot
|
||||||
_cols.value = buf.cols
|
if (snap != null) {
|
||||||
_cursorAddress.value = buf.cursorAddress
|
_rows.value = snap.rows
|
||||||
|
_cols.value = snap.cols
|
||||||
|
_cursorAddress.value = snap.cursorAddress
|
||||||
|
_screenVersion.value = snap.version
|
||||||
|
} else {
|
||||||
|
_rows.value = buf.displayRows
|
||||||
|
_cols.value = buf.displayCols
|
||||||
|
_cursorAddress.value = buf.displayCursorAddress
|
||||||
|
_screenVersion.value = buf.snapshotVersion
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
||||||
_isInsertMode.value = newClient.isInsertMode
|
_isInsertMode.value = newClient.isInsertMode
|
||||||
_isDocMode.value = newClient.isDocMode
|
_isDocMode.value = newClient.isDocMode
|
||||||
_isWordWrap.value = newClient.isWordWrap
|
_isWordWrap.value = newClient.isWordWrap
|
||||||
_isAplMode.value = newClient.isAplMode
|
_isAplMode.value = newClient.isAplMode
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// Drive CUT mode file transfers if active
|
// Drive CUT mode file transfers if active
|
||||||
fileTransferCoordinator?.onScreenUpdated()
|
fileTransferCoordinator?.onScreenUpdated()
|
||||||
@@ -1114,6 +1162,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 {
|
||||||
@@ -1411,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
|
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
|
val snap = buf.displaySnapshot
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until cols) {
|
||||||
val addr = row * cols + c
|
val addr = row * cols + c
|
||||||
val cell = buf.getCell(addr)
|
val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr)
|
||||||
if (cell != null && !cell.isFieldAttribute) {
|
if (cell != null && !cell.isFieldAttribute) {
|
||||||
val ch = cell.ucs4
|
val ch = cell.ucs4
|
||||||
if (ch in '!'..'~' || ch.code > 127) {
|
if (ch in '!'..'~' || ch.code > 127) {
|
||||||
@@ -1431,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
|
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
|
||||||
if (buf == null) return 0
|
if (buf == null) return 0
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
val total = buf.rows * buf.cols
|
val snap = buf.displaySnapshot
|
||||||
|
val total = (snap?.rows ?: buf.displayRows) * (snap?.cols ?: buf.displayCols)
|
||||||
for (i in 0 until total) {
|
for (i in 0 until total) {
|
||||||
val cell = buf.getCell(i)
|
val cell = snap?.getCell(i) ?: buf.getDisplayCell(i)
|
||||||
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||||
sb.append(cell.ucs4)
|
sb.append(cell.ucs4)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -176,6 +229,10 @@ object HostStorage {
|
|||||||
"1149" to "1149 - Iceland (Euro €)",
|
"1149" to "1149 - Iceland (Euro €)",
|
||||||
"930" to "930 - Japanese Katakana Mixed DBCS",
|
"930" to "930 - Japanese Katakana Mixed DBCS",
|
||||||
"939" to "939 - Japanese Latin Mixed DBCS",
|
"939" to "939 - Japanese Latin Mixed DBCS",
|
||||||
|
"290" to "290 - Japanese Katakana SBCS",
|
||||||
|
"1027" to "1027 - Japanese Latin Extended SBCS",
|
||||||
|
"1390" to "1390 - Japanese Katakana DBCS Euro",
|
||||||
|
"1399" to "1399 - Japanese Latin DBCS Euro",
|
||||||
"935" to "935 - Simplified Chinese Mixed DBCS",
|
"935" to "935 - Simplified Chinese Mixed DBCS",
|
||||||
"937" to "937 - Traditional Chinese Mixed DBCS",
|
"937" to "937 - Traditional Chinese Mixed DBCS",
|
||||||
"1388" to "1388 - Simplified Chinese Extended DBCS",
|
"1388" to "1388 - Simplified Chinese Extended DBCS",
|
||||||
@@ -192,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package haus.nightmare.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
||||||
|
enum class ConnectChoice {
|
||||||
|
NEW_WINDOW,
|
||||||
|
ABORT_CURRENT,
|
||||||
|
CANCEL
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectChoiceDialog(
|
||||||
|
currentTarget: String,
|
||||||
|
onChoice: (ConnectChoice) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val targetText = currentTarget.ifBlank { "the host" }
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
onChoice(ConnectChoice.CANCEL)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.95f)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Connection Already Active",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "a3270 is currently connected to $targetText.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Would you like to open the connection in a new window or abort the current connection?",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onChoice(ConnectChoice.CANCEL)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Cancel", color = Color.Gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onChoice(ConnectChoice.ABORT_CURRENT)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
|
||||||
|
) {
|
||||||
|
Text("Abort Current Connection", fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onChoice(ConnectChoice.NEW_WINDOW)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
|
||||||
|
) {
|
||||||
|
Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,12 +55,16 @@ fun ConnectDialog(
|
|||||||
proxyHost: String,
|
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,16 +555,52 @@ fun FileTransferDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Row(
|
||||||
OutlinedTextField(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
value = spaceStr,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
onValueChange = { spaceStr = it },
|
verticalAlignment = Alignment.CenterVertically
|
||||||
label = { Text("SPACE (Allocation)", fontSize = 11.sp) },
|
) {
|
||||||
placeholder = { Text("e.g. (1,1) cyl or 5,2") },
|
Text("Units:", fontSize = 11.sp)
|
||||||
singleLine = true,
|
Row {
|
||||||
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
|
listOf(
|
||||||
modifier = Modifier.fillMaxWidth()
|
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 (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.weight(0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CMS Options
|
// CMS Options
|
||||||
@@ -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 {
|
||||||
|
allEntries.filter { matchesFilter(it, filter) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseTextData(text: String, source: String) {
|
||||||
|
allEntries = if (hostType == FTConfig.HostType.CMS) {
|
||||||
|
CMSDirectoryParser.parse(text)
|
||||||
} else {
|
} else {
|
||||||
val list = xfer.getTsoDirectory(q)
|
TSODirectoryParser.parse(text)
|
||||||
entries = list ?: emptyList()
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
|
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))
|
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,25 +867,41 @@ 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
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onDismiss) {
|
Row {
|
||||||
Text("Cancel", color = Color.LightGray)
|
if (hostType == FTConfig.HostType.TSO) {
|
||||||
}
|
OutlinedButton(
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
onClick = { inspectSelectedDataset() },
|
||||||
Button(
|
enabled = selectedIndex in currentEntries.indices && !isLoading,
|
||||||
onClick = {
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
|
||||||
if (selectedIndex in entries.indices) {
|
) {
|
||||||
val selectedName = entries[selectedIndex].name
|
Text("Inspect (LISTDS)", fontSize = 11.sp)
|
||||||
onSelectDataset(selectedName)
|
|
||||||
onDismiss()
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
enabled = selectedIndex in entries.indices,
|
}
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
|
||||||
) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("Select Dataset")
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (selectedIndex in currentEntries.indices) {
|
||||||
|
val entry = currentEntries[selectedIndex]
|
||||||
|
val selectedName = entry.name
|
||||||
|
onSelectEntry?.invoke(selectedName, entry)
|
||||||
|
onSelectDataset(selectedName)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = selectedIndex in currentEntries.indices,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
|
) {
|
||||||
|
Text("Select Dataset")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ class HotspotEngine {
|
|||||||
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
|
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val ISPF_MENU_PATTERN = Pattern.compile(
|
||||||
|
"(?:^\\s*|\\s{3,})(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)"
|
||||||
|
)
|
||||||
|
|
||||||
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
|
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
|
||||||
val s = start.coerceAtLeast(0)
|
val s = start.coerceAtLeast(0)
|
||||||
val e = end.coerceAtMost(arr.size)
|
val e = end.coerceAtMost(arr.size)
|
||||||
@@ -308,6 +312,33 @@ class HotspotEngine {
|
|||||||
hotspots.add(h)
|
hotspots.add(h)
|
||||||
markOccupied(occupied, startCol, endCol)
|
markOccupied(occupied, startCol, endCol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
|
||||||
|
val mIspf = ISPF_MENU_PATTERN.matcher(line)
|
||||||
|
while (mIspf.find()) {
|
||||||
|
val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue
|
||||||
|
val labelStr = mIspf.group(3) ?: continue
|
||||||
|
val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2)
|
||||||
|
val endCol = mIspf.end(3)
|
||||||
|
if (isRangeOccupied(occupied, startCol, endCol)) continue
|
||||||
|
|
||||||
|
val action = "$choiceStr[enter]"
|
||||||
|
val tooltip = "Select option $choiceStr ($labelStr)"
|
||||||
|
val display = line.substring(startCol, endCol)
|
||||||
|
|
||||||
|
val h = Hotspot(
|
||||||
|
type = HotspotType.MENU_CHOICE,
|
||||||
|
startRow = r,
|
||||||
|
startCol = startCol,
|
||||||
|
endRow = r,
|
||||||
|
endCol = endCol - 1,
|
||||||
|
displayText = display,
|
||||||
|
action = action,
|
||||||
|
tooltip = tooltip
|
||||||
|
)
|
||||||
|
hotspots.add(h)
|
||||||
|
markOccupied(occupied, startCol, endCol)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fun OiaStatusBar(
|
|||||||
inhibitReason: Int = 0,
|
inhibitReason: Int = 0,
|
||||||
luName: String = "",
|
luName: String = "",
|
||||||
isNumericField: Boolean = false,
|
isNumericField: Boolean = false,
|
||||||
|
fieldStatus: String = "",
|
||||||
uiTheme: String = "DARK",
|
uiTheme: String = "DARK",
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -163,16 +164,22 @@ fun OiaStatusBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field Status: NUM vs ALPHA
|
// Field Status: DBCS vs NUM vs ALPHA
|
||||||
if (connectionState.isConnected() && rows > 0 && cols > 0) {
|
if (connectionState.isConnected() && rows > 0 && cols > 0) {
|
||||||
|
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
|
||||||
|
val badgeColor = when (effectiveFieldStatus) {
|
||||||
|
"DBCS" -> sysAvailClr
|
||||||
|
"NUM" -> Color(0xFFFFD43B)
|
||||||
|
else -> Color(0xFF74C0FC)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Surface(
|
Surface(
|
||||||
color = Color(0xFF339AF0).copy(alpha = 0.2f),
|
color = badgeColor.copy(alpha = 0.2f),
|
||||||
shape = RoundedCornerShape(4.dp)
|
shape = RoundedCornerShape(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isNumericField) "NUM" else "ALPHA",
|
text = effectiveFieldStatus,
|
||||||
color = if (isNumericField) Color(0xFFFFD43B) else Color(0xFF74C0FC),
|
color = badgeColor,
|
||||||
fontSize = 9.sp,
|
fontSize = 9.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.core.content.FileProvider
|
|||||||
import haus.nightmare.lib3270j.graphics.GraphicsPlane
|
import haus.nightmare.lib3270j.graphics.GraphicsPlane
|
||||||
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
|
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
|
||||||
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
|
||||||
|
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||||
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -284,6 +285,7 @@ fun ScreenExporterDialog(
|
|||||||
|
|
||||||
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
||||||
if (screenBuffer == null) return ""
|
if (screenBuffer == null) return ""
|
||||||
|
screenBuffer.updateDisplaySnapshot()
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
var currentHidden = false
|
var currentHidden = false
|
||||||
|
|
||||||
@@ -291,11 +293,15 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
|
|||||||
val rowSb = StringBuilder()
|
val rowSb = StringBuilder()
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until cols) {
|
||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
val cell = screenBuffer.getCell(addr)
|
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||||
if (cell.fa.toInt() != 0) {
|
if (cell.fa.toInt() != 0) {
|
||||||
val faVal = cell.fa.toInt() and 0xFF
|
val faVal = cell.fa.toInt() and 0xFF
|
||||||
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||||
rowSb.append(' ')
|
rowSb.append(' ')
|
||||||
|
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||||
|
if (c > 0) continue else rowSb.append(' ')
|
||||||
|
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||||
|
rowSb.append(' ')
|
||||||
} else {
|
} else {
|
||||||
if (currentHidden && maskHidden) {
|
if (currentHidden && maskHidden) {
|
||||||
rowSb.append(' ')
|
rowSb.append(' ')
|
||||||
@@ -312,6 +318,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
|
|||||||
|
|
||||||
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
|
||||||
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
|
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
|
||||||
|
screenBuffer.updateDisplaySnapshot()
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
|
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
|
||||||
sb.append("<meta charset=\"UTF-8\">\n")
|
sb.append("<meta charset=\"UTF-8\">\n")
|
||||||
@@ -338,7 +345,7 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
|
|||||||
for (r in 0 until rows) {
|
for (r in 0 until rows) {
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until cols) {
|
||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
val cell = screenBuffer.getCell(addr)
|
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||||
if (cell.fa.toInt() != 0) {
|
if (cell.fa.toInt() != 0) {
|
||||||
val faVal = cell.fa.toInt() and 0xFF
|
val faVal = cell.fa.toInt() and 0xFF
|
||||||
currentProt = (faVal and FA_PROTECT) != 0
|
currentProt = (faVal and FA_PROTECT) != 0
|
||||||
@@ -346,6 +353,10 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
|
|||||||
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
|
||||||
currentFieldEa = cell
|
currentFieldEa = cell
|
||||||
sb.append(' ')
|
sb.append(' ')
|
||||||
|
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||||
|
if (c > 0) continue else sb.append(' ')
|
||||||
|
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||||
|
sb.append(' ')
|
||||||
} else {
|
} else {
|
||||||
if (currentHidden && maskHidden) {
|
if (currentHidden && maskHidden) {
|
||||||
sb.append(' ')
|
sb.append(' ')
|
||||||
@@ -421,6 +432,7 @@ private fun generateBitmap(
|
|||||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
|
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
|
||||||
|
|
||||||
if (screenBuffer == null) return bitmap
|
if (screenBuffer == null) return bitmap
|
||||||
|
screenBuffer.updateDisplaySnapshot()
|
||||||
|
|
||||||
// Layer 1: Vector graphics plane (under text)
|
// Layer 1: Vector graphics plane (under text)
|
||||||
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
||||||
@@ -448,7 +460,7 @@ private fun generateBitmap(
|
|||||||
for (r in 0 until rows) {
|
for (r in 0 until rows) {
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until cols) {
|
||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
val cell = screenBuffer.getCell(addr)
|
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||||
val left = (c * charWidth).toFloat()
|
val left = (c * charWidth).toFloat()
|
||||||
val top = (r * charHeight).toFloat()
|
val top = (r * charHeight).toFloat()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -111,6 +113,10 @@ fun SettingsDialog(
|
|||||||
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
|
var hotspotsPfEnabled by remember { mutableStateOf(initialHotspotsPfEnabled) }
|
||||||
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
|
var hotspotsMenuChoicesEnabled by remember { mutableStateOf(initialHotspotsMenuChoicesEnabled) }
|
||||||
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
|
var startupBehavior by remember { mutableStateOf(initialStartupBehavior) }
|
||||||
|
val savedHosts = remember { haus.nightmare.a3270.storage.HostStorage.getSavedHosts(context) }
|
||||||
|
var selectedAutoHostId by remember {
|
||||||
|
mutableStateOf(haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)?.id ?: savedHosts.firstOrNull()?.id ?: "")
|
||||||
|
}
|
||||||
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
|
var dynamicRowsStr by remember { mutableStateOf(initialDynamicRows.toString()) }
|
||||||
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
|
var dynamicColsStr by remember { mutableStateOf(initialDynamicCols.toString()) }
|
||||||
|
|
||||||
@@ -125,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) }
|
||||||
|
|
||||||
@@ -434,7 +441,7 @@ fun SettingsDialog(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
|
Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
|
||||||
Text("\"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray)
|
Text("ISPF, \"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray)
|
||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = hotspotsMenuChoicesEnabled,
|
checked = hotspotsMenuChoicesEnabled,
|
||||||
@@ -488,6 +495,47 @@ fun SettingsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true) && savedHosts.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Auto-Connect Target Profile", fontSize = 12.sp, color = Color.LightGray)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
var hostDropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedHost = savedHosts.find { it.id == selectedAutoHostId } ?: savedHosts.first()
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { hostDropdownExpanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${selectedHost.name} (${selectedHost.host}:${selectedHost.port})",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = hostDropdownExpanded,
|
||||||
|
onDismissRequest = { hostDropdownExpanded = false }
|
||||||
|
) {
|
||||||
|
savedHosts.forEach { host ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "${host.name} (${host.host}:${host.port})",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (host.id == selectedAutoHostId) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedAutoHostId = host.id
|
||||||
|
hostDropdownExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -613,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(),
|
||||||
@@ -842,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()
|
||||||
@@ -852,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,8 +992,21 @@ fun SettingsDialog(
|
|||||||
insertOffOnAid,
|
insertOffOnAid,
|
||||||
fourColorOverride,
|
fourColorOverride,
|
||||||
numericFieldLock,
|
numericFieldLock,
|
||||||
autoSkipEnabled
|
autoSkipEnabled,
|
||||||
|
defaultHostType
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (startupBehavior.equals("AUTO_CONNECT", ignoreCase = true)) {
|
||||||
|
val targetHost = savedHosts.find { it.id == selectedAutoHostId }
|
||||||
|
if (targetHost != null) {
|
||||||
|
haus.nightmare.a3270.storage.HostStorage.saveHost(context, targetHost.copy(autoConnect = true))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val autoHost = haus.nightmare.a3270.storage.HostStorage.getAutoConnectHost(context)
|
||||||
|
if (autoHost != null) {
|
||||||
|
haus.nightmare.a3270.storage.HostStorage.saveHost(context, autoHost.copy(autoConnect = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
) {
|
) {
|
||||||
@@ -976,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()
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ class TerminalInputView(context: Context) : View(context) {
|
|||||||
var onBackspace: () -> Unit = {}
|
var onBackspace: () -> Unit = {}
|
||||||
var onTab: () -> Unit = {}
|
var onTab: () -> Unit = {}
|
||||||
var onBackTab: () -> Unit = {}
|
var onBackTab: () -> Unit = {}
|
||||||
|
var isDbcsSession: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
|
imm?.restartInput(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
isFocusable = true
|
isFocusable = true
|
||||||
@@ -97,9 +105,13 @@ class TerminalInputView(context: Context) : View(context) {
|
|||||||
override fun onCheckIsTextEditor(): Boolean = true
|
override fun onCheckIsTextEditor(): Boolean = true
|
||||||
|
|
||||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
|
||||||
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
|
outAttrs.inputType = if (isDbcsSession) {
|
||||||
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
|
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
} else {
|
||||||
|
InputType.TYPE_CLASS_TEXT or
|
||||||
|
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
|
||||||
|
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
|
}
|
||||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
|
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
|
||||||
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||||
EditorInfo.IME_ACTION_NONE
|
EditorInfo.IME_ACTION_NONE
|
||||||
|
|||||||
@@ -205,6 +205,17 @@ fun TerminalView(
|
|||||||
if (h.type == HotspotEngine.HotspotType.URL) {
|
if (h.type == HotspotEngine.HotspotType.URL) {
|
||||||
onOpenUrl?.invoke(h.action)
|
onOpenUrl?.invoke(h.action)
|
||||||
handledHotspot = true
|
handledHotspot = true
|
||||||
|
} else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) {
|
||||||
|
// In 3270 / ISPF, clicking/tapping a menu choice hotspot positions the cursor
|
||||||
|
// on a protected screen label. Typing directly into a protected cell triggers
|
||||||
|
// INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via
|
||||||
|
// the command/option line (first unprotected field reachable via [home]).
|
||||||
|
var action = h.action
|
||||||
|
if (!action.startsWith("[home]")) {
|
||||||
|
action = "[home][eraseeof]$action"
|
||||||
|
}
|
||||||
|
onSendHotspotAction?.invoke(action)
|
||||||
|
handledHotspot = true
|
||||||
} else {
|
} else {
|
||||||
onSendHotspotAction?.invoke(h.action)
|
onSendHotspotAction?.invoke(h.action)
|
||||||
handledHotspot = true
|
handledHotspot = true
|
||||||
@@ -219,10 +230,25 @@ fun TerminalView(
|
|||||||
if (buf != null && buf.isFormatted) {
|
if (buf != null && buf.isFormatted) {
|
||||||
val faPos = buf.findFieldAttribute(clickAddr)
|
val faPos = buf.findFieldAttribute(clickAddr)
|
||||||
if (faPos >= 0) {
|
if (faPos >= 0) {
|
||||||
val fa = buf.getCell(faPos).fa.toInt() and 0xFF
|
val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
|
||||||
if (faIsSelectable(fa) && !faIsZero(fa)) {
|
if (faIsSelectable(fa) && !faIsZero(fa)) {
|
||||||
onLightPenSelect?.invoke(clickAddr)
|
val size = buf.displayRows * buf.displayCols
|
||||||
isDetectable = true
|
val desPos = if (size > 0) (faPos + 1) % size else -1
|
||||||
|
val desCell = if (desPos >= 0) buf.getDisplayCell(desPos) else null
|
||||||
|
val desAscii = desCell?.ucs4 ?: 0.toChar()
|
||||||
|
val desEbcdic = desCell?.ec?.toInt()?.and(0xFF) ?: 0
|
||||||
|
|
||||||
|
val isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
|
||||||
|
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E')
|
||||||
|
val isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06')
|
||||||
|
|
||||||
|
// Automatic tap selection applies only to toggle (?/>) and enter (&) fields.
|
||||||
|
// Standard text fields (space/null designators) must not trigger light-pen selection
|
||||||
|
// so normal cursor positioning and editing work as expected.
|
||||||
|
if (isToggleField || isEnterField) {
|
||||||
|
onLightPenSelect?.invoke(clickAddr)
|
||||||
|
isDetectable = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +258,7 @@ fun TerminalView(
|
|||||||
var addr = clickAddr
|
var addr = clickAddr
|
||||||
if (buf != null && buf.isFormatted) {
|
if (buf != null && buf.isFormatted) {
|
||||||
val faVal = buf.getFieldAttributeAt(addr)
|
val faVal = buf.getFieldAttributeAt(addr)
|
||||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
|
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
|
||||||
addr = buf.findNextUnprotected(addr)
|
addr = buf.findNextUnprotected(addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,10 +273,15 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val version = currentScreenVersion
|
val version = currentScreenVersion
|
||||||
val activeCursorAddr = currentCursorAddr
|
|
||||||
if (version == -1L) return@Canvas
|
if (version == -1L) return@Canvas
|
||||||
|
|
||||||
val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
|
val buf = screenBuffer
|
||||||
|
val snapshot = buf?.displaySnapshot
|
||||||
|
val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
|
||||||
|
val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
|
||||||
|
val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
|
||||||
|
|
||||||
|
val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
|
||||||
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
|
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
|
||||||
|
|
||||||
val cellWidth = metrics.cellWidth
|
val cellWidth = metrics.cellWidth
|
||||||
@@ -272,8 +303,7 @@ fun TerminalView(
|
|||||||
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
|
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
|
||||||
}
|
}
|
||||||
|
|
||||||
val buf = screenBuffer
|
val totalCells = snapRows * snapCols
|
||||||
val totalCells = rows * cols
|
|
||||||
|
|
||||||
var currentFA: Byte = 0
|
var currentFA: Byte = 0
|
||||||
var currentFieldEa: ExtendedAttribute? = null
|
var currentFieldEa: ExtendedAttribute? = null
|
||||||
@@ -287,10 +317,10 @@ fun TerminalView(
|
|||||||
val start = selectionStart
|
val start = selectionStart
|
||||||
val end = selectionEnd
|
val end = selectionEnd
|
||||||
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
|
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
|
||||||
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||||
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||||
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||||
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||||
|
|
||||||
selMinRow = minOf(startRow, endRow)
|
selMinRow = minOf(startRow, endRow)
|
||||||
selMaxRow = maxOf(startRow, endRow)
|
selMaxRow = maxOf(startRow, endRow)
|
||||||
@@ -317,11 +347,11 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw Crosshair Ruler if enabled
|
// 2. Draw Crosshair Ruler if enabled
|
||||||
if (crosshairRuler && cols > 0 && rows > 0) {
|
if (crosshairRuler && snapCols > 0 && snapRows > 0) {
|
||||||
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
|
val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
|
||||||
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
|
val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
|
||||||
val gridW = cols * cellWidth
|
val gridW = snapCols * cellWidth
|
||||||
val gridH = rows * cellHeight
|
val gridH = snapRows * cellHeight
|
||||||
val cx = offsetX + curCol * cellWidth
|
val cx = offsetX + curCol * cellWidth
|
||||||
val cy = offsetY + curRow * cellHeight
|
val cy = offsetY + curRow * cellHeight
|
||||||
|
|
||||||
@@ -337,9 +367,9 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (r in 0 until rows) {
|
for (r in 0 until snapRows) {
|
||||||
for (c in 0 until cols) {
|
for (c in 0 until snapCols) {
|
||||||
val addr = r * cols + c
|
val addr = r * snapCols + c
|
||||||
if (addr >= totalCells) break
|
if (addr >= totalCells) break
|
||||||
|
|
||||||
val left = offsetX + c * cellWidth
|
val left = offsetX + c * cellWidth
|
||||||
@@ -355,9 +385,9 @@ fun TerminalView(
|
|||||||
var csVal = 0
|
var csVal = 0
|
||||||
var ecVal = 0
|
var ecVal = 0
|
||||||
|
|
||||||
if (buf != null) {
|
val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
|
||||||
val ea = buf.getCell(addr)
|
|
||||||
|
|
||||||
|
if (ea != null) {
|
||||||
if (ea.isFieldAttribute) {
|
if (ea.isFieldAttribute) {
|
||||||
currentFA = ea.fa
|
currentFA = ea.fa
|
||||||
currentFieldEa = ea
|
currentFieldEa = ea
|
||||||
@@ -411,7 +441,10 @@ fun TerminalView(
|
|||||||
bgColor = tmp
|
bgColor = tmp
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bgColor != COLOR_BLACK && bgColor != Color.Transparent) {
|
val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
|
||||||
|
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
|
||||||
|
|
||||||
|
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
|
||||||
drawRect(
|
drawRect(
|
||||||
color = bgColor,
|
color = bgColor,
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(left, top),
|
||||||
@@ -449,19 +482,22 @@ fun TerminalView(
|
|||||||
|
|
||||||
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
|
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
|
||||||
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
||||||
|
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
|
||||||
|
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
|
||||||
|
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
|
||||||
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
|
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
|
||||||
if (isUnderlineCursor) {
|
if (isUnderlineCursor) {
|
||||||
val ulH = maxOf(4f, cellHeight / 4f)
|
val ulH = maxOf(4f, cellHeight / 4f)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(255, 255, 255, 180),
|
color = Color(255, 255, 255, 180),
|
||||||
topLeft = Offset(left, top + cellHeight - ulH),
|
topLeft = Offset(curLeft, top + cellHeight - ulH),
|
||||||
size = Size(cellWidth, ulH)
|
size = Size(curWidth, ulH)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(255, 255, 255, 180),
|
color = Color(255, 255, 255, 180),
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(curLeft, top),
|
||||||
size = Size(cellWidth, cellHeight)
|
size = Size(curWidth, cellHeight)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,12 +523,14 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val suppressText = isBlink && !textBlinkVisible
|
val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
|
||||||
|
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
|
||||||
|
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
|
||||||
if (!drawnAsPs && charVal != ' ' && !suppressText) {
|
if (!drawnAsPs && charVal != ' ' && !suppressText) {
|
||||||
paint.color = fgColor.toArgb()
|
paint.color = fgColor.toArgb()
|
||||||
paint.isFakeBoldText = isBold
|
paint.isFakeBoldText = isBold
|
||||||
val fontMetrics = paint.fontMetrics
|
val fontMetrics = paint.fontMetrics
|
||||||
val textX = left + cellWidth / 2f
|
val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
|
||||||
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
|
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
@@ -513,7 +551,7 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Field Outlining (0xC2 / TY_FOUTLINE)
|
// Field Outlining (0xC2 / TY_FOUTLINE)
|
||||||
val cellEa = if (buf != null) buf.getCell(addr) else null
|
val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
|
||||||
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
|
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
|
||||||
val fEa = currentFieldEa
|
val fEa = currentFieldEa
|
||||||
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
|
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
|
||||||
@@ -528,11 +566,13 @@ fun TerminalView(
|
|||||||
|
|
||||||
val size = totalCells
|
val size = totalCells
|
||||||
val prevAddr = (addr - 1 + size) % size
|
val prevAddr = (addr - 1 + size) % size
|
||||||
if (buf != null && (buf.getDisplayCell(prevAddr)?.isFieldAttribute == true || c == 0)) {
|
val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
|
||||||
|
if (prevCell?.isFieldAttribute == true || c == 0) {
|
||||||
if ((fol and olLeft) != 0) outline = outline or olLeft
|
if ((fol and olLeft) != 0) outline = outline or olLeft
|
||||||
}
|
}
|
||||||
val nextAddr = (addr + 1) % size
|
val nextAddr = (addr + 1) % size
|
||||||
if (buf != null && (buf.getDisplayCell(nextAddr)?.isFieldAttribute == true || c == cols - 1)) {
|
val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
|
||||||
|
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
|
||||||
if ((fol and olRight) != 0) outline = outline or olRight
|
if ((fol and olRight) != 0) outline = outline or olRight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,8 +880,8 @@ fun calculateGridMetrics(
|
|||||||
|
|
||||||
val gridW = cols * cellW
|
val gridW = cols * cellW
|
||||||
val gridH = rows * cellH
|
val gridH = rows * cellH
|
||||||
val offX = (viewWidth - gridW) / 2f
|
val offX = maxOf(0f, (viewWidth - gridW) / 2f)
|
||||||
val offY = (viewHeight - gridH) / 2f
|
val offY = maxOf(0f, (viewHeight - gridH) / 2f)
|
||||||
|
|
||||||
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
|
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
|
||||||
}
|
}
|
||||||
@@ -881,9 +921,22 @@ private fun copySelection(
|
|||||||
for (c in colStart..colEnd) {
|
for (c in colStart..colEnd) {
|
||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
if (addr < (rows * cols)) {
|
if (addr < (rows * cols)) {
|
||||||
val cell = screenBuffer.getCell(addr)
|
val cell = screenBuffer.getDisplayCell(addr)
|
||||||
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
if (cell != null && !cell.isFieldAttribute) {
|
||||||
line.append(cell.ucs4)
|
if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||||
|
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
|
||||||
|
if (isSubsequent) {
|
||||||
|
continue // Character already added by DB_LEFT
|
||||||
|
} else {
|
||||||
|
line.append(' ')
|
||||||
|
}
|
||||||
|
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
|
||||||
|
line.append(' ')
|
||||||
|
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||||
|
line.append(cell.ucs4)
|
||||||
|
} else {
|
||||||
|
line.append(' ')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
line.append(' ')
|
line.append(' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="exports" path="exports/" />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
|
import haus.nightmare.a3270.storage.AppSettings
|
||||||
|
import haus.nightmare.a3270.storage.HostStorage
|
||||||
|
import haus.nightmare.a3270.storage.SavedHost
|
||||||
|
import haus.nightmare.a3270.ui.ConnectChoice
|
||||||
|
import haus.nightmare.a3270.ui.LastTransferState
|
||||||
|
import haus.nightmare.a3270.ui.formatCMSFilter
|
||||||
|
import haus.nightmare.a3270.ui.matchesCmsFilemode
|
||||||
|
import haus.nightmare.a3270.ui.matchesCmsFilter
|
||||||
|
import haus.nightmare.a3270.ui.matchesFilter
|
||||||
|
import haus.nightmare.a3270.ui.matchesTsoFilter
|
||||||
|
import haus.nightmare.a3270.ui.matchesWildcard
|
||||||
|
import haus.nightmare.a3270.ui.resolveDefaultQuery
|
||||||
|
import haus.nightmare.lib3270j.ConnectionConfig
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel
|
||||||
|
import haus.nightmare.lib3270j.ft.FTConfig
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
|
||||||
|
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
|
||||||
|
import haus.nightmare.lib3270j.graphics.GraphicsMode
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.StringReader
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class ConnectionAndHostDirectoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSavedHostToConnectionConfigPreservesMetadata() {
|
||||||
|
val hostId = UUID.randomUUID().toString()
|
||||||
|
val host = SavedHost(
|
||||||
|
id = hostId,
|
||||||
|
name = "MVS Production LPAR",
|
||||||
|
host = "mvs.prod.corp",
|
||||||
|
port = 992,
|
||||||
|
model = 4,
|
||||||
|
dynamicRows = 62,
|
||||||
|
dynamicCols = 160,
|
||||||
|
luName = "LU3270A",
|
||||||
|
autoConnect = false,
|
||||||
|
hostType = "TSO",
|
||||||
|
useTls = true,
|
||||||
|
tlsVerifyCert = true,
|
||||||
|
tn3270e = true,
|
||||||
|
graphicsMode = "VECTOR_GRAPHICS",
|
||||||
|
codePage = "1047",
|
||||||
|
proxyType = "SOCKS5",
|
||||||
|
proxyHost = "proxy.corp",
|
||||||
|
proxyPort = 1080,
|
||||||
|
proxyUsername = "mvsadmin",
|
||||||
|
proxyPassword = "mypassword"
|
||||||
|
)
|
||||||
|
|
||||||
|
val config = host.toConnectionConfig()
|
||||||
|
|
||||||
|
assertEquals("mvs.prod.corp", config.host)
|
||||||
|
assertEquals(992, config.port)
|
||||||
|
assertEquals(TerminalModel.IBM_3279_4, config.model)
|
||||||
|
assertEquals("LU3270A", config.luName)
|
||||||
|
assertTrue(config.isUseTls)
|
||||||
|
assertTrue(config.isTlsVerifyCert)
|
||||||
|
assertTrue(config.isTn3270eEnabled)
|
||||||
|
assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode)
|
||||||
|
assertEquals("1047", config.codePage)
|
||||||
|
assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType)
|
||||||
|
assertEquals("proxy.corp", config.proxyHost)
|
||||||
|
assertEquals(1080, config.proxyPort)
|
||||||
|
assertEquals("mvsadmin", config.proxyUsername)
|
||||||
|
assertEquals("mypassword", config.proxyPassword)
|
||||||
|
|
||||||
|
// Metadata preservation added in connection hardening
|
||||||
|
assertTrue("savedConnection flag must be true", config.isSavedConnection)
|
||||||
|
assertEquals(hostId, config.savedProfileId)
|
||||||
|
assertEquals("MVS Production LPAR", config.savedProfileName)
|
||||||
|
assertEquals("TSO", config.hostType)
|
||||||
|
assertEquals(FTConfig.HostType.TSO, config.ftHostType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSavedHostToConnectionConfigCmsHostType() {
|
||||||
|
val host = SavedHost(
|
||||||
|
id = "cms-1",
|
||||||
|
name = "VM/CMS Guest",
|
||||||
|
host = "vm.local",
|
||||||
|
port = 23,
|
||||||
|
hostType = "CMS"
|
||||||
|
)
|
||||||
|
val config = host.toConnectionConfig()
|
||||||
|
assertEquals("CMS", config.hostType)
|
||||||
|
assertEquals(FTConfig.HostType.CMS, config.ftHostType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testResolveDefaultQuery() {
|
||||||
|
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null))
|
||||||
|
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, ""))
|
||||||
|
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " "))
|
||||||
|
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*"))
|
||||||
|
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"))
|
||||||
|
assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A"))
|
||||||
|
|
||||||
|
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null))
|
||||||
|
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, ""))
|
||||||
|
assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatCMSFilter() {
|
||||||
|
assertEquals("* * A", formatCMSFilter(null))
|
||||||
|
assertEquals("* * A", formatCMSFilter(""))
|
||||||
|
assertEquals("* * A", formatCMSFilter(" "))
|
||||||
|
assertEquals("* * A", formatCMSFilter("*"))
|
||||||
|
assertEquals("* * A", formatCMSFilter("* * *"))
|
||||||
|
assertEquals("MYFILE * A", formatCMSFilter("MYFILE"))
|
||||||
|
assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC"))
|
||||||
|
assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B"))
|
||||||
|
assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC"))
|
||||||
|
assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMatchesWildcard() {
|
||||||
|
assertTrue(matchesWildcard("HELLO", "*"))
|
||||||
|
assertTrue(matchesWildcard("HELLO", "H*"))
|
||||||
|
assertTrue(matchesWildcard("HELLO", "H*O"))
|
||||||
|
assertTrue(matchesWildcard("HELLO", "H?LLO"))
|
||||||
|
assertTrue(matchesWildcard("HELLO", "?????"))
|
||||||
|
assertFalse(matchesWildcard("HELLO", "H?L"))
|
||||||
|
assertFalse(matchesWildcard("HELLO", "WORLD"))
|
||||||
|
|
||||||
|
assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME"))
|
||||||
|
assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*"))
|
||||||
|
assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMatchesCmsFilterAndFilemode() {
|
||||||
|
val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply {
|
||||||
|
recfm = "F"
|
||||||
|
lrecl = 80
|
||||||
|
blksize = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(matchesFilter(entry, null))
|
||||||
|
assertTrue(matchesFilter(entry, ""))
|
||||||
|
assertTrue(matchesFilter(entry, "* * *"))
|
||||||
|
assertTrue(matchesFilter(entry, "* * A"))
|
||||||
|
assertTrue(matchesFilter(entry, "PROFILE * A"))
|
||||||
|
assertTrue(matchesFilter(entry, "PROFILE EXEC *"))
|
||||||
|
assertTrue(matchesFilter(entry, "PROFILE EXEC A1"))
|
||||||
|
assertTrue(matchesFilter(entry, "PROFILE.EXEC"))
|
||||||
|
assertTrue(matchesFilter(entry, "PROFILE"))
|
||||||
|
assertTrue(matchesFilter(entry, "EXEC"))
|
||||||
|
|
||||||
|
assertFalse(matchesFilter(entry, "* * B"))
|
||||||
|
assertFalse(matchesFilter(entry, "TEST * *"))
|
||||||
|
assertFalse(matchesFilter(entry, "*.COBOL"))
|
||||||
|
assertFalse(matchesFilter(entry, "PROFILE EXEC A2"))
|
||||||
|
|
||||||
|
assertTrue(matchesCmsFilemode("A1", "A"))
|
||||||
|
assertTrue(matchesCmsFilemode("A1", "A1"))
|
||||||
|
assertTrue(matchesCmsFilemode("A1", "*"))
|
||||||
|
assertFalse(matchesCmsFilemode("A1", "B"))
|
||||||
|
assertFalse(matchesCmsFilemode("A1", "A2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMatchesTsoFilter() {
|
||||||
|
val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply {
|
||||||
|
volume = "VOL001"
|
||||||
|
dsorg = "PO"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(matchesFilter(entry, null))
|
||||||
|
assertTrue(matchesFilter(entry, ""))
|
||||||
|
assertTrue(matchesFilter(entry, "*"))
|
||||||
|
assertTrue(matchesFilter(entry, "*SOURCE*"))
|
||||||
|
assertTrue(matchesFilter(entry, "USER.*.COBOL"))
|
||||||
|
assertTrue(matchesFilter(entry, "COBOL")) // substring search
|
||||||
|
assertTrue(matchesFilter(entry, "USER.SOURCE"))
|
||||||
|
|
||||||
|
assertFalse(matchesFilter(entry, "SYS1.*"))
|
||||||
|
assertFalse(matchesFilter(entry, "*.PLI"))
|
||||||
|
assertFalse(matchesFilter(entry, "FORTRAN"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAppSettingsIniHostTypeParsing() {
|
||||||
|
val testIni = """
|
||||||
|
[Connection]
|
||||||
|
hostType = CMS
|
||||||
|
|
||||||
|
[General]
|
||||||
|
defaultHostType = CICS
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
var parsedHostType: String? = null
|
||||||
|
AppSettings.parseIni(StringReader(testIni)) { _, key, value ->
|
||||||
|
if (key.equals("hostType", ignoreCase = true) ||
|
||||||
|
key.equals("hosttype", ignoreCase = true) ||
|
||||||
|
key.equals("host_type", ignoreCase = true) ||
|
||||||
|
key.equals("defaulthosttype", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
parsedHostType = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(parsedHostType)
|
||||||
|
// First or second value parsed
|
||||||
|
assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLastTransferStateUnitsAndDirBlocks() {
|
||||||
|
val state = LastTransferState()
|
||||||
|
assertEquals(FTConfig.HostType.TSO, state.hostType)
|
||||||
|
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
|
||||||
|
assertEquals("", state.dirBlocks)
|
||||||
|
|
||||||
|
state.units = FTConfig.AllocationUnit.TRACKS
|
||||||
|
state.dirBlocks = "20"
|
||||||
|
state.hostType = FTConfig.HostType.CMS
|
||||||
|
|
||||||
|
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
|
||||||
|
assertEquals("20", state.dirBlocks)
|
||||||
|
assertEquals(FTConfig.HostType.CMS, state.hostType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testConnectChoiceEnum() {
|
||||||
|
val choices = ConnectChoice.values()
|
||||||
|
assertEquals(3, choices.size)
|
||||||
|
assertTrue(choices.contains(ConnectChoice.NEW_WINDOW))
|
||||||
|
assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT))
|
||||||
|
assertTrue(choices.contains(ConnectChoice.CANCEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHostStorageExportAndLoadIniRoundtrip() {
|
||||||
|
val host1 = SavedHost(
|
||||||
|
id = "test-h1",
|
||||||
|
name = "Test Mainframe MVS",
|
||||||
|
host = "mvs.corp.local",
|
||||||
|
port = 992,
|
||||||
|
model = 4,
|
||||||
|
dynamicRows = 62,
|
||||||
|
dynamicCols = 160,
|
||||||
|
luName = "LU01",
|
||||||
|
autoConnect = true,
|
||||||
|
hostType = "TSO",
|
||||||
|
useTls = true,
|
||||||
|
tlsVerifyCert = false,
|
||||||
|
tn3270e = true,
|
||||||
|
graphicsMode = "VECTOR_GRAPHICS",
|
||||||
|
codePage = "1047",
|
||||||
|
proxyType = "SOCKS5",
|
||||||
|
proxyHost = "proxy.corp",
|
||||||
|
proxyPort = 1080,
|
||||||
|
proxyUsername = "admin",
|
||||||
|
proxyPassword = "secret"
|
||||||
|
)
|
||||||
|
val host2 = SavedHost(
|
||||||
|
id = "test-h2",
|
||||||
|
name = "VM CMS Guest",
|
||||||
|
host = "vm.corp.local",
|
||||||
|
port = 23,
|
||||||
|
hostType = "CMS"
|
||||||
|
)
|
||||||
|
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
HostStorage.exportToIni(listOf(host1, host2), sw)
|
||||||
|
val exportedIni = sw.toString()
|
||||||
|
|
||||||
|
assertTrue(exportedIni.contains("[Test Mainframe MVS]"))
|
||||||
|
assertTrue(exportedIni.contains("host = mvs.corp.local"))
|
||||||
|
assertTrue(exportedIni.contains("port = 992"))
|
||||||
|
assertTrue(exportedIni.contains("autoConnect = true"))
|
||||||
|
assertTrue(exportedIni.contains("hostType = TSO"))
|
||||||
|
assertTrue(exportedIni.contains("useTls = true"))
|
||||||
|
assertTrue(exportedIni.contains("[VM CMS Guest]"))
|
||||||
|
assertTrue(exportedIni.contains("hostType = CMS"))
|
||||||
|
|
||||||
|
val loaded = HostStorage.loadFromIni(StringReader(exportedIni))
|
||||||
|
assertEquals(2, loaded.size)
|
||||||
|
|
||||||
|
val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" }
|
||||||
|
assertNotNull(loadedH1)
|
||||||
|
assertEquals("test-h1", loadedH1!!.id)
|
||||||
|
assertEquals("mvs.corp.local", loadedH1.host)
|
||||||
|
assertEquals(992, loadedH1.port)
|
||||||
|
assertEquals(4, loadedH1.model)
|
||||||
|
assertEquals(62, loadedH1.dynamicRows)
|
||||||
|
assertEquals(160, loadedH1.dynamicCols)
|
||||||
|
assertEquals("LU01", loadedH1.luName)
|
||||||
|
assertTrue(loadedH1.autoConnect)
|
||||||
|
assertEquals("TSO", loadedH1.hostType)
|
||||||
|
assertTrue(loadedH1.useTls)
|
||||||
|
assertFalse(loadedH1.tlsVerifyCert)
|
||||||
|
assertTrue(loadedH1.tn3270e)
|
||||||
|
assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode)
|
||||||
|
assertEquals("1047", loadedH1.codePage)
|
||||||
|
assertEquals("SOCKS5", loadedH1.proxyType)
|
||||||
|
assertEquals("proxy.corp", loadedH1.proxyHost)
|
||||||
|
assertEquals(1080, loadedH1.proxyPort)
|
||||||
|
assertEquals("admin", loadedH1.proxyUsername)
|
||||||
|
assertEquals("secret", loadedH1.proxyPassword)
|
||||||
|
|
||||||
|
val loadedH2 = loaded.find { it.name == "VM CMS Guest" }
|
||||||
|
assertNotNull(loadedH2)
|
||||||
|
assertEquals("test-h2", loadedH2!!.id)
|
||||||
|
assertEquals("vm.corp.local", loadedH2.host)
|
||||||
|
assertEquals(23, loadedH2.port)
|
||||||
|
assertEquals("CMS", loadedH2.hostType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
|
import haus.nightmare.a3270.storage.HostStorage
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel
|
||||||
|
import haus.nightmare.lib3270j.charset.CodePageRegistry
|
||||||
|
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||||
|
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||||
|
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
class DBCSAndParityCompatibilityTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNewJapaneseCodePagesAvailableAndResolvable() {
|
||||||
|
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
|
||||||
|
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
|
||||||
|
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
|
||||||
|
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
|
||||||
|
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
|
||||||
|
|
||||||
|
val cp290 = CodePageRegistry.getCodePage("290")
|
||||||
|
assertNotNull("Cp290 must resolve", cp290)
|
||||||
|
assertFalse("Cp290 is SBCS", cp290.isDBCS)
|
||||||
|
|
||||||
|
val cp1027 = CodePageRegistry.getCodePage("1027")
|
||||||
|
assertNotNull("Cp1027 must resolve", cp1027)
|
||||||
|
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
|
||||||
|
|
||||||
|
val cp1390 = CodePageRegistry.getCodePage("1390")
|
||||||
|
assertNotNull("Cp1390 must resolve", cp1390)
|
||||||
|
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
|
||||||
|
|
||||||
|
val cp1399 = CodePageRegistry.getCodePage("1399")
|
||||||
|
assertNotNull("Cp1399 must resolve", cp1399)
|
||||||
|
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
|
||||||
|
|
||||||
|
val trans1390 = EbcdicTranslator("1390")
|
||||||
|
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testScreenExporterDBCSNoDuplication() {
|
||||||
|
val trans = EbcdicTranslator("930")
|
||||||
|
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
|
||||||
|
screen.erase(false)
|
||||||
|
|
||||||
|
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
|
||||||
|
val cell0 = screen.getCell(0)
|
||||||
|
cell0.db = ExtendedAttribute.DB_LEFT
|
||||||
|
cell0.ucs4 = '\u6771'
|
||||||
|
|
||||||
|
val cell1 = screen.getCell(1)
|
||||||
|
cell1.db = ExtendedAttribute.DB_RIGHT
|
||||||
|
cell1.ucs4 = '\u6771'
|
||||||
|
|
||||||
|
// Put SBCS 'X' at cell 2
|
||||||
|
val cell2 = screen.getCell(2)
|
||||||
|
cell2.db = ExtendedAttribute.DB_NONE
|
||||||
|
cell2.ucs4 = 'X'
|
||||||
|
|
||||||
|
// Invoke private generatePlainText via reflection
|
||||||
|
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
|
||||||
|
val method: Method = exporterClass.getDeclaredMethod(
|
||||||
|
"generatePlainText",
|
||||||
|
ScreenBuffer::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Boolean::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
method.isAccessible = true
|
||||||
|
|
||||||
|
val plainText = method.invoke(null, screen, 1, 80, true) as String
|
||||||
|
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
|
||||||
|
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTerminalGridMetricsCenteringClampedAtZero() {
|
||||||
|
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
|
||||||
|
val method = terminalViewClass.getDeclaredMethod(
|
||||||
|
"calculateGridMetrics",
|
||||||
|
Float::class.javaPrimitiveType,
|
||||||
|
Float::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType
|
||||||
|
)
|
||||||
|
method.isAccessible = true
|
||||||
|
|
||||||
|
// View is constrained / non-negative offsets
|
||||||
|
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
|
||||||
|
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
|
||||||
|
offXField.isAccessible = true
|
||||||
|
val offX = offXField.getFloat(constrainedMetrics)
|
||||||
|
|
||||||
|
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
|
||||||
|
offYField.isAccessible = true
|
||||||
|
val offY = offYField.getFloat(constrainedMetrics)
|
||||||
|
|
||||||
|
assertTrue("Constrained offX must be non-negative", offX >= 0f)
|
||||||
|
assertTrue("Constrained offY must be non-negative", offY >= 0f)
|
||||||
|
|
||||||
|
// Wide view (pillarbox with positive offX)
|
||||||
|
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
|
||||||
|
val wideOffX = offXField.getFloat(wideMetrics)
|
||||||
|
val wideOffY = offYField.getFloat(wideMetrics)
|
||||||
|
assertTrue("Wide view offX should be positive", wideOffX > 0f)
|
||||||
|
assertEquals(0f, wideOffY, 0.001f)
|
||||||
|
|
||||||
|
// Tall view (letterbox with positive offY)
|
||||||
|
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
|
||||||
|
val tallOffX = offXField.getFloat(tallMetrics)
|
||||||
|
val tallOffY = offYField.getFloat(tallMetrics)
|
||||||
|
assertEquals(0f, tallOffX, 0.001f)
|
||||||
|
assertTrue("Tall view offY should be positive", tallOffY > 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testExtendedAttributeTransparencyConstants() {
|
||||||
|
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
|
||||||
|
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
|
||||||
|
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package haus.nightmare.a3270
|
||||||
|
|
||||||
|
import haus.nightmare.lib3270j.TerminalModel
|
||||||
|
import haus.nightmare.lib3270j.charset.EbcdicTranslator
|
||||||
|
import haus.nightmare.lib3270j.screen.ExtendedAttribute
|
||||||
|
import haus.nightmare.lib3270j.screen.ScreenBuffer
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class DisplaySnapshotIntegrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSnapshotCaptureAndLockFreeReads() {
|
||||||
|
val translator = EbcdicTranslator()
|
||||||
|
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||||
|
|
||||||
|
sb.erase(false)
|
||||||
|
val initialVersion = sb.snapshotVersion
|
||||||
|
|
||||||
|
sb.getCell(0).ucs4 = 'H'
|
||||||
|
sb.getCell(1).ucs4 = 'E'
|
||||||
|
sb.getCell(2).ucs4 = 'L'
|
||||||
|
sb.getCell(3).ucs4 = 'L'
|
||||||
|
sb.getCell(4).ucs4 = 'O'
|
||||||
|
sb.setCursorAddress(42)
|
||||||
|
sb.updateDisplaySnapshot()
|
||||||
|
|
||||||
|
val snap = sb.displaySnapshot
|
||||||
|
assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
|
||||||
|
assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
|
||||||
|
assertEquals(24, snap.rows)
|
||||||
|
assertEquals(80, snap.cols)
|
||||||
|
assertEquals(42, snap.cursorAddress)
|
||||||
|
assertEquals('H', snap.getCell(0)?.ucs4)
|
||||||
|
assertEquals('E', snap.getCell(1)?.ucs4)
|
||||||
|
assertEquals('L', snap.getCell(2)?.ucs4)
|
||||||
|
assertEquals('L', snap.getCell(3)?.ucs4)
|
||||||
|
assertEquals('O', snap.getCell(4)?.ucs4)
|
||||||
|
|
||||||
|
// getDisplayCell fallback
|
||||||
|
assertEquals('H', sb.getDisplayCell(0)?.ucs4)
|
||||||
|
assertEquals(42, sb.displayCursorAddress)
|
||||||
|
assertEquals(24, sb.displayRows)
|
||||||
|
assertEquals(80, sb.displayCols)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTearFreeRenderingUnderConcurrentBufferMutation() {
|
||||||
|
val translator = EbcdicTranslator()
|
||||||
|
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
|
||||||
|
sb.erase(false)
|
||||||
|
|
||||||
|
val size = 24 * 80
|
||||||
|
val totalUpdates = 2000
|
||||||
|
val workerCount = 4
|
||||||
|
val executor = Executors.newFixedThreadPool(workerCount + 1)
|
||||||
|
val running = AtomicBoolean(true)
|
||||||
|
val tornCellCount = AtomicInteger(0)
|
||||||
|
val exceptionCount = AtomicInteger(0)
|
||||||
|
val totalReads = AtomicLong(0)
|
||||||
|
|
||||||
|
val startLatch = CountDownLatch(1)
|
||||||
|
val writersDoneLatch = CountDownLatch(1)
|
||||||
|
|
||||||
|
// Reader workers simulating Compose Canvas draw passes
|
||||||
|
for (w in 0 until workerCount) {
|
||||||
|
executor.submit {
|
||||||
|
try {
|
||||||
|
startLatch.await()
|
||||||
|
while (running.get()) {
|
||||||
|
val snap = sb.displaySnapshot ?: continue
|
||||||
|
val snapSize = snap.rows * snap.cols
|
||||||
|
|
||||||
|
for (addr in 0 until snapSize) {
|
||||||
|
val cell = snap.getCell(addr) ?: continue
|
||||||
|
totalReads.incrementAndGet()
|
||||||
|
|
||||||
|
// Patterned validation:
|
||||||
|
// ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
|
||||||
|
if (cell.ucs4 in 'A'..'Z') {
|
||||||
|
val ucsOffset = cell.ucs4 - 'A'
|
||||||
|
val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
|
||||||
|
val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
|
||||||
|
val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
|
||||||
|
|
||||||
|
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
|
||||||
|
tornCellCount.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
exceptionCount.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer task simulating background Telnet network receiver thread
|
||||||
|
executor.submit {
|
||||||
|
try {
|
||||||
|
startLatch.await()
|
||||||
|
for (iter in 0 until totalUpdates) {
|
||||||
|
val mod = iter % 26
|
||||||
|
val ch = ('A' + mod)
|
||||||
|
val ec = (0x10 + mod).toByte()
|
||||||
|
val fg = (0x30 + mod).toByte()
|
||||||
|
val bg = (0x50 + mod).toByte()
|
||||||
|
|
||||||
|
val rawBuf = sb.buffer
|
||||||
|
for (i in 0 until size) {
|
||||||
|
val cell = rawBuf[i]
|
||||||
|
cell.ucs4 = ch
|
||||||
|
cell.ec = ec
|
||||||
|
cell.fg = fg
|
||||||
|
cell.bg = bg
|
||||||
|
}
|
||||||
|
sb.setCursorAddress(iter % size)
|
||||||
|
sb.updateDisplaySnapshot()
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
exceptionCount.incrementAndGet()
|
||||||
|
} finally {
|
||||||
|
writersDoneLatch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startLatch.countDown()
|
||||||
|
val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
|
||||||
|
assertTrue("Writer must complete updates without deadlock", finished)
|
||||||
|
|
||||||
|
running.set(false)
|
||||||
|
executor.shutdown()
|
||||||
|
assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
|
||||||
|
assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
|
||||||
|
assertTrue("Total reads must be significant", totalReads.get() > 50_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testConcurrentReadsDuringScreenResizing() {
|
||||||
|
val translator = EbcdicTranslator()
|
||||||
|
val sb = ScreenBuffer(24, 80, 27, 132, translator)
|
||||||
|
val workerCount = 4
|
||||||
|
val executor = Executors.newFixedThreadPool(workerCount + 1)
|
||||||
|
val running = AtomicBoolean(true)
|
||||||
|
val exceptionCount = AtomicInteger(0)
|
||||||
|
val successfulReads = AtomicLong(0)
|
||||||
|
|
||||||
|
val startLatch = CountDownLatch(1)
|
||||||
|
val writerLatch = CountDownLatch(1)
|
||||||
|
|
||||||
|
for (w in 0 until workerCount) {
|
||||||
|
executor.submit {
|
||||||
|
try {
|
||||||
|
startLatch.await()
|
||||||
|
while (running.get()) {
|
||||||
|
val snap = sb.displaySnapshot ?: continue
|
||||||
|
val total = snap.rows * snap.cols
|
||||||
|
|
||||||
|
for (i in 0 until total) {
|
||||||
|
val cell = snap.getCell(i)
|
||||||
|
if (cell != null) {
|
||||||
|
successfulReads.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
exceptionCount.incrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer alternates dimensions between 24x80 and 27x132
|
||||||
|
executor.submit {
|
||||||
|
try {
|
||||||
|
startLatch.await()
|
||||||
|
for (iter in 0 until 500) {
|
||||||
|
sb.erase((iter % 2) != 0)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
exceptionCount.incrementAndGet()
|
||||||
|
} finally {
|
||||||
|
writerLatch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startLatch.countDown()
|
||||||
|
assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
running.set(false)
|
||||||
|
executor.shutdown()
|
||||||
|
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
|
||||||
|
assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,105 @@ class HotspotAndHistoryFeaturesTest {
|
|||||||
assertTrue(enterHotspots.any { it.action == "[enter]" })
|
assertTrue(enterHotspots.any { it.action == "[enter]" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testIspfPrimaryOptionMenuDetection() {
|
||||||
|
val engine = HotspotEngine()
|
||||||
|
val screen =
|
||||||
|
" Menu Utilities Compilers Options Status Help\n" +
|
||||||
|
" ──────────────────────────────────────────────────────────────────────────────\n" +
|
||||||
|
" ISPF Primary Option Menu\n" +
|
||||||
|
" Option ===>\n" +
|
||||||
|
" \n" +
|
||||||
|
" 0 Settings Terminal and user parameters User ID . : RUDI\n" +
|
||||||
|
" 1 View Display source data or listings Time. . . : 12:31\n" +
|
||||||
|
" 2 Edit Create or change source data Terminal. : 3278\n" +
|
||||||
|
" 3 Utilities Perform utility functions Screen. . : 1\n" +
|
||||||
|
" 4 Foreground Interactive language processing Language. : ENGLISH\n" +
|
||||||
|
" 5 Batch Submit job for language processing Appl ID . : ISR\n" +
|
||||||
|
" 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" +
|
||||||
|
" 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" +
|
||||||
|
" 9 IBM Products IBM program development products System ID : VS01\n" +
|
||||||
|
" 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" +
|
||||||
|
" 11 Workplace ISPF Object/Action Workplace\n" +
|
||||||
|
" M More Additional workplace options\n" +
|
||||||
|
" X Exit Terminate ISPF using log and list defaults"
|
||||||
|
|
||||||
|
val hotspots = engine.scanText(screen, 18, 80)
|
||||||
|
|
||||||
|
// 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11
|
||||||
|
assertEquals(14, hotspots.size)
|
||||||
|
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
|
||||||
|
assertEquals(13, menuChoices.size)
|
||||||
|
|
||||||
|
// Option 0
|
||||||
|
val h0 = menuChoices[0]
|
||||||
|
assertEquals(HotspotType.MENU_CHOICE, h0.type)
|
||||||
|
assertEquals("0 Settings", h0.displayText)
|
||||||
|
assertEquals("0[enter]", h0.action)
|
||||||
|
assertEquals("Select option 0 (Settings)", h0.tooltip)
|
||||||
|
assertEquals(5, h0.startRow)
|
||||||
|
|
||||||
|
// Option 1
|
||||||
|
val h1 = menuChoices[1]
|
||||||
|
assertEquals("1 View", h1.displayText)
|
||||||
|
assertEquals("1[enter]", h1.action)
|
||||||
|
assertEquals("Select option 1 (View)", h1.tooltip)
|
||||||
|
assertEquals(6, h1.startRow)
|
||||||
|
|
||||||
|
// Option 2
|
||||||
|
val h2 = menuChoices[2]
|
||||||
|
assertEquals("2 Edit", h2.displayText)
|
||||||
|
assertEquals("2[enter]", h2.action)
|
||||||
|
assertEquals("Select option 2 (Edit)", h2.tooltip)
|
||||||
|
|
||||||
|
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
|
||||||
|
val h10 = menuChoices[9]
|
||||||
|
assertEquals("10 SCLM", h10.displayText)
|
||||||
|
assertEquals("10[enter]", h10.action)
|
||||||
|
assertEquals("Select option 10 (SCLM)", h10.tooltip)
|
||||||
|
|
||||||
|
// Option M (letter, index 11)
|
||||||
|
val hM = menuChoices[11]
|
||||||
|
assertEquals("M More", hM.displayText)
|
||||||
|
assertEquals("M[enter]", hM.action)
|
||||||
|
assertEquals("Select option M (More)", hM.tooltip)
|
||||||
|
|
||||||
|
// Option X (exit, index 12)
|
||||||
|
val hX = menuChoices[12]
|
||||||
|
assertEquals("X Exit", hX.displayText)
|
||||||
|
assertEquals("X[enter]", hX.action)
|
||||||
|
assertEquals("Select option X (Exit)", hX.tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testIspfSubOptionsAndMultiColumn() {
|
||||||
|
val engine = HotspotEngine()
|
||||||
|
val screen =
|
||||||
|
" 3.1 Library - Compress or print data set\n" +
|
||||||
|
" 3.2 DataSet - Allocate, rename, or delete\n" +
|
||||||
|
" 1 - View 2 - Edit"
|
||||||
|
|
||||||
|
val hotspots = engine.scanText(screen, 3, 80)
|
||||||
|
assertEquals(4, hotspots.size)
|
||||||
|
|
||||||
|
val h31 = hotspots[0]
|
||||||
|
assertEquals("3.1 Library", h31.displayText)
|
||||||
|
assertEquals("3.1[enter]", h31.action)
|
||||||
|
assertEquals("Select option 3.1 (Library)", h31.tooltip)
|
||||||
|
|
||||||
|
val h32 = hotspots[1]
|
||||||
|
assertEquals("3.2 DataSet", h32.displayText)
|
||||||
|
assertEquals("3.2[enter]", h32.action)
|
||||||
|
|
||||||
|
val h1 = hotspots[2]
|
||||||
|
assertEquals("1 - View", h1.displayText)
|
||||||
|
assertEquals("1[enter]", h1.action)
|
||||||
|
|
||||||
|
val h2 = hotspots[3]
|
||||||
|
assertEquals("2 - Edit", h2.displayText)
|
||||||
|
assertEquals("2[enter]", h2.action)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testHotspotHitTesting() {
|
fun testHotspotHitTesting() {
|
||||||
val engine = HotspotEngine()
|
val engine = HotspotEngine()
|
||||||
@@ -192,4 +291,22 @@ class HotspotAndHistoryFeaturesTest {
|
|||||||
assertEquals(true, hsPf)
|
assertEquals(true, hsPf)
|
||||||
assertEquals(false, hsChoices)
|
assertEquals(false, hsChoices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMenuChoiceActionPrefixingForSafeSubmission() {
|
||||||
|
val engine = HotspotEngine()
|
||||||
|
val screen = " 1 View Display source data or listings"
|
||||||
|
val hotspots = engine.scanText(screen, 1, 80)
|
||||||
|
assertEquals(1, hotspots.size)
|
||||||
|
val h = hotspots[0]
|
||||||
|
assertEquals(HotspotType.MENU_CHOICE, h.type)
|
||||||
|
assertEquals("1[enter]", h.action)
|
||||||
|
|
||||||
|
// Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock
|
||||||
|
var safeAction = h.action
|
||||||
|
if (!safeAction.startsWith("[home]")) {
|
||||||
|
safeAction = "[home][eraseeof]$safeAction"
|
||||||
|
}
|
||||||
|
assertEquals("[home][eraseeof]1[enter]", safeAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user