first commit

This commit is contained in:
2026-08-20 17:28:09 -04:00
commit 3bca77ffb5
13 changed files with 2366 additions and 0 deletions
@@ -0,0 +1,348 @@
package org.pubvm.a3270
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
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.TwoRowKeyBar
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.TerminalView
import androidx.compose.foundation.layout.size
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
class MainActivity : ComponentActivity() {
private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101)
}
}
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
background = Color(0xFF000000),
surface = Color(0xFF1E1E1E)
)
) {
MainScreen(
viewModel = viewModel,
isShiftPressed = isShiftPressedState.value,
onClearShift = { isShiftPressedState.value = false }
)
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift) fNum + 12 else fNum
val pfAids = intArrayOf(
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
viewModel.cursorLeft()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
viewModel.cursorUp()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
viewModel.cursorDown()
return true
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
viewModel.cursorRight()
return true
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
viewModel.sendAid(AID_ENTER)
return true
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
if (event.isShiftPressed) {
viewModel.backTab()
} else {
viewModel.tab()
}
return true
}
}
return super.dispatchKeyEvent(event)
}
}
@Composable
fun MainScreen(
viewModel: TerminalViewModel,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {}
) {
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsState()
val screenBuffer by viewModel.screenBuffer.collectAsState()
val rows by viewModel.rows.collectAsState()
val cols by viewModel.cols.collectAsState()
val cursorAddr by viewModel.cursorAddress.collectAsState()
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
val screenVersion by viewModel.screenVersion.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.isAppInBackground = true
} else if (event == Lifecycle.Event.ON_START) {
viewModel.isAppInBackground = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName)
}
}
LaunchedEffect(connectionState) {
if (connectionState.isConnected()) {
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
TerminalService.start(context, activeHost)
} else {
TerminalService.stop(context)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays)
TerminalView(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
onTapAddress = { addr ->
viewModel.setCursor(addr)
focusRequester.requestFocus()
keyboardController?.show()
},
onPasteText = { text ->
viewModel.pasteString(text)
},
modifier = Modifier.weight(1f)
)
// Transparent BasicTextField maintaining active Android IME connection
BasicTextField(
value = textFieldValue,
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)
}
}
textFieldValue = TextFieldValue("")
onClearShift()
}
},
textStyle = TextStyle(color = Color.Transparent),
cursorBrush = SolidColor(Color.Transparent),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.None
),
keyboardActions = KeyboardActions(
onDone = { viewModel.sendAid(AID_ENTER) },
onGo = { viewModel.sendAid(AID_ENTER) },
onSend = { viewModel.sendAid(AID_ENTER) },
onNext = {
// Stubbed for future form field navigation
}
),
modifier = Modifier
.size(1.dp)
.focusRequester(focusRequester)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
viewModel.backspace()
onClearShift()
true
} else {
false
}
}
)
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
focusRequester.requestFocus()
},
onReset = {
viewModel.resetKeyboard()
focusRequester.requestFocus()
},
onTab = {
viewModel.tab()
focusRequester.requestFocus()
},
onBackTab = {
viewModel.backTab()
focusRequester.requestFocus()
},
onCursorLeft = {
viewModel.cursorLeft()
focusRequester.requestFocus()
},
onCursorUp = {
viewModel.cursorUp()
focusRequester.requestFocus()
},
onCursorDown = {
viewModel.cursorDown()
focusRequester.requestFocus()
},
onCursorRight = {
viewModel.cursorRight()
focusRequester.requestFocus()
}
)
// OIA Status Bar
OiaStatusBar(
connectionState = connectionState,
currentHost = viewModel.currentHost,
isKeyboardLocked = isKeyboardLocked,
oiaText = oiaText,
cursorAddr = cursorAddr,
rows = rows,
cols = cols
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName ->
showConnectDialog = false
viewModel.connect(host, port, model, luName)
focusRequester.requestFocus()
}
)
}
if (showFtDialog) {
FileTransferDialog(
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
showFtDialog = false
}
)
}
}
@@ -0,0 +1,420 @@
package org.pubvm.a3270
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel
import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.service.TerminalService
import java.util.concurrent.Executors
import java.util.logging.Logger
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
var isAppInBackground: Boolean = false
// Dedicated single-thread FIFO queue for all local keyboard and cursor actions
private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _screenBuffer = MutableStateFlow<ScreenBuffer?>(null)
val screenBuffer: StateFlow<ScreenBuffer?> = _screenBuffer.asStateFlow()
private val _rows = MutableStateFlow(24)
val rows: StateFlow<Int> = _rows.asStateFlow()
private val _cols = MutableStateFlow(80)
val cols: StateFlow<Int> = _cols.asStateFlow()
private val _cursorAddress = MutableStateFlow(0)
val cursorAddress: StateFlow<Int> = _cursorAddress.asStateFlow()
private val _oiaText = MutableStateFlow("Disconnected")
val oiaText: StateFlow<String> = _oiaText.asStateFlow()
private val _screenVersion = MutableStateFlow(0L)
val screenVersion: StateFlow<Long> = _screenVersion.asStateFlow()
private val _isKeyboardLocked = MutableStateFlow(false)
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
if (_connectionState.value.isConnected()) return
currentHost = host
lastScreenContentHash = 0
hasInitialScreenLoaded = false
viewModelScope.launch(Dispatchers.IO) {
try {
_oiaText.value = "Connecting to $host:$port..."
val model = when (modelNum) {
3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
else -> TerminalModel.IBM_3279_2
}
val config = ConnectionConfig(host, port, model).apply {
if (luName.isNotBlank()) {
setLuName(luName)
}
}
val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient
newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState
_oiaText.value = if (newState.isFullSession()) {
"3270 Connected ($host)"
} else if (newState.isHalfConnected()) {
"Connecting..."
} else {
"Disconnected"
}
}
override fun onConnectionError(message: String) {
log.warning("Connection error: $message")
_oiaText.value = "Error: $message"
}
})
newClient.inputProcessor.setLockStateListener { locked ->
_isKeyboardLocked.value = locked
}
newClient.addScreenUpdateListener(object : ScreenUpdateListener {
override fun onScreenUpdated() {
val buf = newClient.screenBuffer
_screenBuffer.value = buf
if (buf != null) {
_rows.value = buf.rows
_cols.value = buf.cols
_cursorAddress.value = buf.cursorAddress
}
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_screenVersion.value = System.currentTimeMillis()
val newHash = computeScreenContentHash(buf)
val contentChanged = (newHash != lastScreenContentHash)
if (!hasInitialScreenLoaded) {
if (buf != null && newHash != 0) {
hasInitialScreenLoaded = true
lastScreenContentHash = newHash
}
} else if (contentChanged) {
lastScreenContentHash = newHash
if (isAppInBackground) {
val snippet = extractScreenSnippet(buf)
val activeHost = currentHost.ifBlank { "Mainframe" }
TerminalService.notifyScreenUpdate(getApplication(), activeHost, snippet)
}
}
}
override fun onSoundAlarm() {
log.fine("Sound Alarm")
}
})
_rows.value = model.defaultRows
_cols.value = model.defaultCols
_screenBuffer.value = newClient.screenBuffer
newClient.connect()
} catch (e: Exception) {
log.severe("Failed to connect: ${e.message}")
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}"
}
}
}
fun disconnect() {
viewModelScope.launch(Dispatchers.IO) {
try {
client?.disconnect()
client = null
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Disconnected"
} catch (e: Exception) {
log.warning("Error disconnecting: ${e.message}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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()
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
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}")
}
}
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received"
val rows = buf.rows
val cols = buf.cols
if (rows <= 0 || cols <= 0) return "Mainframe screen update received"
val curAddr = _cursorAddress.value
val cursorRow = (curAddr / cols).coerceIn(0, rows - 1)
// 1. Check bottom 3 lines first (where 3270 status, errors, reader & I/S MSGs land)
val minBottomRow = (rows - 3).coerceAtLeast(1)
for (r in (rows - 1) downTo minBottomRow) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4 && !line.startsWith("***") && !line.startsWith("===")) {
return line
}
}
// 2. Check cursor row if not row 0
if (cursorRow > 0) {
val cursorLine = getLineText(buf, cursorRow, cols).trim()
if (cursorLine.length >= 4) {
return cursorLine
}
}
// 3. Scan rows from bottom to top, skipping static header row 0
for (r in (rows - 1) downTo 1) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4) {
return line
}
}
return getLineText(buf, 0, cols).trim().ifBlank { "Mainframe screen update received" }
}
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder()
for (c in 0 until cols) {
val addr = row * cols + c
val cell = buf.getCell(addr)
if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) {
sb.append(ch)
} else {
sb.append(' ')
}
} else {
sb.append(' ')
}
}
return sb.toString().replace(Regex("\\s+"), " ").trim()
}
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0
val sb = StringBuilder()
val total = buf.rows * buf.cols
for (i in 0 until total) {
val cell = buf.getCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4)
}
}
return sb.toString().hashCode()
}
override fun onCleared() {
super.onCleared()
client?.disconnect()
inputExecutor.shutdown()
}
}
@@ -0,0 +1,176 @@
package org.pubvm.a3270.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity
import org.pubvm.a3270.R
class TerminalService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
if (action == ACTION_STOP) {
try {
stopForeground(STOP_FOREGROUND_REMOVE)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.cancel(NOTIFICATION_ID)
} catch (e: Exception) {
e.printStackTrace()
}
stopSelf()
return START_NOT_STICKY
}
val host = intent?.getStringExtra(EXTRA_HOST) ?: "Mainframe"
createNotificationChannels()
val notificationIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, notificationIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification: Notification = NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
.setContentTitle("a3270 Active Session")
.setContentText("Connected to $host (Socket Active)")
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
try {
if (Build.VERSION.SDK_INT >= 34) {
startForeground(
NOTIFICATION_ID,
notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
manager?.notify(NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
}
return START_STICKY
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
val manager = getSystemService(NotificationManager::class.java) ?: return
// Delete obsolete channels so system settings reset cleanly
try {
manager.deleteNotificationChannel("a3270_connection_channel")
manager.deleteNotificationChannel("a3270_connection_v2")
} catch (_: Exception) {}
// 1. Service Persistent Channel
val serviceChannel = NotificationChannel(
SERVICE_CHANNEL_ID,
"a3270 Terminal Connection",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Keeps the 3270 TCP socket connection alive in the background"
setShowBadge(true)
}
manager.createNotificationChannel(serviceChannel)
// 2. Screen Update Notification Channel
val updateChannel = NotificationChannel(
UPDATE_CHANNEL_ID,
"a3270 Screen Updates",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifies when mainframe screen updates occur in the background"
enableVibration(true)
}
manager.createNotificationChannel(updateChannel)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
companion object {
private const val SERVICE_CHANNEL_ID = "a3270_connection_v3"
private const val UPDATE_CHANNEL_ID = "a3270_screen_updates"
private const val NOTIFICATION_ID = 32701
private const val UPDATE_NOTIFICATION_ID = 32702
const val EXTRA_HOST = "extra_host"
const val ACTION_STOP = "action_stop"
fun start(context: Context, host: String) {
try {
val intent = Intent(context, TerminalService::class.java).apply {
putExtra(EXTRA_HOST, host)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
fun stop(context: Context) {
try {
val intent = Intent(context, TerminalService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun notifyScreenUpdate(context: Context, host: String, textSnippet: String) {
try {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val displayHost = if (host.isNotBlank()) host else "Mainframe"
val notification = NotificationCompat.Builder(context, UPDATE_CHANNEL_ID)
.setContentTitle("$displayHost has an alert.")
.setContentText(textSnippet)
.setStyle(NotificationCompat.BigTextStyle().bigText(textSnippet))
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setContentIntent(pendingIntent)
.build()
manager.notify(UPDATE_NOTIFICATION_ID, notification)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
@@ -0,0 +1,105 @@
package org.pubvm.a3270.storage
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
data class SavedHost(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int = 23,
val model: Int = 2,
val luName: String = "",
val autoConnect: Boolean = false
) {
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("name", name)
put("host", host)
put("port", port)
put("model", model)
put("luName", luName)
put("autoConnect", autoConnect)
}
}
companion object {
fun fromJson(json: JSONObject): SavedHost {
return SavedHost(
id = json.optString("id", UUID.randomUUID().toString()),
name = json.optString("name", "Mainframe"),
host = json.optString("host", "127.0.0.1"),
port = json.optInt("port", 23),
model = json.optInt("model", 2),
luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false)
)
}
}
}
object HostStorage {
private const val PREFS_NAME = "a3270_host_prefs"
private const val KEY_SAVED_HOSTS = "saved_hosts"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getSavedHosts(context: Context): List<SavedHost> {
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
val list = mutableListOf<SavedHost>()
try {
val jsonArray = JSONArray(jsonStr)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
list.add(SavedHost.fromJson(obj))
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun saveHost(context: Context, host: SavedHost) {
val currentHosts = getSavedHosts(context).toMutableList()
val index = currentHosts.indexOfFirst { it.id == host.id }
// If this host is set to autoConnect, unset autoConnect on all others
val updatedHost = if (host.autoConnect) {
currentHosts.indices.forEach { i ->
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
}
host
} else {
host
}
if (index >= 0) {
currentHosts[index] = updatedHost
} else {
currentHosts.add(updatedHost)
}
saveAll(context, currentHosts)
}
fun deleteHost(context: Context, hostId: String) {
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
saveAll(context, currentHosts)
}
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
private fun saveAll(context: Context, hosts: List<SavedHost>) {
val array = JSONArray()
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
}
@@ -0,0 +1,312 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.platform.LocalContext
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
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost
@Composable
fun ConnectDialog(
onDismiss: () -> Unit,
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("127.0.0.1") }
var portStr by remember { mutableStateOf("23") }
var modelNum by remember { mutableIntStateOf(2) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
profileName = saved.name
host = saved.host
portStr = saved.port.toString()
modelNum = saved.model
luName = saved.luName
autoConnect = saved.autoConnect
}
fun clearFields() {
selectedHostId = null
profileName = ""
host = "127.0.0.1"
portStr = "23"
modelNum = 2
luName = ""
autoConnect = false
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: 23
if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = host.trim(),
port = port,
model = modelNum,
luName = luName.trim(),
autoConnect = autoConnect
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
selectedHostId = hostToSave.id
return hostToSave
}
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.96f)
.wrapContentHeight()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Title Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
TextButton(
onClick = { clearFields() },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("+ New Profile", fontSize = 12.sp)
}
}
// Scrollable Content Container
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Saved Profiles List
if (savedHosts.isNotEmpty()) {
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 95.dp)
.verticalScroll(rememberScrollState())
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
savedHosts.forEach { profile ->
val isSelected = profile.id == selectedHostId
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719),
modifier = Modifier
.fillMaxWidth()
.border(
width = if (isSelected) 1.dp else 0.dp,
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
shape = RoundedCornerShape(6.dp)
)
.clickable { loadProfile(profile) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
if (profile.autoConnect) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF2B8A3E),
shape = RoundedCornerShape(4.dp)
) {
Text(
"AUTO",
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
}
Row {
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
Text("Edit", fontSize = 11.sp)
}
TextButton(
onClick = {
HostStorage.deleteHost(context, profile.id)
savedHosts = HostStorage.getSavedHosts(context)
if (selectedHostId == profile.id) {
clearFields()
}
},
contentPadding = PaddingValues(horizontal = 4.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
) {
Text("Delete", fontSize = 11.sp)
}
}
}
}
}
}
}
}
Text(
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host / IP Address") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port (default 23)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Model:", fontSize = 11.sp, color = Color.Gray)
listOf(2, 3, 4, 5).forEach { m ->
FilterChip(
selected = (modelNum == m),
onClick = { modelNum = m },
label = { Text("M$m", fontSize = 11.sp) }
)
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name (Optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { autoConnect = !autoConnect }
) {
Checkbox(
checked = autoConnect,
onCheckedChange = { autoConnect = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp)
}
}
}
// Action Buttons Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(4.dp))
OutlinedButton(onClick = { saveCurrentProfile() }) {
Text("Save")
}
Spacer(modifier = Modifier.width(4.dp))
Button(
onClick = {
val saved = saveCurrentProfile()
if (saved != null) {
onConnect(saved.host, saved.port, saved.model, saved.luName)
}
}
) {
Text("Connect")
}
}
}
}
}
}
@@ -0,0 +1,147 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import org.lib3270j.ft.FTConfig
@Composable
fun FileTransferDialog(
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit
) {
var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
keyboardController?.show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("IND\$FILE File Transfer") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host File Name") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
OutlinedTextField(
value = localFile,
onValueChange = { localFile = it },
label = { Text("Local File Path") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:")
Row {
FilterChip(
selected = isReceive,
onClick = { isReceive = true },
label = { Text("Receive (GET)") }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = { isReceive = false },
label = { Text("Send (PUT)") }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:")
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII") }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary") }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host:")
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
}
},
confirmButton = {
Button(
onClick = {
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim())
setReceive(isReceive)
setAscii(isAscii)
setHostType(hostType)
}
onStartTransfer(config)
}
}
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@@ -0,0 +1,213 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.focus.focusProperties
import org.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.*
@Composable
fun TwoRowKeyBar(
connectionState: ConnectionState,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {},
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onFtClick: () -> Unit,
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
onBackTab: () -> Unit,
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
onCursorRight: () -> Unit = {},
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF161719))
) {
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "",
color = Color(0xFF343A40),
modifier = Modifier.fillMaxWidth(),
innerPaddingHorizontal = 0.dp,
onClick = { menuExpanded = true }
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
if (!connectionState.isConnected()) {
DropdownMenuItem(
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onConnectClick()
}
)
} else {
DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onDisconnectClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onFtClick()
}
)
}
}
}
// 2. TAB / BTAB depending on Shift state
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
KeyButton(
label = tabLabel,
color = Color(0xFF1C7ED6),
modifier = Modifier.weight(1f),
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onBackTab()
onClearShift()
} else {
onTab()
}
}
)
// 3. RESET
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset)
// 4. ENTER
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 5. CLEAR
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
// 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
// 7. PA2
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
// 8. PA3
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
// 9. Left Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
// 10. Up Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
// 11. Down Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
// 12. Right Navigation
KeyButton("", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
}
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
val startPf = if (isShiftPressed) 13 else 1
val endPf = if (isShiftPressed) 24 else 12
for (i in startPf..endPf) {
val aid = pfAids[i - 1]
KeyButton(
label = "F$i",
color = Color(0xFF364FC7),
modifier = Modifier.weight(1f),
innerPaddingHorizontal = 0.dp,
onClick = {
onSendAid(aid)
if (isShiftPressed) {
onClearShift()
}
}
)
}
}
}
}
@Composable
private fun KeyButton(
label: String,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(4.dp),
color = color,
shadowElevation = 1.dp,
modifier = modifier
.padding(horizontal = 1.dp, vertical = 1.dp)
.height(31.dp)
.focusProperties { canFocus = false }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
) {
Text(
text = label,
color = Color.White,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
@@ -0,0 +1,106 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
modifier: Modifier = Modifier
) {
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) {
"OFFLINE"
} else if (isKeyboardLocked) {
"X SYSTEM"
} else {
"READY"
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
} else {
Color(0xFF51CF66) // Green for ready
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF161719))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = Color.White,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position
Text(
text = "R$posStr",
color = Color(0xFF22B8CF),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -0,0 +1,433 @@
package org.pubvm.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
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 org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF5078FF), // 1: Blue
Color(0xFFFF3232), // 2: Red
Color(0xFFFF82B4), // 3: Pink
Color(0xFF32CD32), // 4: Green
Color(0xFF40E0D0), // 5: Turquoise
Color(0xFFFFFF50), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF1E3CB4), // 9: Deep Blue
Color(0xFFFFA500), // 10: Orange
Color(0xFFB482FF), // 11: Purple
Color(0xFF90EE90), // 12: Pale Green
Color(0xFFAFEEEE), // 13: Pale Turquoise
Color(0xFFAAAAAA), // 14: Grey
Color(0xFFFFFFFF) // 15: White
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
onTapAddress: (Int) -> Unit,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) {
// Stationary Long Press (held >= 600ms without dragging)
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap (Move Cursor)
showContextMenu = false
selectionStart = null
selectionEnd = null
val cellWidth = size.width / cols
val cellHeight = size.height / rows
if (cellWidth > 0 && cellHeight > 0) {
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
) {
val width = size.width
val height = size.height
val cellWidth = width / cols
val cellHeight = height / rows
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = cellHeight * 0.85f
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null) {
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = c * cellWidth
val top = r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden)
if (faIsZero(currentFA.toInt() and 0xFF)) {
continue
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
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]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Highlight selected block range
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
if (addr == cursorAddr && !isSelected) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
if (charVal != ' ') {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
left + (cellWidth / 4),
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2),
end = Offset(left + cellWidth, top + cellHeight - 2),
strokeWidth = 2f
)
}
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
}
}
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
for (c in minCol..maxCol) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
else 0
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}