From 3a7811543d874134bb1576ba1491dd5632046493 Mon Sep 17 00:00:00 2001 From: Rudi Date: Fri, 21 Aug 2026 00:28:32 -0400 Subject: [PATCH] Debug input --- src/main/java/org/pubvm/a3270/MainActivity.kt | 154 +++++------ .../java/org/pubvm/a3270/TerminalViewModel.kt | 106 +++++++- .../java/org/pubvm/a3270/ft/FileTransfer.kt | 248 ++++++++++++++++++ .../org/pubvm/a3270/storage/HostStorage.kt | 7 +- .../java/org/pubvm/a3270/ui/ConnectDialog.kt | 156 +++++------ .../org/pubvm/a3270/ui/FileTransferDialog.kt | 175 +++++++++--- .../org/pubvm/a3270/ui/TerminalInputView.kt | 114 ++++++++ 7 files changed, 762 insertions(+), 198 deletions(-) create mode 100644 src/main/java/org/pubvm/a3270/ft/FileTransfer.kt create mode 100644 src/main/java/org/pubvm/a3270/ui/TerminalInputView.kt diff --git a/src/main/java/org/pubvm/a3270/MainActivity.kt b/src/main/java/org/pubvm/a3270/MainActivity.kt index c7d513d..0f482e2 100644 --- a/src/main/java/org/pubvm/a3270/MainActivity.kt +++ b/src/main/java/org/pubvm/a3270/MainActivity.kt @@ -4,50 +4,33 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.KeyEvent +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background 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.runtime.* 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.SolidColor import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -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.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import org.lib3270j.protocol.DS3270Constants.AID_ENTER import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.storage.HostStorage import org.pubvm.a3270.ui.ConnectDialog 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.SettingsDialog +import org.pubvm.a3270.ui.TerminalInputView import org.pubvm.a3270.ui.TerminalView - -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 +import org.pubvm.a3270.ui.TwoRowKeyBar class MainActivity : ComponentActivity() { @@ -56,23 +39,22 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + 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 (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101) + if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) } } setContent { MaterialTheme( colorScheme = darkColorScheme( - background = Color(0xFF000000), - surface = Color(0xFF1E1E1E) + background = Color.Black, + surface = Color(0xFF1E1E1E), + primary = Color(0xFF339AF0) ) ) { MainScreen( @@ -165,15 +147,13 @@ fun MainScreen( val screenVersion by viewModel.screenVersion.collectAsState() val maskHiddenInput by viewModel.maskHiddenInput.collectAsState() val cursorBlink by viewModel.cursorBlink.collectAsState() + val ftState by viewModel.ftState.collectAsState() var showConnectDialog by remember { mutableStateOf(false) } var showFtDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + var terminalInputViewRef by remember { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { @@ -193,7 +173,7 @@ fun MainScreen( LaunchedEffect(Unit) { val autoHost = HostStorage.getAutoConnectHost(context) 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( modifier = Modifier.fillMaxSize() ) { - // Main 3270 Terminal Screen View (100% clean top screen, zero overlays) + // Main 3270 Terminal Screen View TerminalView( screenBuffer = screenBuffer, rows = rows, @@ -227,8 +207,7 @@ fun MainScreen( blinkCursor = cursorBlink, onTapAddress = { addr -> viewModel.setCursor(addr) - focusRequester.requestFocus() - keyboardController?.show() + terminalInputViewRef?.showSoftKeyboard() }, onPasteText = { text -> viewModel.pasteString(text) @@ -236,45 +215,34 @@ fun MainScreen( modifier = Modifier.weight(1f) ) - // Transparent BasicTextField maintaining active Android IME connection - BasicTextField( - value = textFieldValue, - onValueChange = { newValue -> - val text = newValue.text - if (text.isNotEmpty()) { - viewModel.typeString(text) - textFieldValue = TextFieldValue("") - onClearShift() - } - }, - textStyle = TextStyle(color = Color.Transparent), - 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 - } - ), - modifier = Modifier - .size(1.dp) - .focusRequester(focusRequester) - .onPreviewKeyEvent { keyEvent -> - if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) { + // Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support) + AndroidView( + factory = { ctx -> + TerminalInputView(ctx).apply { + onInputText = { text -> + viewModel.typeString(text) + onClearShift() + } + onSendAid = { aid -> + viewModel.sendAid(aid) + } + onBackspace = { viewModel.backspace() 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) @@ -288,35 +256,35 @@ fun MainScreen( onSettingsClick = { showSettingsDialog = true }, onSendAid = { aid -> viewModel.sendAid(aid) - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onReset = { viewModel.resetKeyboard() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onTab = { viewModel.tab() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onBackTab = { viewModel.backTab() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onCursorLeft = { viewModel.cursorLeft() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onCursorUp = { viewModel.cursorUp() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onCursorDown = { viewModel.cursorDown() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() }, onCursorRight = { viewModel.cursorRight() - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() } ) @@ -336,19 +304,27 @@ fun MainScreen( if (showConnectDialog) { ConnectDialog( onDismiss = { showConnectDialog = false }, - onConnect = { host, port, model, luName -> + onConnect = { host, port, model, luName, hostType -> showConnectDialog = false - viewModel.connect(host, port, model, luName) - focusRequester.requestFocus() + viewModel.connect(host, port, model, luName, hostType) + terminalInputViewRef?.showSoftKeyboard() } ) } if (showFtDialog) { FileTransferDialog( + initialHostType = viewModel.activeHostType, + ftProgressState = ftState, onDismiss = { showFtDialog = false }, 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 -> showSettingsDialog = false viewModel.updateSettings(mask, blink) - focusRequester.requestFocus() + terminalInputViewRef?.showSoftKeyboard() } ) } diff --git a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt index 8b64501..c171ec9 100644 --- a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt +++ b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt @@ -14,13 +14,16 @@ import org.lib3270j.ConnectionConfig import org.lib3270j.ConnectionState import org.lib3270j.Telnet3270Client import org.lib3270j.TerminalModel +import org.lib3270j.ft.FTConfig import org.lib3270j.listener.ConnectionListener import org.lib3270j.listener.ScreenUpdateListener import org.lib3270j.protocol.DS3270Constants.AID_ENTER import org.lib3270j.protocol.DS3270Constants.faIsProtected import org.lib3270j.screen.ScreenBuffer +import org.pubvm.a3270.ft.FileTransfer import org.pubvm.a3270.service.TerminalService import org.pubvm.a3270.storage.AppSettings +import java.io.File import java.util.concurrent.Executors import java.util.logging.Logger @@ -38,6 +41,14 @@ sealed interface 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) { 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)) val cursorBlink: StateFlow = _cursorBlink.asStateFlow() + // File Transfer State + private var fileTransferCoordinator: FileTransfer? = null + private val _ftState = MutableStateFlow(FTProgressState()) + val ftState: StateFlow = _ftState.asStateFlow() + private var client: Telnet3270Client? = null var currentHost: String = "" private set + var activeHostType: String = "TSO" + private set private var lastScreenContentHash: Int = 0 private var hasInitialScreenLoaded: Boolean = false @@ -180,11 +198,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _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 currentHost = host + activeHostType = hostType lastScreenContentHash = 0 hasInitialScreenLoaded = false + fileTransferCoordinator = null + _ftState.value = FTProgressState() viewModelScope.launch(Dispatchers.IO) { try { @@ -210,7 +231,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { _connectionState.value = newState _oiaText.value = if (newState.isFullSession()) { - "3270 Connected ($host)" + "3270 Connected ($host - $activeHostType)" } else if (newState.isHalfConnected()) { "Connecting..." } else { @@ -240,6 +261,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _screenVersion.value = System.currentTimeMillis() + // Drive CUT mode file transfers if active + fileTransferCoordinator?.onScreenUpdated() + val newHash = computeScreenContentHash(buf) val contentChanged = (newHash != lastScreenContentHash) @@ -279,16 +303,92 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application fun disconnect() { viewModelScope.launch(Dispatchers.IO) { try { + fileTransferCoordinator?.reset() + fileTransferCoordinator = null client?.disconnect() client = null _connectionState.value = ConnectionState.NOT_CONNECTED _oiaText.value = "Disconnected" + _ftState.value = FTProgressState() } catch (e: Exception) { 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().getExternalFilesDir(null) ?: getApplication().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) { inputChannel.trySend(TerminalInputAction.TypeText(ch.toString())) } @@ -416,6 +516,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application override fun onCleared() { super.onCleared() + fileTransferCoordinator?.reset() + fileTransferCoordinator = null client?.disconnect() inputChannel.close() inputExecutor.shutdown() diff --git a/src/main/java/org/pubvm/a3270/ft/FileTransfer.kt b/src/main/java/org/pubvm/a3270/ft/FileTransfer.kt new file mode 100644 index 0000000..0bae888 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ft/FileTransfer.kt @@ -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 +} diff --git a/src/main/java/org/pubvm/a3270/storage/HostStorage.kt b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt index b17aea9..ce3510d 100644 --- a/src/main/java/org/pubvm/a3270/storage/HostStorage.kt +++ b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt @@ -13,7 +13,8 @@ data class SavedHost( val port: Int = 23, val model: Int = 2, val luName: String = "", - val autoConnect: Boolean = false + val autoConnect: Boolean = false, + val hostType: String = "TSO" ) { fun toJson(): JSONObject { return JSONObject().apply { @@ -24,6 +25,7 @@ data class SavedHost( put("model", model) put("luName", luName) put("autoConnect", autoConnect) + put("hostType", hostType) } } @@ -36,7 +38,8 @@ data class SavedHost( port = json.optInt("port", 23), model = json.optInt("model", 2), luName = json.optString("luName", ""), - autoConnect = json.optBoolean("autoConnect", false) + autoConnect = json.optBoolean("autoConnect", false), + hostType = json.optString("hostType", "TSO") ) } } diff --git a/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt index 8ef20ad..b3601d4 100644 --- a/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt +++ b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt @@ -25,7 +25,7 @@ import org.pubvm.a3270.storage.SavedHost @Composable fun ConnectDialog( 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 var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) } @@ -37,6 +37,7 @@ fun ConnectDialog( var modelNum by remember { mutableIntStateOf(2) } var luName by remember { mutableStateOf("") } var autoConnect by remember { mutableStateOf(false) } + var hostType by remember { mutableStateOf("TSO") } fun loadProfile(saved: SavedHost) { selectedHostId = saved.id @@ -46,6 +47,7 @@ fun ConnectDialog( modelNum = saved.model luName = saved.luName autoConnect = saved.autoConnect + hostType = saved.hostType } fun clearFields() { @@ -56,6 +58,7 @@ fun ConnectDialog( modelNum = 2 luName = "" autoConnect = false + hostType = "TSO" } fun saveCurrentProfile(): SavedHost? { @@ -69,7 +72,8 @@ fun ConnectDialog( port = port, model = modelNum, luName = luName.trim(), - autoConnect = autoConnect + autoConnect = autoConnect, + hostType = hostType ) HostStorage.saveHost(context, hostToSave) savedHosts = HostStorage.getSavedHosts(context) @@ -120,92 +124,82 @@ fun ConnectDialog( Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = 300.dp) - .verticalScroll(rememberScrollState()) + .heightIn(max = 440.dp) ) { + val scrollState = rememberScrollState() Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Saved Profiles List + // Saved Profiles List Section if (savedHosts.isNotEmpty()) { Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray) - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 95.dp) - .verticalScroll(rememberScrollState()) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - savedHosts.forEach { profile -> - val isSelected = profile.id == selectedHostId - Surface( - shape = RoundedCornerShape(6.dp), - color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719), + savedHosts.forEach { saved -> + val isSelected = (saved.id == selectedHostId) + Surface( + shape = RoundedCornerShape(6.dp), + color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B), + border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null, + modifier = Modifier + .fillMaxWidth() + .clickable { loadProfile(saved) } + ) { + Row( modifier = Modifier .fillMaxWidth() - .border( - width = if (isSelected) 1.dp else 0.dp, - color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent, - shape = RoundedCornerShape(6.dp) - ) - .clickable { loadProfile(profile) } + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - 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( - "AUTO", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) - ) - } - } - } - Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray) + Column(modifier = Modifier.weight(1f)) { + Text( + text = saved.name, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = Color.White + ) + 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( + onClick = { + 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)) } - - Row { - TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) { - Text("Edit", fontSize = 11.sp) - } - TextButton( - onClick = { - HostStorage.deleteHost(context, profile.id) - savedHosts = HostStorage.getSavedHosts(context) - if (selectedHostId == profile.id) { - clearFields() - } - }, - contentPadding = PaddingValues(horizontal = 4.dp), - colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B)) - ) { - Text("Delete", fontSize = 11.sp) - } + IconButton( + onClick = { + HostStorage.deleteHost(context, saved.id) + savedHosts = HostStorage.getSavedHosts(context) + if (selectedHostId == saved.id) { + clearFields() + } + }, + modifier = Modifier.size(28.dp) + ) { + Text("✕", fontSize = 12.sp, color = Color(0xFFFF6B6B)) } } } } } } + HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 2.dp)) } Text( @@ -241,6 +235,7 @@ fun ConnectDialog( modifier = Modifier.fillMaxWidth() ) + // Terminal Model Row( horizontalArrangement = Arrangement.spacedBy(6.dp), 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( value = luName, onValueChange = { luName = it }, @@ -299,7 +309,7 @@ fun ConnectDialog( onClick = { val saved = saveCurrentProfile() if (saved != null) { - onConnect(saved.host, saved.port, saved.model, saved.luName) + onConnect(saved.host, saved.port, saved.model, saved.luName, saved.hostType) } } ) { diff --git a/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt b/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt index 223d770..0d7bd78 100644 --- a/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt +++ b/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt @@ -1,123 +1,227 @@ package org.pubvm.a3270.ui import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 org.lib3270j.ft.FTConfig +import org.pubvm.a3270.FTProgressState @Composable fun FileTransferDialog( + initialHostType: String = "TSO", + ftProgressState: FTProgressState = FTProgressState(), 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 localFile by remember { mutableStateOf("") } var isReceive by remember { mutableStateOf(true) } var isAscii by remember { mutableStateOf(true) } - var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) } - - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(150) - focusRequester.requestFocus() - keyboardController?.show() - } + var hostType by remember { mutableStateOf(initialTypeEnum) } + var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) } + var lreclStr by remember { mutableStateOf("") } + var blksizeStr by remember { mutableStateOf("") } + var overwrite by remember { mutableStateOf(true) } AlertDialog( onDismissRequest = onDismiss, - title = { Text("IND\$FILE File Transfer") }, + title = { + Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp) + }, text = { Column( modifier = Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(vertical = 4.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( value = hostFile, 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, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) + modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = localFile, 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, modifier = Modifier.fillMaxWidth() ) + // Transfer Direction Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Direction:") + Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Row { FilterChip( selected = isReceive, onClick = { isReceive = true }, - label = { Text("Receive (GET)") } + label = { Text("Receive (GET)", fontSize = 11.sp) } ) Spacer(modifier = Modifier.width(4.dp)) FilterChip( selected = !isReceive, onClick = { isReceive = false }, - label = { Text("Send (PUT)") } + label = { Text("Send (PUT)", fontSize = 11.sp) } ) } } + // Transfer Mode Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Mode:") + Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Row { FilterChip( selected = isAscii, onClick = { isAscii = true }, - label = { Text("ASCII") } + label = { Text("ASCII (Text)", fontSize = 11.sp) } ) Spacer(modifier = Modifier.width(4.dp)) FilterChip( selected = !isAscii, onClick = { isAscii = false }, - label = { Text("Binary") } + label = { Text("Binary", fontSize = 11.sp) } ) } } + // Host Type (TSO, CMS, CICS) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Host:") + Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Row { FTConfig.HostType.values().forEach { ht -> FilterChip( selected = (hostType == ht), onClick = { hostType = ht }, - label = { Text(ht.name) } + label = { Text(ht.name, fontSize = 11.sp) } ) 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 = { @@ -127,20 +231,27 @@ fun FileTransferDialog( val config = FTConfig().apply { setHostFilename(hostFile.trim()) setLocalFilename(localFile.trim()) - setReceive(isReceive) - setAscii(isAscii) + setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND) + setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY) 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) } - } + }, + enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning ) { Text("Start Transfer") } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text("Close") } } ) diff --git a/src/main/java/org/pubvm/a3270/ui/TerminalInputView.kt b/src/main/java/org/pubvm/a3270/ui/TerminalInputView.kt new file mode 100644 index 0000000..1603cdb --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/TerminalInputView.kt @@ -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) + } +}