Debug input
This commit is contained in:
@@ -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<TerminalInputView?>(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()) {
|
||||
// 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)
|
||||
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
|
||||
onSendAid = { aid ->
|
||||
viewModel.sendAid(aid)
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(1.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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<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) {
|
||||
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()
|
||||
|
||||
@@ -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 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,39 +124,31 @@ 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(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
savedHosts.forEach { profile ->
|
||||
val isSelected = profile.id == selectedHostId
|
||||
savedHosts.forEach { saved ->
|
||||
val isSelected = (saved.id == selectedHostId)
|
||||
Surface(
|
||||
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
|
||||
.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) }
|
||||
.clickable { loadProfile(saved) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -162,50 +158,48 @@ fun ConnectDialog(
|
||||
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 = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
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)
|
||||
if (selectedHostId == profile.id) {
|
||||
if (selectedHostId == saved.id) {
|
||||
clearFields()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Text("Delete", fontSize = 11.sp)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user