Add input masking
Release a3270 / Build & Publish Release (push) Successful in 4m25s
Build and Test a3270 / Build Android APK (push) Successful in 6m25s

This commit is contained in:
2026-08-20 19:59:35 -04:00
parent 4b112d47f4
commit 60315098e2
7 changed files with 242 additions and 13 deletions
+5 -5
View File
@@ -6,11 +6,11 @@ cd "$SCRIPT_DIR"
echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME if not already set
if [ -z "$JAVA_HOME" ]; 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"
elif [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
# Set up JAVA_HOME (prefer JDK 21 if available)
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
fi
fi
@@ -34,6 +34,7 @@ import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.TerminalView
@@ -156,9 +157,12 @@ fun MainScreen(
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.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 showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -213,6 +217,8 @@ fun MainScreen(
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
onTapAddress = { addr ->
viewModel.setCursor(addr)
focusRequester.requestFocus()
@@ -279,6 +285,7 @@ fun MainScreen(
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
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()
}
)
}
}
@@ -18,6 +18,7 @@ import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings
import java.util.concurrent.Executors
import java.util.logging.Logger
@@ -55,6 +56,19 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val _isKeyboardLocked = MutableStateFlow(false)
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()
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
@@ -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,
onDisconnectClick: () -> Unit,
onFtClick: () -> Unit,
onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
@@ -51,7 +52,7 @@ fun TwoRowKeyBar(
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
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
private fun KeyButton(
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.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
@@ -57,6 +58,8 @@ fun TerminalView(
cols: Int,
cursorAddr: Int,
screenVersion: Long,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
@@ -70,6 +73,20 @@ fun TerminalView(
var showContextMenu by remember { mutableStateOf(false) }
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()) {
Canvas(
modifier = Modifier
@@ -213,9 +230,19 @@ fun TerminalView(
isBold = true
}
// Invisible fields (passwords / hidden)
// Invisible fields (passwords / hidden inputs)
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
@@ -226,10 +253,6 @@ fun TerminalView(
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
}
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
} else {
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(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),