Debug input

This commit is contained in:
2026-08-21 00:28:32 -04:00
parent c767953c69
commit 3a7811543d
7 changed files with 762 additions and 198 deletions
+61 -85
View File
@@ -4,50 +4,33 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.OiaStatusBar import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import androidx.compose.foundation.layout.size
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -56,23 +39,22 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
} }
} }
setContent { setContent {
MaterialTheme( MaterialTheme(
colorScheme = darkColorScheme( colorScheme = darkColorScheme(
background = Color(0xFF000000), background = Color.Black,
surface = Color(0xFF1E1E1E) surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
) )
) { ) {
MainScreen( MainScreen(
@@ -165,15 +147,13 @@ fun MainScreen(
val screenVersion by viewModel.screenVersion.collectAsState() val screenVersion by viewModel.screenVersion.collectAsState()
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState() val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
val cursorBlink by viewModel.cursorBlink.collectAsState() val cursorBlink by viewModel.cursorBlink.collectAsState()
val ftState by viewModel.ftState.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) } var showConnectDialog 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) }
val focusRequester = remember { FocusRequester() } var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val keyboardController = LocalSoftwareKeyboardController.current
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -193,7 +173,7 @@ fun MainScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context) val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) { if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName) viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName, autoHost.hostType)
} }
} }
@@ -216,7 +196,7 @@ fun MainScreen(
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays) // Main 3270 Terminal Screen View
TerminalView( TerminalView(
screenBuffer = screenBuffer, screenBuffer = screenBuffer,
rows = rows, rows = rows,
@@ -227,8 +207,7 @@ fun MainScreen(
blinkCursor = cursorBlink, blinkCursor = cursorBlink,
onTapAddress = { addr -> onTapAddress = { addr ->
viewModel.setCursor(addr) viewModel.setCursor(addr)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
keyboardController?.show()
}, },
onPasteText = { text -> onPasteText = { text ->
viewModel.pasteString(text) viewModel.pasteString(text)
@@ -236,45 +215,34 @@ fun MainScreen(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Transparent BasicTextField maintaining active Android IME connection // Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
BasicTextField( AndroidView(
value = textFieldValue, factory = { ctx ->
onValueChange = { newValue -> TerminalInputView(ctx).apply {
val text = newValue.text onInputText = { text ->
if (text.isNotEmpty()) {
viewModel.typeString(text) viewModel.typeString(text)
textFieldValue = TextFieldValue("")
onClearShift() onClearShift()
} }
}, onSendAid = { aid ->
textStyle = TextStyle(color = Color.Transparent), viewModel.sendAid(aid)
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.None
),
keyboardActions = KeyboardActions(
onDone = { viewModel.sendAid(AID_ENTER) },
onGo = { viewModel.sendAid(AID_ENTER) },
onSend = { viewModel.sendAid(AID_ENTER) },
onNext = {
// Stubbed for future form field navigation
} }
), onBackspace = {
modifier = Modifier
.size(1.dp)
.focusRequester(focusRequester)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
viewModel.backspace() viewModel.backspace()
onClearShift() onClearShift()
true
} else {
false
} }
onTab = {
viewModel.tab()
} }
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
) )
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24) // Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
@@ -288,35 +256,35 @@ fun MainScreen(
onSettingsClick = { showSettingsDialog = true }, onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid -> onSendAid = { aid ->
viewModel.sendAid(aid) viewModel.sendAid(aid)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onReset = { onReset = {
viewModel.resetKeyboard() viewModel.resetKeyboard()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onTab = { onTab = {
viewModel.tab() viewModel.tab()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onBackTab = { onBackTab = {
viewModel.backTab() viewModel.backTab()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorLeft = { onCursorLeft = {
viewModel.cursorLeft() viewModel.cursorLeft()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorUp = { onCursorUp = {
viewModel.cursorUp() viewModel.cursorUp()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorDown = { onCursorDown = {
viewModel.cursorDown() viewModel.cursorDown()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
}, },
onCursorRight = { onCursorRight = {
viewModel.cursorRight() viewModel.cursorRight()
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
} }
) )
@@ -336,19 +304,27 @@ fun MainScreen(
if (showConnectDialog) { if (showConnectDialog) {
ConnectDialog( ConnectDialog(
onDismiss = { showConnectDialog = false }, onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName -> onConnect = { host, port, model, luName, hostType ->
showConnectDialog = false showConnectDialog = false
viewModel.connect(host, port, model, luName) viewModel.connect(host, port, model, luName, hostType)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
} }
) )
} }
if (showFtDialog) { if (showFtDialog) {
FileTransferDialog( FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
onDismiss = { showFtDialog = false }, onDismiss = { showFtDialog = false },
onStartTransfer = { config -> onStartTransfer = { config ->
showFtDialog = false val error = viewModel.startFileTransfer(config)
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
} }
) )
} }
@@ -361,7 +337,7 @@ fun MainScreen(
onSave = { mask, blink -> onSave = { mask, blink ->
showSettingsDialog = false showSettingsDialog = false
viewModel.updateSettings(mask, blink) viewModel.updateSettings(mask, blink)
focusRequester.requestFocus() terminalInputViewRef?.showSoftKeyboard()
} }
) )
} }
@@ -14,13 +14,16 @@ import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings import org.pubvm.a3270.storage.AppSettings
import java.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.logging.Logger import java.util.logging.Logger
@@ -38,6 +41,14 @@ sealed interface TerminalInputAction {
data class SetCursor(val baddr: Int) : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction
} }
data class FTProgressState(
val isActive: Boolean = false,
val isRunning: Boolean = false,
val bytesTransferred: Long = 0L,
val statusMessage: String = "",
val isError: Boolean = false
)
class TerminalViewModel(application: Application) : AndroidViewModel(application) { class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name) private val log = Logger.getLogger(TerminalViewModel::class.java.name)
@@ -81,9 +92,16 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application)) private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow() val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
// File Transfer State
private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
private var client: Telnet3270Client? = null private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set private set
var activeHostType: String = "TSO"
private set
private var lastScreenContentHash: Int = 0 private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false private var hasInitialScreenLoaded: Boolean = false
@@ -180,11 +198,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_cursorBlink.value = blink _cursorBlink.value = blink
} }
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") { fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "", hostType: String = "TSO") {
if (_connectionState.value.isConnected()) return if (_connectionState.value.isConnected()) return
currentHost = host currentHost = host
activeHostType = hostType
lastScreenContentHash = 0 lastScreenContentHash = 0
hasInitialScreenLoaded = false hasInitialScreenLoaded = false
fileTransferCoordinator = null
_ftState.value = FTProgressState()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -210,7 +231,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState _connectionState.value = newState
_oiaText.value = if (newState.isFullSession()) { _oiaText.value = if (newState.isFullSession()) {
"3270 Connected ($host)" "3270 Connected ($host - $activeHostType)"
} else if (newState.isHalfConnected()) { } else if (newState.isHalfConnected()) {
"Connecting..." "Connecting..."
} else { } else {
@@ -240,6 +261,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_screenVersion.value = System.currentTimeMillis() _screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated()
val newHash = computeScreenContentHash(buf) val newHash = computeScreenContentHash(buf)
val contentChanged = (newHash != lastScreenContentHash) val contentChanged = (newHash != lastScreenContentHash)
@@ -279,16 +303,92 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
fun disconnect() { fun disconnect() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect() client?.disconnect()
client = null client = null
_connectionState.value = ConnectionState.NOT_CONNECTED _connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Disconnected" _oiaText.value = "Disconnected"
_ftState.value = FTProgressState()
} catch (e: Exception) { } catch (e: Exception) {
log.warning("Error disconnecting: ${e.message}") log.warning("Error disconnecting: ${e.message}")
} }
} }
} }
fun startFileTransfer(config: FTConfig): String? {
val c = client ?: return "Terminal is not connected."
// Resolve local path if relative (default to app files or download directory)
val rawPath = config.localFilename
if (!rawPath.startsWith("/")) {
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
val resolvedFile = File(appFilesDir, rawPath)
config.localFilename = resolvedFile.absolutePath
}
if (fileTransferCoordinator == null) {
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
override fun onTransferStarted() {
_ftState.value = FTProgressState(
isActive = true,
isRunning = false,
bytesTransferred = 0L,
statusMessage = "Starting IND\$FILE transfer..."
)
}
override fun onTransferRunning() {
_ftState.value = _ftState.value.copy(
isRunning = true,
statusMessage = "Transfer in progress..."
)
}
override fun onBytesTransferred(bytes: Long) {
_ftState.value = _ftState.value.copy(
bytesTransferred = bytes,
statusMessage = "Transferring: $bytes bytes"
)
}
override fun onTransferComplete(message: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = message,
isError = false
)
}
override fun onTransferAborted(error: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = "Transfer failed: $error",
isError = true
)
}
})
}
val err = fileTransferCoordinator?.startTransfer(config)
if (err != null) {
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Error: $err",
isError = true
)
}
return err
}
fun cancelFileTransfer() {
fileTransferCoordinator?.cancel()
}
fun typeChar(ch: Char) { fun typeChar(ch: Char) {
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString())) inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
} }
@@ -416,6 +516,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect() client?.disconnect()
inputChannel.close() inputChannel.close()
inputExecutor.shutdown() inputExecutor.shutdown()
@@ -0,0 +1,248 @@
package org.pubvm.a3270.ft
import android.os.Handler
import android.os.Looper
import org.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft
import java.io.File
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.logging.Logger
/**
* Android coordinator for IND$FILE file transfers.
* Manages the transfer state machine, timeouts, and coordinates between
* UI, Telnet3270Client, and lower-level CUT/DFT protocol handlers.
*/
class FileTransfer(
private val client: Telnet3270Client,
private val callback: FileTransferCallback
) : FTCut.FTCutListener, FTDft.FTDftListener {
private val log = Logger.getLogger(FileTransfer::class.java.name)
private val mainHandler = Handler(Looper.getMainLooper())
enum class FTMode {
UNKNOWN, CUT, DFT
}
interface FileTransferCallback {
fun onTransferStarted()
fun onTransferRunning()
fun onBytesTransferred(bytes: Long)
fun onTransferComplete(message: String)
fun onTransferAborted(error: String)
}
private var currentConfig: FTConfig? = null
private var localFile: File? = null
private var state = FTState.NONE
private var activeMode = FTMode.UNKNOWN
private var cutHandler: FTCut? = null
private var dftHandler: FTDft? = null
private var timeoutTimer: Timer? = null
private val startTimeoutMs = 30000L // 30 seconds
fun startTransfer(config: FTConfig): String? {
if (state != FTState.NONE) {
return "A transfer is already in progress."
}
val validationError = config.validate()
if (validationError != null) {
return validationError
}
this.currentConfig = config
this.localFile = File(config.localFilename)
this.activeMode = FTMode.UNKNOWN
// Check overwrite
if (config.isReceive && !config.isAppend && !config.isOverwrite) {
if (localFile?.exists() == true) {
return "Local file already exists and overwrite is not permitted."
}
}
// Initialize protocol handlers lazily
if (cutHandler == null) {
cutHandler = FTCut(
client.screenBuffer,
client.inputProcessor,
client.translator,
this
)
}
if (dftHandler == null) {
dftHandler = FTDft(
client.inputProcessor,
client.translator,
this
)
client.dataStreamProcessor.setFTDft(dftHandler)
}
// Build and type the IND$FILE command
val command = config.buildCommand()
log.info("Starting IND\$FILE transfer with command: $command")
// Erase field and verify it can hold the command
val capacity = client.inputProcessor.kybdPrime()
if (capacity < 0) {
cleanupHandlers(false)
return when (capacity) {
-1 -> "Keyboard is locked."
-3 -> "No unprotected input field found on screen."
else -> "Cannot start transfer from current screen state."
}
}
if (capacity < command.length) {
cleanupHandlers(false)
return "Current input field is too small for IND\$FILE command ($capacity chars max)."
}
setState(FTState.AWAIT_ACK)
client.emulateInput(command + "\n")
startTimeout()
mainHandler.post { callback.onTransferStarted() }
return null
}
fun cancel() {
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
log.info("User cancelled transfer")
setState(FTState.ABORT_WAIT)
} else if (state != FTState.NONE) {
log.info("Forcing cancel from state $state")
completeTransfer("Transfer cancelled.")
mainHandler.post { callback.onTransferAborted("Cancelled by user.") }
}
}
fun reset() {
log.info("Force resetting FileTransfer state from $state")
completeTransfer("Reset")
}
fun isTransferActive(): Boolean = state != FTState.NONE
fun getActiveMode(): FTMode = activeMode
fun onScreenUpdated() {
if ((activeMode == FTMode.CUT || activeMode == FTMode.UNKNOWN) &&
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
cutHandler?.processScreenUpdate()
}
}
private fun startTimeout() {
cancelTimeout()
timeoutTimer = Timer("FTTimeout", true).apply {
schedule(object : TimerTask() {
override fun run() {
mainHandler.post {
if (state == FTState.AWAIT_ACK) {
log.warning("Transfer start timeout")
completeTransfer("Transfer failed to start within 30 seconds.")
callback.onTransferAborted("Transfer start timeout.")
}
}
}
}, startTimeoutMs)
}
}
private fun cancelTimeout() {
timeoutTimer?.cancel()
timeoutTimer = null
}
private fun cleanupHandlers(success: Boolean) {
cutHandler?.cleanup()
dftHandler?.cleanup()
if (!success && currentConfig != null && currentConfig?.isReceive == true && !currentConfig!!.isAppend) {
if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile!!.exists()) {
log.info("Cleaning up incomplete download: ${localFile!!.absolutePath}")
localFile!!.delete()
}
}
}
private fun completeTransfer(errorMessage: String?) {
cancelTimeout()
val success = (errorMessage == null)
cleanupHandlers(success)
setState(FTState.NONE)
activeMode = FTMode.UNKNOWN
currentConfig = null
}
override fun onCutRunning() {
handleTransferRunning(FTMode.CUT)
}
override fun onDftRunning() {
handleTransferRunning(FTMode.DFT)
}
private fun handleTransferRunning(mode: FTMode) {
if (activeMode == FTMode.UNKNOWN) {
activeMode = mode
log.info("FT mode established: $activeMode")
try {
if (activeMode == FTMode.DFT) {
dftHandler?.initTransfer(localFile)
} else {
cutHandler?.initTransfer(localFile)
}
} catch (e: IOException) {
log.warning("Failed to open local file for $activeMode: ${e.message}")
onTransferAborted("Failed to open local file: ${e.message}")
return
}
}
cancelTimeout()
setState(FTState.RUNNING)
mainHandler.post { callback.onTransferRunning() }
}
override fun onTransferComplete(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post {
if (errorMessage == null) {
callback.onTransferComplete("File transfer complete.")
} else {
callback.onTransferAborted(errorMessage)
}
}
}
override fun onTransferAborted(errorMessage: String?) {
completeTransfer(errorMessage)
mainHandler.post { callback.onTransferAborted(errorMessage ?: "Transfer aborted") }
}
override fun onBytesTransferred(bytes: Long) {
mainHandler.post { callback.onBytesTransferred(bytes) }
}
override fun getCurrentState(): FTState = state
override fun setState(state: FTState) {
this.state = state
}
override fun getConfig(): FTConfig? = currentConfig
override fun getLocalFile(): File? = localFile
}
@@ -13,7 +13,8 @@ data class SavedHost(
val port: Int = 23, val port: Int = 23,
val model: Int = 2, val model: Int = 2,
val luName: String = "", val luName: String = "",
val autoConnect: Boolean = false val autoConnect: Boolean = false,
val hostType: String = "TSO"
) { ) {
fun toJson(): JSONObject { fun toJson(): JSONObject {
return JSONObject().apply { return JSONObject().apply {
@@ -24,6 +25,7 @@ data class SavedHost(
put("model", model) put("model", model)
put("luName", luName) put("luName", luName)
put("autoConnect", autoConnect) put("autoConnect", autoConnect)
put("hostType", hostType)
} }
} }
@@ -36,7 +38,8 @@ data class SavedHost(
port = json.optInt("port", 23), port = json.optInt("port", 23),
model = json.optInt("model", 2), model = json.optInt("model", 2),
luName = json.optString("luName", ""), luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false) autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO")
) )
} }
} }
@@ -25,7 +25,7 @@ import org.pubvm.a3270.storage.SavedHost
@Composable @Composable
fun ConnectDialog( fun ConnectDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit onConnect: (host: String, port: Int, model: Int, luName: String, hostType: String) -> 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)) }
@@ -37,6 +37,7 @@ fun ConnectDialog(
var modelNum by remember { mutableIntStateOf(2) } var modelNum by remember { mutableIntStateOf(2) }
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") }
fun loadProfile(saved: SavedHost) { fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id selectedHostId = saved.id
@@ -46,6 +47,7 @@ fun ConnectDialog(
modelNum = saved.model modelNum = saved.model
luName = saved.luName luName = saved.luName
autoConnect = saved.autoConnect autoConnect = saved.autoConnect
hostType = saved.hostType
} }
fun clearFields() { fun clearFields() {
@@ -56,6 +58,7 @@ fun ConnectDialog(
modelNum = 2 modelNum = 2
luName = "" luName = ""
autoConnect = false autoConnect = false
hostType = "TSO"
} }
fun saveCurrentProfile(): SavedHost? { fun saveCurrentProfile(): SavedHost? {
@@ -69,7 +72,8 @@ fun ConnectDialog(
port = port, port = port,
model = modelNum, model = modelNum,
luName = luName.trim(), luName = luName.trim(),
autoConnect = autoConnect autoConnect = autoConnect,
hostType = hostType
) )
HostStorage.saveHost(context, hostToSave) HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
@@ -120,39 +124,31 @@ fun ConnectDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 300.dp) .heightIn(max = 440.dp)
.verticalScroll(rememberScrollState())
) { ) {
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(6.dp) .fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Saved Profiles List // Saved Profiles List Section
if (savedHosts.isNotEmpty()) { if (savedHosts.isNotEmpty()) {
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray) Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 95.dp)
.verticalScroll(rememberScrollState())
) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
savedHosts.forEach { profile -> savedHosts.forEach { saved ->
val isSelected = profile.id == selectedHostId val isSelected = (saved.id == selectedHostId)
Surface( Surface(
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719), color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.border( .clickable { loadProfile(saved) }
width = if (isSelected) 1.dp else 0.dp,
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
shape = RoundedCornerShape(6.dp)
)
.clickable { loadProfile(profile) }
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -162,50 +158,48 @@ fun ConnectDialog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
if (profile.autoConnect) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF2B8A3E),
shape = RoundedCornerShape(4.dp)
) {
Text( Text(
"AUTO", text = saved.name,
fontSize = 9.sp, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = Color.White, color = Color.White
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) )
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})" +
if (saved.autoConnect) " [Auto-Connect]" else "",
fontSize = 10.sp,
color = Color.LightGray
) )
} }
} Row(verticalAlignment = Alignment.CenterVertically) {
} IconButton(
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
}
Row {
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
Text("Edit", fontSize = 11.sp)
}
TextButton(
onClick = { onClick = {
HostStorage.deleteHost(context, profile.id) loadProfile(saved)
val hostToConn = saveCurrentProfile() ?: saved
onConnect(hostToConn.host, hostToConn.port, hostToConn.model, hostToConn.luName, hostToConn.hostType)
},
modifier = Modifier.size(28.dp)
) {
Text("", fontSize = 13.sp, color = Color(0xFF51CF66))
}
IconButton(
onClick = {
HostStorage.deleteHost(context, saved.id)
savedHosts = HostStorage.getSavedHosts(context) savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == profile.id) { if (selectedHostId == saved.id) {
clearFields() clearFields()
} }
}, },
contentPadding = PaddingValues(horizontal = 4.dp), modifier = Modifier.size(28.dp)
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
) { ) {
Text("Delete", fontSize = 11.sp) Text("", fontSize = 12.sp, color = Color(0xFFFF6B6B))
}
} }
} }
} }
} }
} }
} }
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 2.dp))
} }
Text( Text(
@@ -241,6 +235,7 @@ fun ConnectDialog(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Terminal Model
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -255,6 +250,21 @@ fun ConnectDialog(
} }
} }
// Host System Type (TSO, CMS, CICS)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 11.sp, color = Color.Gray)
listOf("TSO", "CMS", "CICS").forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht, fontSize = 11.sp) }
)
}
}
OutlinedTextField( OutlinedTextField(
value = luName, value = luName,
onValueChange = { luName = it }, onValueChange = { luName = it },
@@ -299,7 +309,7 @@ fun ConnectDialog(
onClick = { onClick = {
val saved = saveCurrentProfile() val saved = saveCurrentProfile()
if (saved != null) { if (saved != null) {
onConnect(saved.host, saved.port, saved.model, saved.luName) onConnect(saved.host, saved.port, saved.model, saved.luName, saved.hostType)
} }
} }
) { ) {
@@ -1,123 +1,227 @@
package org.pubvm.a3270.ui package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.focus.FocusRequester import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ft.FTConfig import org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
@Composable @Composable
fun FileTransferDialog( fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
onDismiss: () -> Unit, onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) { ) {
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") } var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") } var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) } var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) } var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) } var hostType by remember { mutableStateOf(initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
val focusRequester = remember { FocusRequester() } var lreclStr by remember { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current var blksizeStr by remember { mutableStateOf("") }
var overwrite by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("IND\$FILE File Transfer") }, title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = { text = {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
OutlinedTextField( OutlinedTextField(
value = hostFile, value = hostFile,
onValueChange = { hostFile = it }, onValueChange = { hostFile = it },
label = { Text("Host File Name") }, label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
value = localFile, value = localFile,
onValueChange = { localFile = it }, onValueChange = { localFile = it },
label = { Text("Local File Path") }, label = { Text("Local File Name / Path") },
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// Transfer Direction
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Direction:") Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FilterChip( FilterChip(
selected = isReceive, selected = isReceive,
onClick = { isReceive = true }, onClick = { isReceive = true },
label = { Text("Receive (GET)") } label = { Text("Receive (GET)", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = !isReceive, selected = !isReceive,
onClick = { isReceive = false }, onClick = { isReceive = false },
label = { Text("Send (PUT)") } label = { Text("Send (PUT)", fontSize = 11.sp) }
) )
} }
} }
// Transfer Mode
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Mode:") Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FilterChip( FilterChip(
selected = isAscii, selected = isAscii,
onClick = { isAscii = true }, onClick = { isAscii = true },
label = { Text("ASCII") } label = { Text("ASCII (Text)", fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
FilterChip( FilterChip(
selected = !isAscii, selected = !isAscii,
onClick = { isAscii = false }, onClick = { isAscii = false },
label = { Text("Binary") } label = { Text("Binary", fontSize = 11.sp) }
) )
} }
} }
// Host Type (TSO, CMS, CICS)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Host:") Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row { Row {
FTConfig.HostType.values().forEach { ht -> FTConfig.HostType.values().forEach { ht ->
FilterChip( FilterChip(
selected = (hostType == ht), selected = (hostType == ht),
onClick = { hostType = ht }, onClick = { hostType = ht },
label = { Text(ht.name) } label = { Text(ht.name, fontSize = 11.sp) }
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
} }
} }
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
} }
}, },
confirmButton = { confirmButton = {
@@ -127,20 +231,27 @@ fun FileTransferDialog(
val config = FTConfig().apply { val config = FTConfig().apply {
setHostFilename(hostFile.trim()) setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim()) setLocalFilename(localFile.trim())
setReceive(isReceive) setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
setAscii(isAscii) setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType) setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
}
} }
onStartTransfer(config) onStartTransfer(config)
} }
} },
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
) { ) {
Text("Start Transfer") Text("Start Transfer")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
Text("Cancel") Text("Close")
} }
} }
) )
@@ -0,0 +1,114 @@
package org.pubvm.a3270.ui
import android.content.Context
import android.text.InputType
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
*
* It avoids the destructive TextFieldValue("") resetting cycle which causes Android IME
* (Gboard/Samsung Keyboard) to reset symbol locks, cancel Caps Lock, and drop fast keystrokes.
*/
class TerminalInputConnection(
targetView: View,
private val onInputText: (String) -> Unit,
private val onSendAid: (Int) -> Unit,
private val onBackspace: () -> Unit,
private val onTab: () -> Unit,
private val onBackTab: () -> Unit
) : BaseInputConnection(targetView, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (!text.isNullOrEmpty()) {
onInputText(text.toString())
}
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
if (beforeLength > 0) {
repeat(beforeLength) { onBackspace() }
return true
}
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) {
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onSendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_DEL -> {
onBackspace()
return true
}
KeyEvent.KEYCODE_TAB -> {
if (event.isShiftPressed) onBackTab() else onTab()
return true
}
}
val unicode = event.keyCharacterMap.get(event.keyCode, event.metaState)
if (unicode > 0) {
onInputText(unicode.toChar().toString())
return true
}
}
return super.sendKeyEvent(event)
}
}
/**
* Native Android View hosting the terminal InputConnection with Termux-style EditorInfo.
*
* TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + TYPE_CLASS_TEXT forces keyboards like Gboard
* to permanently display the alphanumeric number row across the top, disable intrusive
* autocorrect overlays, and preserve Shift/Caps Lock and Symbol mode (?123) state across typing.
*/
class TerminalInputView(context: Context) : View(context) {
var onInputText: (String) -> Unit = {}
var onSendAid: (Int) -> Unit = {}
var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {}
init {
isFocusable = true
isFocusableInTouchMode = true
}
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE
return TerminalInputConnection(this, onInputText, onSendAid, onBackspace, onTab, onBackTab)
}
fun showSoftKeyboard() {
requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}