Debug input
This commit is contained in:
@@ -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)
|
onClearShift()
|
||||||
textFieldValue = TextFieldValue("")
|
}
|
||||||
onClearShift()
|
onSendAid = { aid ->
|
||||||
}
|
viewModel.sendAid(aid)
|
||||||
},
|
}
|
||||||
textStyle = TextStyle(color = Color.Transparent),
|
onBackspace = {
|
||||||
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) {
|
|
||||||
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,92 +124,82 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
.heightIn(max = 95.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
) {
|
||||||
Column(
|
savedHosts.forEach { saved ->
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
val isSelected = (saved.id == selectedHostId)
|
||||||
modifier = Modifier.fillMaxWidth()
|
Surface(
|
||||||
) {
|
shape = RoundedCornerShape(6.dp),
|
||||||
savedHosts.forEach { profile ->
|
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
|
||||||
val isSelected = profile.id == selectedHostId
|
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
|
||||||
Surface(
|
modifier = Modifier
|
||||||
shape = RoundedCornerShape(6.dp),
|
.fillMaxWidth()
|
||||||
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719),
|
.clickable { loadProfile(saved) }
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.border(
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
width = if (isSelected) 1.dp else 0.dp,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
shape = RoundedCornerShape(6.dp)
|
|
||||||
)
|
|
||||||
.clickable { loadProfile(profile) }
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
modifier = Modifier
|
Text(
|
||||||
.fillMaxWidth()
|
text = saved.name,
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
fontWeight = FontWeight.SemiBold,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
fontSize = 12.sp,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
color = Color.White
|
||||||
) {
|
)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})" +
|
||||||
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
|
if (saved.autoConnect) " [Auto-Connect]" else "",
|
||||||
if (profile.autoConnect) {
|
fontSize = 10.sp,
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
color = Color.LightGray
|
||||||
Surface(
|
)
|
||||||
color = Color(0xFF2B8A3E),
|
}
|
||||||
shape = RoundedCornerShape(4.dp)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
IconButton(
|
||||||
Text(
|
onClick = {
|
||||||
"AUTO",
|
loadProfile(saved)
|
||||||
fontSize = 9.sp,
|
val hostToConn = saveCurrentProfile() ?: saved
|
||||||
fontWeight = FontWeight.Bold,
|
onConnect(hostToConn.host, hostToConn.port, hostToConn.model, hostToConn.luName, hostToConn.hostType)
|
||||||
color = Color.White,
|
},
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
) {
|
||||||
}
|
Text("▶", fontSize = 13.sp, color = Color(0xFF51CF66))
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
|
|
||||||
}
|
}
|
||||||
|
IconButton(
|
||||||
Row {
|
onClick = {
|
||||||
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
HostStorage.deleteHost(context, saved.id)
|
||||||
Text("Edit", fontSize = 11.sp)
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
}
|
if (selectedHostId == saved.id) {
|
||||||
TextButton(
|
clearFields()
|
||||||
onClick = {
|
}
|
||||||
HostStorage.deleteHost(context, profile.id)
|
},
|
||||||
savedHosts = HostStorage.getSavedHosts(context)
|
modifier = Modifier.size(28.dp)
|
||||||
if (selectedHostId == profile.id) {
|
) {
|
||||||
clearFields()
|
Text("✕", fontSize = 12.sp, color = Color(0xFFFF6B6B))
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
|
|
||||||
) {
|
|
||||||
Text("Delete", fontSize = 11.sp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user