Add input buffering
Build and Test a3270 / Build Android APK (push) Successful in 4m35s

This commit is contained in:
2026-08-20 20:21:41 -04:00
parent 60315098e2
commit c767953c69
5 changed files with 142 additions and 152 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "org.pubvm.a3270"
minSdk 24
targetSdk 34
versionCode 1
versionName "0.1.0"
versionCode 2
versionName "0.1.1"
}
buildTypes {
+1 -1
View File
@@ -42,7 +42,7 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
fi
echo "Building debug APK..."
./gradlew assembleDebug --no-daemon
./gradlew assembleDebug
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
if [ -f "$APK_PATH" ]; then
+2 -1
View File
@@ -1,2 +1,3 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
@@ -136,6 +136,12 @@ class MainActivity : ComponentActivity() {
viewModel.tab()
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DEL) {
viewModel.backspace()
return true
} else if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
viewModel.resetKeyboard()
return true
}
}
return super.dispatchKeyEvent(event)
@@ -236,13 +242,7 @@ fun MainScreen(
onValueChange = { newValue ->
val text = newValue.text
if (text.isNotEmpty()) {
for (ch in text) {
if (ch == '\n' || ch == '\r') {
viewModel.sendAid(AID_ENTER)
} else {
viewModel.typeChar(ch)
}
}
viewModel.typeString(text)
textFieldValue = TextFieldValue("")
onClearShift()
}
+129 -140
View File
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -15,6 +16,7 @@ import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel
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.service.TerminalService
@@ -22,6 +24,20 @@ import org.pubvm.a3270.storage.AppSettings
import java.util.concurrent.Executors
import java.util.logging.Logger
sealed interface TerminalInputAction {
data class TypeText(val text: String) : TerminalInputAction
data class SendAid(val aidCode: Int) : TerminalInputAction
data object Backspace : TerminalInputAction
data object Tab : TerminalInputAction
data object BackTab : TerminalInputAction
data object CursorLeft : TerminalInputAction
data object CursorRight : TerminalInputAction
data object CursorUp : TerminalInputAction
data object CursorDown : TerminalInputAction
data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
}
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
@@ -32,6 +48,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
// Non-blocking FIFO input channel buffer
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@@ -62,13 +81,6 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
fun updateSettings(maskHidden: Boolean, blink: Boolean) {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
}
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
@@ -76,6 +88,98 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false
init {
// Start continuous background input buffer consumer
viewModelScope.launch(inputDispatcher) {
for (action in inputChannel) {
processInputAction(action)
// Drain any additional pending actions in the buffer before updating the UI
while (true) {
val next = inputChannel.tryReceive().getOrNull() ?: break
processInputAction(next)
}
val c = client
if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress
}
_screenVersion.value = System.currentTimeMillis()
}
}
}
private fun processInputAction(action: TerminalInputAction) {
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
try {
when (action) {
is TerminalInputAction.TypeText -> {
ip.isKeyboardLocked = false
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
}
}
}
is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false
ip.backspace()
}
is TerminalInputAction.Tab -> {
ip.tab()
}
is TerminalInputAction.BackTab -> {
ip.backTab()
}
is TerminalInputAction.CursorLeft -> {
ip.cursorLeft()
}
is TerminalInputAction.CursorRight -> {
ip.cursorRight()
}
is TerminalInputAction.CursorUp -> {
ip.cursorUp()
}
is TerminalInputAction.CursorDown -> {
ip.cursorDown()
}
is TerminalInputAction.Reset -> {
ip.reset()
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
}
}
}
} catch (e: Exception) {
log.warning("Error processing input action: ${e.message}")
}
}
fun updateSettings(maskHidden: Boolean, blink: Boolean) {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
}
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
if (_connectionState.value.isConnected()) return
currentHost = host
@@ -186,175 +290,59 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
}
fun typeChar(ch: Char) {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
val ip = c.inputProcessor
ip.isKeyboardLocked = false // Ensure keyboard lock is cleared on user typing
val buf = c.screenBuffer
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error typing char: ${e.message}")
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
}
fun typeString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun pasteString(text: String) {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
val ip = c.inputProcessor
ip.isKeyboardLocked = false
for (ch in text) {
if (ch == '\n' || ch == '\r') {
ip.tab()
} else if (ch >= ' ') {
ip.typeCharacter(ch)
}
}
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error pasting text: ${e.message}")
}
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun backspace() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.isKeyboardLocked = false
c.inputProcessor.backspace()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error backspacing: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.Backspace)
}
fun tab() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.tab()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error tabbing: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.Tab)
}
fun backTab() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.backTab()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error back-tabbing: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.BackTab)
}
fun resetKeyboard() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.reset()
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error resetting keyboard: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.Reset)
}
fun sendAid(aidCode: Int) {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.setKeyboardLocked(false)
c.inputProcessor.sendAid(aidCode)
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error sending AID: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
}
fun setCursor(baddr: Int) {
val buf = _screenBuffer.value ?: return
if (baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = baddr
_cursorAddress.value = baddr
_screenVersion.value = System.currentTimeMillis()
}
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
}
fun cursorLeft() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorLeft()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorLeft: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.CursorLeft)
}
fun cursorUp() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorUp()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorUp: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.CursorUp)
}
fun cursorDown() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorDown()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorDown: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.CursorDown)
}
fun cursorRight() {
val c = client ?: return
viewModelScope.launch(inputDispatcher) {
try {
c.inputProcessor.cursorRight()
_cursorAddress.value = c.screenBuffer.cursorAddress
_screenVersion.value = System.currentTimeMillis()
} catch (e: Exception) {
log.warning("Error cursorRight: ${e.message}")
}
}
inputChannel.trySend(TerminalInputAction.CursorRight)
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
@@ -429,6 +417,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
override fun onCleared() {
super.onCleared()
client?.disconnect()
inputChannel.close()
inputExecutor.shutdown()
}
}