2 Commits

Author SHA1 Message Date
rudi 60315098e2 Add input masking
Release a3270 / Build & Publish Release (push) Successful in 4m25s
Build and Test a3270 / Build Android APK (push) Successful in 6m25s
2026-08-20 19:59:35 -04:00
rudi 4b112d47f4 Add releases
Build and Test a3270 / Build Android APK (push) Successful in 4m26s
2026-08-20 19:30:59 -04:00
8 changed files with 242 additions and 42 deletions
-29
View File
@@ -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.
+4 -4
View File
@@ -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
@@ -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
@@ -156,9 +157,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 +217,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()
@@ -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()
}
)
}
} }
@@ -18,6 +18,7 @@ import org.lib3270j.listener.ScreenUpdateListener
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
@@ -55,6 +56,19 @@ 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()
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 private var client: Telnet3270Client? = null
var currentHost: String = "" var currentHost: String = ""
private set 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, 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),