Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c767953c69
|
|||
|
60315098e2
|
|||
|
4b112d47f4
|
@@ -94,35 +94,6 @@ build/outputs/apk/debug/a3270-debug.apk
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏷️ How to Tag & Publish (Beta) Releases
|
|
||||||
|
|
||||||
Releases can be published automatically to Gitea via Git tags:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create a version tag (stable or beta)
|
|
||||||
git tag -a v0.1.0-beta.1 -m "Release v0.1.0-beta.1"
|
|
||||||
|
|
||||||
# Or a stable release:
|
|
||||||
git tag -a v0.1.0 -m "Release v0.1.0"
|
|
||||||
|
|
||||||
# 2. Push the tag to Gitea
|
|
||||||
git push origin v0.1.0-beta.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Pushing any `v*` tag triggers the automated release workflow (`.gitea/workflows/release.yaml`), which:
|
|
||||||
1. Automatically detects pre-releases (tags containing `beta`, `alpha`, `rc`, `pre`, `preview`, or `dev`) and marks them as **Pre-release** on Gitea.
|
|
||||||
2. Builds the APK and packages it.
|
|
||||||
3. Publishes a new Gitea Release with `a3270.apk` attached automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Topics & Discoverability
|
|
||||||
|
|
||||||
Suggested keywords/topics for repository indexing:
|
|
||||||
`tn3270` · `tn3270e` · `ibm-3270` · `mainframe` · `terminal-emulator` · `android` · `android-app` · `jetpack-compose` · `kotlin` · `x3270` · `j3270` · `ind-file` · `file-transfer` · `ebcdic` · `z-vm` · `z-os` · `cms` · `tso` · `cics`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
|
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "org.pubvm.a3270"
|
applicationId "org.pubvm.a3270"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "0.1.0"
|
versionName "0.1.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
echo "=== Building a3270 (Android APK) ==="
|
echo "=== Building a3270 (Android APK) ==="
|
||||||
|
|
||||||
# Set up JAVA_HOME if not already set
|
# Set up JAVA_HOME (prefer JDK 21 if available)
|
||||||
if [ -z "$JAVA_HOME" ]; then
|
|
||||||
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
||||||
elif [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
elif [ -z "$JAVA_HOME" ]; then
|
||||||
|
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -42,7 +42,7 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building debug APK..."
|
echo "Building debug APK..."
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug
|
||||||
|
|
||||||
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
||||||
if [ -f "$APK_PATH" ]; then
|
if [ -f "$APK_PATH" ]; then
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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.TwoRowKeyBar
|
||||||
import org.pubvm.a3270.ui.OiaStatusBar
|
import org.pubvm.a3270.ui.OiaStatusBar
|
||||||
import org.pubvm.a3270.ui.TerminalView
|
import org.pubvm.a3270.ui.TerminalView
|
||||||
@@ -135,6 +136,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
viewModel.tab()
|
viewModel.tab()
|
||||||
}
|
}
|
||||||
return true
|
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)
|
return super.dispatchKeyEvent(event)
|
||||||
@@ -156,9 +163,12 @@ fun MainScreen(
|
|||||||
val oiaText by viewModel.oiaText.collectAsState()
|
val oiaText by viewModel.oiaText.collectAsState()
|
||||||
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
||||||
val screenVersion by viewModel.screenVersion.collectAsState()
|
val screenVersion by viewModel.screenVersion.collectAsState()
|
||||||
|
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
|
||||||
|
val cursorBlink by viewModel.cursorBlink.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) }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
@@ -213,6 +223,8 @@ fun MainScreen(
|
|||||||
cols = cols,
|
cols = cols,
|
||||||
cursorAddr = cursorAddr,
|
cursorAddr = cursorAddr,
|
||||||
screenVersion = screenVersion,
|
screenVersion = screenVersion,
|
||||||
|
maskHiddenFields = maskHiddenInput,
|
||||||
|
blinkCursor = cursorBlink,
|
||||||
onTapAddress = { addr ->
|
onTapAddress = { addr ->
|
||||||
viewModel.setCursor(addr)
|
viewModel.setCursor(addr)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
@@ -230,13 +242,7 @@ fun MainScreen(
|
|||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
val text = newValue.text
|
val text = newValue.text
|
||||||
if (text.isNotEmpty()) {
|
if (text.isNotEmpty()) {
|
||||||
for (ch in text) {
|
viewModel.typeString(text)
|
||||||
if (ch == '\n' || ch == '\r') {
|
|
||||||
viewModel.sendAid(AID_ENTER)
|
|
||||||
} else {
|
|
||||||
viewModel.typeChar(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
textFieldValue = TextFieldValue("")
|
textFieldValue = TextFieldValue("")
|
||||||
onClearShift()
|
onClearShift()
|
||||||
}
|
}
|
||||||
@@ -279,6 +285,7 @@ fun MainScreen(
|
|||||||
onConnectClick = { showConnectDialog = true },
|
onConnectClick = { showConnectDialog = true },
|
||||||
onDisconnectClick = { viewModel.disconnect() },
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
onFtClick = { showFtDialog = true },
|
onFtClick = { showFtDialog = true },
|
||||||
|
onSettingsClick = { showSettingsDialog = true },
|
||||||
onSendAid = { aid ->
|
onSendAid = { aid ->
|
||||||
viewModel.sendAid(aid)
|
viewModel.sendAid(aid)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
@@ -345,4 +352,17 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSettingsDialog) {
|
||||||
|
SettingsDialog(
|
||||||
|
initialMaskHiddenInput = maskHiddenInput,
|
||||||
|
initialCursorBlink = cursorBlink,
|
||||||
|
onDismiss = { showSettingsDialog = false },
|
||||||
|
onSave = { mask, blink ->
|
||||||
|
showSettingsDialog = false
|
||||||
|
viewModel.updateSettings(mask, blink)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -15,12 +16,28 @@ import org.lib3270j.Telnet3270Client
|
|||||||
import org.lib3270j.TerminalModel
|
import org.lib3270j.TerminalModel
|
||||||
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.faIsProtected
|
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
import org.pubvm.a3270.service.TerminalService
|
import org.pubvm.a3270.service.TerminalService
|
||||||
|
import org.pubvm.a3270.storage.AppSettings
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.logging.Logger
|
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) {
|
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
||||||
@@ -31,6 +48,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val inputExecutor = Executors.newSingleThreadExecutor()
|
private val inputExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
|
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)
|
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
|
||||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
@@ -55,6 +75,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _isKeyboardLocked = MutableStateFlow(false)
|
private val _isKeyboardLocked = MutableStateFlow(false)
|
||||||
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
|
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
|
||||||
|
|
||||||
|
private val _maskHiddenInput = MutableStateFlow(AppSettings.isMaskHiddenInputEnabled(application))
|
||||||
|
val maskHiddenInput: StateFlow<Boolean> = _maskHiddenInput.asStateFlow()
|
||||||
|
|
||||||
|
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
|
||||||
|
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
|
||||||
|
|
||||||
private var client: Telnet3270Client? = null
|
private var client: Telnet3270Client? = null
|
||||||
var currentHost: String = ""
|
var currentHost: String = ""
|
||||||
private set
|
private set
|
||||||
@@ -62,6 +88,98 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private var lastScreenContentHash: Int = 0
|
private var lastScreenContentHash: Int = 0
|
||||||
private var hasInitialScreenLoaded: Boolean = false
|
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 = "") {
|
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
|
||||||
if (_connectionState.value.isConnected()) return
|
if (_connectionState.value.isConnected()) return
|
||||||
currentHost = host
|
currentHost = host
|
||||||
@@ -172,175 +290,59 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun typeChar(ch: Char) {
|
fun typeChar(ch: Char) {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun typeString(text: String) {
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
inputChannel.trySend(TerminalInputAction.TypeText(text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pasteString(text: String) {
|
fun pasteString(text: String) {
|
||||||
val c = client ?: return
|
if (text.isNotEmpty()) {
|
||||||
viewModelScope.launch(inputDispatcher) {
|
inputChannel.trySend(TerminalInputAction.TypeText(text))
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backspace() {
|
fun backspace() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Backspace)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tab() {
|
fun tab() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Tab)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backTab() {
|
fun backTab() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.BackTab)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetKeyboard() {
|
fun resetKeyboard() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Reset)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.reset()
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error resetting keyboard: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendAid(aidCode: Int) {
|
fun sendAid(aidCode: Int) {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCursor(baddr: Int) {
|
fun setCursor(baddr: Int) {
|
||||||
val buf = _screenBuffer.value ?: return
|
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
|
||||||
if (baddr in 0 until (buf.rows * buf.cols)) {
|
|
||||||
buf.cursorAddress = baddr
|
|
||||||
_cursorAddress.value = baddr
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorLeft() {
|
fun cursorLeft() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorLeft)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorUp() {
|
fun cursorUp() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorUp)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorDown() {
|
fun cursorDown() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorDown)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorRight() {
|
fun cursorRight() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorRight)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
||||||
@@ -415,6 +417,7 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
|
inputChannel.close()
|
||||||
inputExecutor.shutdown()
|
inputExecutor.shutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.pubvm.a3270.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
object AppSettings {
|
||||||
|
private const val PREFS_NAME = "a3270_settings"
|
||||||
|
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
|
||||||
|
private const val KEY_CURSOR_BLINK = "cursor_blink"
|
||||||
|
|
||||||
|
private fun getPrefs(context: Context): SharedPreferences {
|
||||||
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMaskHiddenInputEnabled(context: Context): Boolean {
|
||||||
|
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
|
||||||
|
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCursorBlinkEnabled(context: Context): Boolean {
|
||||||
|
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
|
||||||
|
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ fun TwoRowKeyBar(
|
|||||||
onConnectClick: () -> Unit,
|
onConnectClick: () -> Unit,
|
||||||
onDisconnectClick: () -> Unit,
|
onDisconnectClick: () -> Unit,
|
||||||
onFtClick: () -> Unit,
|
onFtClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit = {},
|
||||||
onSendAid: (Int) -> Unit,
|
onSendAid: (Int) -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
onTab: () -> Unit,
|
onTab: () -> Unit,
|
||||||
@@ -51,7 +52,7 @@ fun TwoRowKeyBar(
|
|||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
|
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
KeyButton(
|
KeyButton(
|
||||||
label = "⋮",
|
label = "⋮",
|
||||||
@@ -89,6 +90,13 @@ fun TwoRowKeyBar(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSettingsClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +186,7 @@ fun TwoRowKeyBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KeyButton(
|
private fun KeyButton(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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 androidx.compose.ui.window.Dialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsDialog(
|
||||||
|
initialMaskHiddenInput: Boolean,
|
||||||
|
initialCursorBlink: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
|
||||||
|
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Terminal Settings",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Setting 1: Mask Protected/Hidden Input with '*'
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Show '*' for Hidden Fields",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Display asterisks for typed characters in non-display / password fields rather than blank spaces.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = maskHiddenInput,
|
||||||
|
onCheckedChange = { maskHiddenInput = it },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = Color(0xFF2B8A3E)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider(color = Color(0xFF2C2D30))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Setting 2: Blinking Cursor
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Blinking Cursor",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Blink the terminal cursor to clearly indicate current screen position.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = cursorBlink,
|
||||||
|
onCheckedChange = { cursorBlink = it },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = Color(0xFF2B8A3E)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(maskHiddenInput, cursorBlink)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
|
) {
|
||||||
|
Text("Save", color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
import org.lib3270j.screen.ExtendedAttribute
|
import org.lib3270j.screen.ExtendedAttribute
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
@@ -57,6 +58,8 @@ fun TerminalView(
|
|||||||
cols: Int,
|
cols: Int,
|
||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
screenVersion: Long,
|
screenVersion: Long,
|
||||||
|
maskHiddenFields: Boolean = true,
|
||||||
|
blinkCursor: Boolean = true,
|
||||||
onTapAddress: (Int) -> Unit,
|
onTapAddress: (Int) -> Unit,
|
||||||
onPasteText: (String) -> Unit = {},
|
onPasteText: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@@ -70,6 +73,20 @@ fun TerminalView(
|
|||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
|
||||||
|
// Blinking cursor state (~530ms interval matching j3270)
|
||||||
|
var cursorVisible by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(cursorAddr, blinkCursor) {
|
||||||
|
if (!blinkCursor) {
|
||||||
|
cursorVisible = true
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
cursorVisible = true
|
||||||
|
while (true) {
|
||||||
|
delay(530L)
|
||||||
|
cursorVisible = !cursorVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -213,9 +230,19 @@ fun TerminalView(
|
|||||||
isBold = true
|
isBold = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invisible fields (passwords / hidden)
|
// Invisible fields (passwords / hidden inputs)
|
||||||
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
||||||
continue
|
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||||
|
charVal = '*'
|
||||||
|
} else {
|
||||||
|
// Blanks out character
|
||||||
|
charVal = ' '
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal visible character
|
||||||
|
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||||
|
charVal = ea.ucs4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended Graphic Rendition
|
// Extended Graphic Rendition
|
||||||
@@ -226,10 +253,6 @@ fun TerminalView(
|
|||||||
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||||
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
|
||||||
charVal = ea.ucs4
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
}
|
}
|
||||||
@@ -258,7 +281,8 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addr == cursorAddr && !isSelected) {
|
// Cursor indicator (respects blinking toggle & timer)
|
||||||
|
if (addr == cursorAddr && !isSelected && cursorVisible) {
|
||||||
drawRect(
|
drawRect(
|
||||||
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(left, top),
|
||||||
|
|||||||
Reference in New Issue
Block a user