commit 3bca77ffb57a672cec199fdabcf6f0d39e8cd7dd Author: Rudi Date: Thu Aug 20 17:28:09 2026 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01b729c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.jar +build +.gradle diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..507059c --- /dev/null +++ b/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'org.pubvm.a3270' + compileSdk 34 + + defaultConfig { + applicationId "org.pubvm.a3270" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "0.1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = '11' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.8' + } +} + +dependencies { + implementation project(':lib3270j') + + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.activity:activity-compose:1.8.2' + + implementation platform('androidx.compose:compose-bom:2024.02.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' +} diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dd764f8 --- /dev/null +++ b/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/pubvm/a3270/MainActivity.kt b/src/main/java/org/pubvm/a3270/MainActivity.kt new file mode 100644 index 0000000..d1c4522 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/MainActivity.kt @@ -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 + } + ) + } +} diff --git a/src/main/java/org/pubvm/a3270/TerminalViewModel.kt b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt new file mode 100644 index 0000000..08f0b71 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/TerminalViewModel.kt @@ -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.asStateFlow() + + private val _screenBuffer = MutableStateFlow(null) + val screenBuffer: StateFlow = _screenBuffer.asStateFlow() + + private val _rows = MutableStateFlow(24) + val rows: StateFlow = _rows.asStateFlow() + + private val _cols = MutableStateFlow(80) + val cols: StateFlow = _cols.asStateFlow() + + private val _cursorAddress = MutableStateFlow(0) + val cursorAddress: StateFlow = _cursorAddress.asStateFlow() + + private val _oiaText = MutableStateFlow("Disconnected") + val oiaText: StateFlow = _oiaText.asStateFlow() + + private val _screenVersion = MutableStateFlow(0L) + val screenVersion: StateFlow = _screenVersion.asStateFlow() + + private val _isKeyboardLocked = MutableStateFlow(false) + val isKeyboardLocked: StateFlow = _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() + } +} diff --git a/src/main/java/org/pubvm/a3270/service/TerminalService.kt b/src/main/java/org/pubvm/a3270/service/TerminalService.kt new file mode 100644 index 0000000..ab49713 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/service/TerminalService.kt @@ -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() + } + } + } +} diff --git a/src/main/java/org/pubvm/a3270/storage/HostStorage.kt b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt new file mode 100644 index 0000000..b17aea9 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/storage/HostStorage.kt @@ -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 { + val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList() + val list = mutableListOf() + 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) { + val array = JSONArray() + hosts.forEach { array.put(it.toJson()) } + getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply() + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt new file mode 100644 index 0000000..8ef20ad --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/ConnectDialog.kt @@ -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(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") + } + } + } + } + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt b/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt new file mode 100644 index 0000000..223d770 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/FileTransferDialog.kt @@ -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") + } + } + ) +} diff --git a/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt new file mode 100644 index 0000000..5a00b88 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/FunctionKeyBar.kt @@ -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 + ) + } + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt new file mode 100644 index 0000000..eb7ce45 --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/OiaStatusBar.kt @@ -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 + ) + } +} diff --git a/src/main/java/org/pubvm/a3270/ui/TerminalView.kt b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt new file mode 100644 index 0000000..199dcdd --- /dev/null +++ b/src/main/java/org/pubvm/a3270/ui/TerminalView.kt @@ -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(null) } + var selectionEnd by remember { mutableStateOf(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() +} diff --git a/src/main/res/drawable/ic_notification.xml b/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..447abb8 --- /dev/null +++ b/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + +