diff --git a/build.sh b/build.sh index f6410c0..32900fb 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/src/main/java/org/pubvm/a3270/MainActivity.kt b/src/main/java/org/pubvm/a3270/MainActivity.kt index d1c4522..2ed1ae0 100644 --- a/src/main/java/org/pubvm/a3270/MainActivity.kt +++ b/src/main/java/org/pubvm/a3270/MainActivity.kt @@ -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() + } + ) + } } diff --git a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt index 08f0b71..6ba90ad 100644 --- a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt +++ b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt @@ -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 = _isKeyboardLocked.asStateFlow() + private val _maskHiddenInput = MutableStateFlow(AppSettings.isMaskHiddenInputEnabled(application)) + val maskHiddenInput: StateFlow = _maskHiddenInput.asStateFlow() + + private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application)) + val cursorBlink: StateFlow = _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 diff --git a/src/main/java/org/pubvm/a3270/storage/AppSettings.kt b/src/main/java/org/pubvm/a3270/storage/AppSettings.kt new file mode 100644 index 0000000..2ba02f2 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/storage/AppSettings.kt @@ -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() + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt index 5a00b88..75efd97 100644 --- a/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt +++ b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt @@ -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, diff --git a/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt b/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt new file mode 100644 index 0000000..0c84919 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/SettingsDialog.kt @@ -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) + } + } + } + } + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt index 199dcdd..d223792 100644 --- a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt +++ b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt @@ -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),