diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index 96268fe..ebfa691 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -215,6 +215,26 @@ class MainActivity : ComponentActivity() { viewModel.sendAid(AID_PA1) return true } + KeyEvent.KEYCODE_A -> { + viewModel.attn() + return true + } + KeyEvent.KEYCODE_S -> { + viewModel.sysReq() + return true + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + viewModel.wordLeft() + return true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + viewModel.wordRight() + return true + } + KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> { + viewModel.deleteWord() + return true + } KeyEvent.KEYCODE_HOME -> { viewModel.cursorHome() return true @@ -249,6 +269,14 @@ class MainActivity : ComponentActivity() { viewModel.resetKeyboard() return true } + KeyEvent.KEYCODE_D -> { + viewModel.deleteWord() + return true + } + KeyEvent.KEYCODE_E -> { + viewModel.fieldEnd() + return true + } KeyEvent.KEYCODE_L -> { viewModel.toggleLightPen() val isLp = viewModel.isLightPenMode.value @@ -593,15 +621,15 @@ fun MainScreen( terminalInputViewRef?.showSoftKeyboard() }, onAttn = { - viewModel.sendAid(AID_PA1) + viewModel.attn() terminalInputViewRef?.showSoftKeyboard() }, onSysReq = { - viewModel.sendAid(AID_SYSREQ) + viewModel.sysReq() terminalInputViewRef?.showSoftKeyboard() }, onCursorSelect = { - viewModel.sendAid(AID_SELECT) + viewModel.cursorSelect() terminalInputViewRef?.showSoftKeyboard() }, onCursorLeft = { @@ -635,7 +663,10 @@ fun MainScreen( isTlsVerified = isTlsVerified, graphicsMode = defaultGraphicsMode, codePage = codePage, - isLightPenMode = isLightPenMode + isLightPenMode = isLightPenMode, + isInsertMode = viewModel.getClient()?.inputProcessor?.isInsertMode ?: false, + inhibitReason = viewModel.getClient()?.oia?.inputInhibited ?: 0, + luName = viewModel.getClient()?.telnetFSM?.connectedLu ?: "" ) } } @@ -701,6 +732,7 @@ fun MainScreen( if (showFindDialog) { FindDialog( + cols = cols, onDismiss = { showFindDialog = false }, onFind = { query: String, matchCase: Boolean, forward: Boolean -> viewModel.find(query, matchCase, forward) diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index b2ed91b..b7972fc 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -18,8 +18,7 @@ import haus.nightmare.lib3270j.TerminalModel import haus.nightmare.lib3270j.ft.FTConfig import haus.nightmare.lib3270j.listener.ConnectionListener import haus.nightmare.lib3270j.listener.ScreenUpdateListener -import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER -import haus.nightmare.lib3270j.protocol.DS3270Constants.faIsProtected +import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.screen.ScreenBuffer import haus.nightmare.a3270.ft.FileTransfer import haus.nightmare.a3270.service.TerminalService @@ -44,6 +43,16 @@ sealed interface TerminalInputAction { data object EraseInput : TerminalInputAction data object Newline : TerminalInputAction data object Reset : TerminalInputAction + data object WordLeft : TerminalInputAction + data object WordRight : TerminalInputAction + data object FieldEnd : TerminalInputAction + data object DeleteWord : TerminalInputAction + data object CursorSelect : TerminalInputAction + data object Attn : TerminalInputAction + data object SysReq : TerminalInputAction + data object Dup : TerminalInputAction + data object FieldMark : TerminalInputAction + data object ToggleInsert : TerminalInputAction data class SetCursor(val baddr: Int) : TerminalInputAction data class LightPenSelect(val baddr: Int) : TerminalInputAction } @@ -205,68 +214,200 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val c = client ?: return val ip = c.inputProcessor val buf = c.screenBuffer + val isNvt = c.connectionState.isNvt || ip.isNvtMode try { when (action) { is TerminalInputAction.TypeText -> { - log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}") + log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}, isNvt=$isNvt") ip.isKeyboardLocked = false - for (ch in action.text) { - if (ch == '\n' || ch == '\r') { - ip.setKeyboardLocked(false) - ip.sendAid(AID_ENTER) - } else if (ch >= ' ') { - ip.typeCharacter(ch) - log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'") + if (isNvt) { + for (ch in action.text) { + if (ch == '\n' || ch == '\r') { + c.sendNVTString("\r\n") + } else { + c.sendNVTChar(ch) + } + } + } else { + for (ch in action.text) { + if (ch == '\n' || ch == '\r') { + ip.setKeyboardLocked(false) + ip.sendAid(AID_ENTER) + } else if (ch >= ' ') { + ip.typeCharacter(ch) + log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(buf.cursorAddress).ucs4}'") + } } } } is TerminalInputAction.SendAid -> { - ip.setKeyboardLocked(false) - ip.sendAid(action.aidCode) + if (isNvt) { + if (action.aidCode == AID_ENTER) { + c.sendNVTString("\r\n") + } else if (action.aidCode == AID_CLEAR) { + c.sendNVTChar('\u000C') + } else { + val pfNum = aidToPfNumber(action.aidCode) + if (pfNum > 0) { + val seq = c.nvtProcessor?.getFunctionKeySequence(pfNum) ?: "" + if (seq.isNotEmpty()) { + c.sendNVTString(seq) + } + } + } + } else { + ip.setKeyboardLocked(false) + ip.sendAid(action.aidCode) + } } is TerminalInputAction.Backspace -> { - ip.isKeyboardLocked = false - ip.backspace() + if (isNvt) { + c.sendNVTChar('\b') + } else { + ip.isKeyboardLocked = false + ip.backspace() + } } is TerminalInputAction.DeleteChar -> { - ip.isKeyboardLocked = false - ip.deleteChar() + if (isNvt) { + c.sendNVTString("\u001B[3~") + } else { + ip.isKeyboardLocked = false + ip.deleteChar() + } } is TerminalInputAction.Tab -> { - ip.tab() + if (isNvt) { + c.sendNVTChar('\t') + } else { + ip.tab() + } } is TerminalInputAction.BackTab -> { - ip.backTab() + if (isNvt) { + c.sendNVTString("\u001B[Z") + } else { + ip.backTab() + } } is TerminalInputAction.CursorLeft -> { - ip.cursorLeft() + if (isNvt) { + c.sendNVTString("\u001B[D") + } else { + ip.cursorLeft() + } } is TerminalInputAction.CursorRight -> { - ip.cursorRight() + if (isNvt) { + c.sendNVTString("\u001B[C") + } else { + ip.cursorRight() + } } is TerminalInputAction.CursorUp -> { - ip.cursorUp() + if (isNvt) { + c.sendNVTString("\u001B[A") + } else { + ip.cursorUp() + } } is TerminalInputAction.CursorDown -> { - ip.cursorDown() + if (isNvt) { + c.sendNVTString("\u001B[B") + } else { + ip.cursorDown() + } } is TerminalInputAction.CursorHome -> { - ip.cursorHome() + if (isNvt) { + c.sendNVTString("\u001B[H") + } else { + ip.cursorHome() + } } is TerminalInputAction.EraseEof -> { - ip.isKeyboardLocked = false - ip.eraseEof() + if (isNvt) { + c.sendNVTString("\u001B[F") + } else { + ip.isKeyboardLocked = false + ip.eraseEof() + } } is TerminalInputAction.EraseInput -> { - ip.isKeyboardLocked = false - ip.eraseInput() + if (!isNvt) { + ip.isKeyboardLocked = false + ip.eraseInput() + } } is TerminalInputAction.Newline -> { - ip.newline() + if (isNvt) { + c.sendNVTString("\r\n") + } else { + ip.newline() + } } is TerminalInputAction.Reset -> { - ip.reset() + if (isNvt) { + c.sendNVTChar('\u001B') + } else { + ip.reset() + } + } + is TerminalInputAction.WordLeft -> { + if (isNvt) { + c.sendNVTString("\u001Bb") + } else { + ip.processWordLeft() + } + } + is TerminalInputAction.WordRight -> { + if (isNvt) { + c.sendNVTString("\u001Bf") + } else { + ip.processWordRight() + } + } + is TerminalInputAction.FieldEnd -> { + if (!isNvt) { + ip.processFieldEnd() + } + } + is TerminalInputAction.DeleteWord -> { + if (!isNvt) { + ip.isKeyboardLocked = false + ip.processDeleteWord() + } + } + is TerminalInputAction.CursorSelect -> { + if (!isNvt) { + ip.cursorSelect() + } + } + is TerminalInputAction.Attn -> { + if (!isNvt) { + ip.attn() + } + } + is TerminalInputAction.SysReq -> { + if (!isNvt) { + ip.sysReq() + } + } + is TerminalInputAction.Dup -> { + if (!isNvt) { + ip.dup() + } + } + is TerminalInputAction.FieldMark -> { + if (!isNvt) { + ip.fieldMark() + } + } + is TerminalInputAction.ToggleInsert -> { + if (!isNvt) { + ip.processToggleInsert() + } } is TerminalInputAction.SetCursor -> { if (action.baddr in 0 until (buf.rows * buf.cols)) { @@ -286,6 +427,18 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } } + private fun aidToPfNumber(aid: Int): Int { + return when (aid) { + AID_PF1 -> 1; AID_PF2 -> 2; AID_PF3 -> 3; AID_PF4 -> 4 + AID_PF5 -> 5; AID_PF6 -> 6; AID_PF7 -> 7; AID_PF8 -> 8 + AID_PF9 -> 9; AID_PF10 -> 10; AID_PF11 -> 11; AID_PF12 -> 12 + AID_PF13 -> 13; AID_PF14 -> 14; AID_PF15 -> 15; AID_PF16 -> 16 + AID_PF17 -> 17; AID_PF18 -> 18; AID_PF19 -> 19; AID_PF20 -> 20 + AID_PF21 -> 21; AID_PF22 -> 22; AID_PF23 -> 23; AID_PF24 -> 24 + else -> 0 + } + } + fun resolveUntrustedCert(accept: Boolean) { try { val prompt = _untrustedCertPrompt.value @@ -492,6 +645,32 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val newClient = Telnet3270Client(config) this@TerminalViewModel.client = newClient + newClient.setNvtClipboardHandler(object : haus.nightmare.lib3270j.nvt.NvtProcessor.ClipboardHandler { + override fun getClipboardText(): String { + return try { + val cm = getApplication().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + cm?.primaryClip?.getItemAt(0)?.text?.toString() ?: "" + } catch (e: Exception) { + "" + } + } + + override fun setClipboardText(text: String?) { + if (!text.isNullOrEmpty()) { + try { + val cm = getApplication().getSystemService(android.content.Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager + cm?.setPrimaryClip(android.content.ClipData.newPlainText("3270 NVT", text)) + } catch (ignored: Exception) {} + } + } + }) + + newClient.addNvtTitleListener { title -> + if (!title.isNullOrBlank()) { + _oiaText.value = title + } + } + newClient.addConnectionListener(object : ConnectionListener { override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) { _connectionState.value = newState @@ -721,6 +900,46 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application inputChannel.trySend(TerminalInputAction.Reset) } + fun wordLeft() { + inputChannel.trySend(TerminalInputAction.WordLeft) + } + + fun wordRight() { + inputChannel.trySend(TerminalInputAction.WordRight) + } + + fun fieldEnd() { + inputChannel.trySend(TerminalInputAction.FieldEnd) + } + + fun deleteWord() { + inputChannel.trySend(TerminalInputAction.DeleteWord) + } + + fun cursorSelect() { + inputChannel.trySend(TerminalInputAction.CursorSelect) + } + + fun attn() { + inputChannel.trySend(TerminalInputAction.Attn) + } + + fun sysReq() { + inputChannel.trySend(TerminalInputAction.SysReq) + } + + fun dup() { + inputChannel.trySend(TerminalInputAction.Dup) + } + + fun fieldMark() { + inputChannel.trySend(TerminalInputAction.FieldMark) + } + + fun toggleInsert() { + inputChannel.trySend(TerminalInputAction.ToggleInsert) + } + fun eraseEof() { inputChannel.trySend(TerminalInputAction.EraseEof) } diff --git a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt index bc0d99e..e92ae47 100644 --- a/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt +++ b/src/main/java/haus/nightmare/a3270/storage/HostStorage.kt @@ -132,6 +132,51 @@ object HostStorage { saveAll(context, currentHosts) } + val AVAILABLE_CODE_PAGES = listOf( + "037" to "037 - US / Canada / Brazil", + "1047" to "1047 - IBM Open Systems / z/OS Unix", + "500" to "500 - International Latin-1", + "273" to "273 - Germany / Austria", + "277" to "277 - Denmark / Norway", + "278" to "278 - Sweden / Finland", + "280" to "280 - Italy", + "284" to "284 - Spain / Latin America", + "285" to "285 - United Kingdom", + "297" to "297 - France", + "420" to "420 - Arabic Bilingual", + "424" to "424 - Hebrew (with Latin)", + "803" to "803 - Hebrew Character Set", + "838" to "838 - Thai Extended", + "1160" to "1160 - Thai Euro", + "870" to "870 - Eastern Europe / Latin-2", + "871" to "871 - Iceland", + "875" to "875 - Greece (Greek)", + "880" to "880 - Cyrillic (Russian)", + "1025" to "1025 - Cyrillic Multilingual", + "1123" to "1123 - Cyrillic Ukraine", + "1154" to "1154 - Cyrillic Euro", + "905" to "905 - Turkey (Latin-5)", + "1026" to "1026 - Turkey (Turkish)", + "1155" to "1155 - Turkey Euro", + "1140" to "1140 - US / Canada (Euro €)", + "1141" to "1141 - Germany / Austria (Euro €)", + "1142" to "1142 - Denmark / Norway (Euro €)", + "1143" to "1143 - Sweden / Finland (Euro €)", + "1144" to "1144 - Italy (Euro €)", + "1145" to "1145 - Spain / Latin America (Euro €)", + "1146" to "1146 - United Kingdom (Euro €)", + "1147" to "1147 - France (Euro €)", + "1148" to "1148 - International (Euro €)", + "1149" to "1149 - Iceland (Euro €)", + "930" to "930 - Japanese Katakana Mixed DBCS", + "939" to "939 - Japanese Latin Mixed DBCS", + "935" to "935 - Simplified Chinese Mixed DBCS", + "937" to "937 - Traditional Chinese Mixed DBCS", + "1388" to "1388 - Simplified Chinese Extended DBCS", + "1371" to "1371 - Traditional Chinese Extended DBCS", + "933" to "933 - Korean Mixed DBCS" + ) + fun getAutoConnectHost(context: Context): SavedHost? { return getSavedHosts(context).firstOrNull { it.autoConnect } } diff --git a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt index 3f7bbe5..978c526 100644 --- a/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/ConnectDialog.kt @@ -467,30 +467,7 @@ fun ConnectDialog( // Code Page Selector var codePageMenuExpanded by remember { mutableStateOf(false) } - val codePagesList = listOf( - "037" to "037 - US / Canada / Brazil", - "1047" to "1047 - IBM Open Systems / z/OS Unix", - "500" to "500 - International Latin-1", - "273" to "273 - Germany / Austria", - "277" to "277 - Denmark / Norway", - "278" to "278 - Sweden / Finland", - "280" to "280 - Italy", - "284" to "284 - Spain / Latin America", - "285" to "285 - United Kingdom", - "297" to "297 - France", - "870" to "870 - Eastern Europe / Latin-2", - "871" to "871 - Iceland", - "875" to "875 - Greece (Greek)", - "1026" to "1026 - Turkey (Turkish)", - "1140" to "1140 - US / Canada (Euro €)", - "1141" to "1141 - Germany / Austria (Euro €)", - "1148" to "1148 - International (Euro €)", - "930" to "930 - Japanese Katakana Mixed DBCS", - "939" to "939 - Japanese Latin Mixed DBCS", - "935" to "935 - Simplified Chinese Mixed DBCS", - "937" to "937 - Traditional Chinese Mixed DBCS", - "933" to "933 - Korean Mixed DBCS" - ) + val codePagesList = HostStorage.AVAILABLE_CODE_PAGES val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage" Column(modifier = Modifier.fillMaxWidth()) { diff --git a/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt index c272800..d6e6d5c 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FieldInspectorDialog.kt @@ -134,6 +134,7 @@ fun FieldInspectorDialog( Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center) Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center) Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) + Text("Wrap", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) Text("Content Text", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp), textAlign = TextAlign.Start) } @@ -176,6 +177,7 @@ fun FieldInspectorDialog( Text(if (field.isHighIntensity) "Y" else "-", color = if (field.isHighIntensity) Color.White else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center) Text(if (field.isHidden) "Y" else "-", color = if (field.isHidden) Color(0xFFFF6B6B) else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center) Text(if (field.isPenSelectable) "Y" else "-", color = if (field.isPenSelectable) Color.Yellow else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) + Text(if (field.isWrapped) "↩" else "-", color = if (field.isWrapped) Color(0xFF339AF0) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center) Text(displayText, color = if (field.isProtected) Color(0xFF90CAF9) else Color(0xFFA5D6A7), fontSize = 11.sp, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.width(220.dp)) } } diff --git a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt index 01aa4c5..ef056e6 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FileTransferDialog.kt @@ -1,5 +1,13 @@ package haus.nightmare.a3270.ui +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.provider.OpenableColumns +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -9,6 +17,7 @@ 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.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -17,6 +26,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import haus.nightmare.lib3270j.ft.FTConfig import haus.nightmare.a3270.FTProgressState +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale private val TerminalKeyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -33,6 +46,8 @@ fun FileTransferDialog( onStartTransfer: (FTConfig) -> Unit, onCancelTransfer: () -> Unit = {} ) { + val context = LocalContext.current + val initialTypeEnum = when (initialHostType.uppercase()) { "CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS "CICS" -> FTConfig.HostType.CICS @@ -41,6 +56,7 @@ fun FileTransferDialog( var hostFile by remember { mutableStateOf("") } var localFile by remember { mutableStateOf("") } + var localDisplayName by remember { mutableStateOf("") } var isReceive by remember { mutableStateOf(true) } var isAscii by remember { mutableStateOf(true) } var hostType by remember { mutableStateOf(initialTypeEnum) } @@ -50,6 +66,74 @@ fun FileTransferDialog( var overwrite by remember { mutableStateOf(true) } var showBrowseDialog by remember { mutableStateOf(false) } + var targetSaveUri by remember { mutableStateOf(null) } + var lastSavedFile by remember { mutableStateOf(null) } + + // SAF Launcher for uploading (Send / PUT) + val openDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + try { + val displayName = getFileNameFromUri(context, uri).ifBlank { "upload.bin" } + val tempDir = File(context.cacheDir, "ft_upload").apply { if (!exists()) mkdirs() } + val tempFile = File(tempDir, displayName) + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + localFile = tempFile.absolutePath + localDisplayName = displayName + if (hostFile.isBlank()) { + val cleanBase = displayName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase() + hostFile = if (hostType == FTConfig.HostType.TSO) "'$cleanBase'" else "$cleanBase FILE A" + } + Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, "Failed to read file: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + // SAF Launcher for choosing download destination (Receive / GET) + val createDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*") + ) { uri: Uri? -> + if (uri != null) { + targetSaveUri = uri + val displayName = getFileNameFromUri(context, uri).ifBlank { "download.bin" } + localDisplayName = displayName + val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() } + val tempFile = File(tempDir, displayName) + localFile = tempFile.absolutePath + Toast.makeText(context, "Destination: $displayName", Toast.LENGTH_SHORT).show() + } + } + + // Watch for completed transfers to export to destination URI if chosen + LaunchedEffect(ftProgressState.isActive, ftProgressState.isError) { + if (!ftProgressState.isActive && !ftProgressState.isError && ftProgressState.bytesTransferred > 0 && isReceive && localFile.isNotBlank()) { + val src = File(localFile) + if (src.exists()) { + lastSavedFile = src + val uri = targetSaveUri + if (uri != null) { + try { + context.contentResolver.openOutputStream(uri)?.use { out -> + src.inputStream().use { input -> + input.copyTo(out) + } + } + Toast.makeText(context, "Saved download to destination file", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(context, "Failed writing to destination: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + } + if (showBrowseDialog) { HostDirectoryDialog( client = client, @@ -58,11 +142,61 @@ fun FileTransferDialog( onDismiss = { showBrowseDialog = false }, onSelectDataset = { selected -> hostFile = selected + if (localFile.isBlank()) { + val base = selected.trim('\'').substringAfterLast('.').substringAfterLast(' ') + val safeName = if (base.isNotBlank()) "$base.txt" else "download.txt" + val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() } + val tempFile = File(tempDir, safeName) + localFile = tempFile.absolutePath + localDisplayName = safeName + } showBrowseDialog = false } ) } + fun exportToDownloads() { + val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null + if (src == null || !src.exists()) { + Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show() + return + } + try { + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadsDir.exists()) downloadsDir.mkdirs() + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val targetName = (if (localDisplayName.isNotBlank()) localDisplayName else src.name).let { + val base = it.substringBeforeLast('.') + val ext = if (it.contains('.')) "." + it.substringAfterLast('.') else "" + "${base}_$timeStamp$ext" + } + val dst = File(downloadsDir, targetName) + src.copyTo(dst, overwrite = true) + Toast.makeText(context, "Exported to Downloads/${dst.name}", Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + fun shareDownloadedFile() { + val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null + if (src == null || !src.exists()) { + Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show() + return + } + try { + val text = src.readText() + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, localDisplayName.ifBlank { src.name }) + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(intent, "Share Transferred File")) + } catch (e: Exception) { + Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + AlertDialog( onDismissRequest = onDismiss, title = { @@ -113,13 +247,70 @@ fun FileTransferDialog( } HorizontalDivider(color = Color(0xFF373A40)) } else if (ftProgressState.statusMessage.isNotBlank()) { - Text( - text = ftProgressState.statusMessage, - fontSize = 12.sp, - color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66) - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = ftProgressState.statusMessage, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66) + ) + if (!ftProgressState.isError && isReceive && lastSavedFile?.exists() == true) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + OutlinedButton( + onClick = { exportToDownloads() }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + modifier = Modifier.weight(1f) + ) { + Text("Save to Downloads 📥", fontSize = 11.sp) + } + OutlinedButton( + onClick = { shareDownloadedFile() }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + modifier = Modifier.weight(1f) + ) { + Text("Share 📤", fontSize = 11.sp) + } + } + } + } + HorizontalDivider(color = Color(0xFF373A40)) } + // Transfer Direction + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Row { + FilterChip( + selected = isReceive, + onClick = { + isReceive = true + targetSaveUri = null + }, + label = { Text("Receive (GET)", fontSize = 11.sp) } + ) + Spacer(modifier = Modifier.width(4.dp)) + FilterChip( + selected = !isReceive, + onClick = { + isReceive = false + targetSaveUri = null + }, + label = { Text("Send (PUT)", fontSize = 11.sp) } + ) + } + } + + // Host File Name Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -145,39 +336,52 @@ fun FileTransferDialog( } } - OutlinedTextField( - value = localFile, - onValueChange = { localFile = it }, - label = { Text("Local File Name / Path") }, - placeholder = { Text("sample.txt") }, - supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) }, - singleLine = true, - keyboardOptions = TerminalKeyboardOptions.copy( - imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done - ), - modifier = Modifier.fillMaxWidth() - ) - - // Transfer Direction - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - Row { - FilterChip( - selected = isReceive, - onClick = { isReceive = true }, - label = { Text("Receive (GET)", fontSize = 11.sp) } - ) - Spacer(modifier = Modifier.width(4.dp)) - FilterChip( - selected = !isReceive, - onClick = { isReceive = false }, - label = { Text("Send (PUT)", fontSize = 11.sp) } + // Local File Selection (SAF Document Picker integration) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + OutlinedTextField( + value = if (localDisplayName.isNotBlank()) localDisplayName else localFile, + onValueChange = { + localDisplayName = it + localFile = it + }, + label = { Text(if (isReceive) "Local Destination" else "Local File to Upload") }, + placeholder = { Text(if (isReceive) "download.txt" else "sample.txt") }, + singleLine = true, + keyboardOptions = TerminalKeyboardOptions.copy( + imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done + ), + modifier = Modifier.weight(1f) ) + Button( + onClick = { + if (isReceive) { + val defName = if (hostFile.isNotBlank()) { + val base = hostFile.trim('\'').substringAfterLast('.').substringAfterLast(' ') + if (base.isNotBlank()) "$base.txt" else "download.txt" + } else "download.txt" + createDocumentLauncher.launch(defName) + } else { + openDocumentLauncher.launch(arrayOf("*/*")) + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.padding(top = 6.dp) + ) { + Text(if (isReceive) "Save As..." else "Pick File...", fontSize = 11.sp) + } } + Text( + text = if (isReceive) "Pick a destination or enter a file name to save in app storage" else "Use 'Pick File' to select any file from Downloads / Storage", + fontSize = 10.sp, + color = Color.LightGray, + modifier = Modifier.padding(start = 4.dp, top = 2.dp) + ) } // Transfer Mode @@ -273,10 +477,18 @@ fun FileTransferDialog( confirmButton = { Button( onClick = { - if (hostFile.isNotBlank() && localFile.isNotBlank()) { + val resolvedLocal = if (localFile.isNotBlank()) { + localFile.trim() + } else if (localDisplayName.isNotBlank()) { + val tempDir = File(context.cacheDir, if (isReceive) "ft_download" else "ft_upload").apply { if (!exists()) mkdirs() } + File(tempDir, localDisplayName.trim()).absolutePath + } else "" + + if (hostFile.isNotBlank() && resolvedLocal.isNotBlank()) { + localFile = resolvedLocal val config = FTConfig().apply { setHostFilename(hostFile.trim()) - setLocalFilename(localFile.trim()) + setLocalFilename(resolvedLocal) setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND) setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY) setHostType(hostType) @@ -290,7 +502,7 @@ fun FileTransferDialog( onStartTransfer(config) } }, - enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning + enabled = hostFile.isNotBlank() && (localFile.isNotBlank() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning ) { Text("Start Transfer") } @@ -302,3 +514,22 @@ fun FileTransferDialog( } ) } + +private fun getFileNameFromUri(context: Context, uri: Uri): String { + var name = "" + try { + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + name = it.getString(nameIndex) ?: "" + } + } + } + } catch (_: Exception) {} + if (name.isBlank()) { + name = uri.lastPathSegment ?: "file" + } + return name +} diff --git a/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt b/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt index bd24923..11eab50 100644 --- a/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/FindDialog.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.window.DialogProperties @Composable fun FindDialog( + cols: Int = 80, onDismiss: () -> Unit, onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int, onClearHighlight: () -> Unit @@ -49,8 +50,9 @@ fun FindDialog( } val pos = onFind(trimmed, matchCase, searchForward) if (pos >= 0) { - val row = (pos / 80) + 1 - val col = (pos % 80) + 1 + val effectiveCols = if (cols > 0) cols else 80 + val row = (pos / effectiveCols) + 1 + val col = (pos % effectiveCols) + 1 statusMessage = "Found at position $pos ($row/$col)" isError = false } else { diff --git a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt index de95186..cacbdeb 100644 --- a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt @@ -29,6 +29,9 @@ fun OiaStatusBar( graphicsMode: String = "NONE", codePage: String = "037", isLightPenMode: Boolean = false, + isInsertMode: Boolean = false, + inhibitReason: Int = 0, + luName: String = "", modifier: Modifier = Modifier ) { val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1 @@ -37,20 +40,36 @@ fun OiaStatusBar( val hostText = if (currentHost.isNotBlank()) currentHost else oiaText - val lockStatusText = if (!connectionState.isConnected()) { - "OFFLINE" - } else if (isKeyboardLocked) { - "X SYSTEM" - } else { - "READY" + val connTypeBadge = when (connectionState) { + ConnectionState.CONNECTED_3270 -> "TN3270" + ConnectionState.CONNECTED_TN3270E -> "TN3270E" + ConnectionState.CONNECTED_SSCP -> "SSCP-LU" + ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT" + ConnectionState.CONNECTED_UNBOUND -> "UNBOUND" + ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING" + ConnectionState.NOT_CONNECTED -> "OFFLINE" + else -> connectionState.name } - val lockStatusColor = if (!connectionState.isConnected()) { - Color(0xFF868E96) + val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) { + "OFFLINE" to Color(0xFF868E96) + } else if (inhibitReason != 0) { + val txt = when (inhibitReason) { + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM" + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM" + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM" + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT" + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >" + haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP" + else -> "X LOCKED" + } + txt to Color(0xFFFF6B6B) } else if (isKeyboardLocked) { - Color(0xFFFF6B6B) // Red for locked + "X SYSTEM" to Color(0xFFFF6B6B) + } else if (isInsertMode) { + "INSERT" to Color(0xFF339AF0) } else { - Color(0xFF51CF66) // Green for ready + "READY" to Color(0xFF51CF66) } Row( @@ -157,6 +176,42 @@ fun OiaStatusBar( ) } } + + // Connection Type Badge (TN3270, TN3270E, SSCP, NVT) + if (connectionState.isConnected()) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFF7950F2).copy(alpha = 0.25f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = connTypeBadge, + color = Color(0xFFB197FC), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } + + // LU Name Badge + if (connectionState.isConnected() && luName.isNotBlank()) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = Color(0xFF40C057).copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "LU:$luName", + color = Color(0xFF69DB7C), + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } } // Terminal Keyboard Lock Status Badge (X SYSTEM vs READY) diff --git a/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt b/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt index 044ee58..df90f75 100644 --- a/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/PrinterSessionDialog.kt @@ -56,6 +56,7 @@ fun PrinterSessionDialog( var assocDisplayLu by remember { mutableStateOf("") } var codePage by remember { mutableStateOf(initialCodePage) } var useTls by remember { mutableStateOf(initialUseTls) } + var pdtType by remember { mutableStateOf("DEFAULT") } var isConnected by remember { mutableStateOf(false) } var sessionStatus by remember { mutableStateOf("Disconnected") } @@ -65,24 +66,7 @@ fun PrinterSessionDialog( var printerClient by remember { mutableStateOf(null) } - val codePagesList = listOf( - "037" to "037 - US / Canada", - "1047" to "1047 - Open Systems / Unix", - "500" to "500 - International", - "273" to "273 - Germany", - "277" to "277 - Denmark / Norway", - "278" to "278 - Sweden / Finland", - "280" to "280 - Italy", - "284" to "284 - Spain", - "285" to "285 - United Kingdom", - "297" to "297 - France", - "870" to "870 - Eastern Europe", - "1140" to "1140 - US (Euro €)", - "1141" to "1141 - Germany (Euro €)", - "1148" to "1148 - Intl (Euro €)", - "930" to "930 - Japanese Katakana DBCS", - "939" to "939 - Japanese Latin DBCS" - ) + val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES fun startSession() { val port = portStr.trim().toIntOrNull() ?: 23 @@ -92,6 +76,16 @@ fun PrinterSessionDialog( if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim() if (assocDisplayLu.isNotBlank()) this.associatedDisplayLuName = assocDisplayLu.trim() this.destinationType = PrinterConfig.DestinationType.MEMORY + + val pdt = when (pdtType) { + "PCL5" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPcl5PDT() + "EPSON_ESC_P" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createEpsonEscPPDT() + "POSTSCRIPT" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPostScriptPDT() + else -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPlainTextPDT() + } + if (pdt != null) { + this.printerDefinitionTable = pdt + } } val client = Telnet3270EPClient(config) @@ -313,16 +307,51 @@ fun PrinterSessionDialog( ) } - // Code Page & TLS Row + // PDT, Code Page & TLS Row Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { + var pdtExpanded by remember { mutableStateOf(false) } + val pdtList = listOf( + "DEFAULT" to "Plain Text", + "PCL5" to "HP PCL 5/6", + "EPSON_ESC_P" to "Epson ESC/P", + "POSTSCRIPT" to "PostScript" + ) + val selectedPdtLabel = pdtList.firstOrNull { it.first == pdtType }?.second ?: "PDT" + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { pdtExpanded = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("PDT: $selectedPdtLabel", fontSize = 10.sp, maxLines = 1) + } + DropdownMenu( + expanded = pdtExpanded, + onDismissRequest = { pdtExpanded = false }, + modifier = Modifier.background(Color(0xFF2C2D30)) + ) { + pdtList.forEach { (type, label) -> + DropdownMenuItem( + text = { Text(label, fontSize = 12.sp, color = Color.White) }, + onClick = { + pdtType = type + pdtExpanded = false + } + ) + } + } + } + var cpExpanded by remember { mutableStateOf(false) } - Box { - OutlinedButton(onClick = { cpExpanded = true }) { - Text("Code Page: CP$codePage", fontSize = 11.sp) + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { cpExpanded = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("CP$codePage", fontSize = 10.sp, maxLines = 1) } DropdownMenu( expanded = cpExpanded, @@ -346,15 +375,14 @@ fun PrinterSessionDialog( modifier = Modifier.clickable { useTls = !useTls } ) { Checkbox(checked = useTls, onCheckedChange = { useTls = it }) - Spacer(modifier = Modifier.width(4.dp)) - Text("TLS", fontSize = 12.sp, color = Color.White) + Text("TLS", fontSize = 11.sp, color = Color.White) } Button( onClick = { startSession() }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)) ) { - Text("Connect Printer") + Text("Connect", fontSize = 11.sp) } } } else { diff --git a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt index 0069b46..8f39838 100644 --- a/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/SettingsDialog.kt @@ -251,30 +251,7 @@ fun SettingsDialog( // Setting 6: Default EBCDIC Code Page var codePageMenuExpanded by remember { mutableStateOf(false) } - val codePagesList = listOf( - "037" to "037 - US / Canada / Brazil", - "1047" to "1047 - IBM Open Systems / z/OS Unix", - "500" to "500 - International Latin-1", - "273" to "273 - Germany / Austria", - "277" to "277 - Denmark / Norway", - "278" to "278 - Sweden / Finland", - "280" to "280 - Italy", - "284" to "284 - Spain / Latin America", - "285" to "285 - United Kingdom", - "297" to "297 - France", - "870" to "870 - Eastern Europe / Latin-2", - "871" to "871 - Iceland", - "875" to "875 - Greece (Greek)", - "1026" to "1026 - Turkey (Turkish)", - "1140" to "1140 - US / Canada (Euro €)", - "1141" to "1141 - Germany / Austria (Euro €)", - "1148" to "1148 - International (Euro €)", - "930" to "930 - Japanese Katakana Mixed DBCS", - "939" to "939 - Japanese Latin Mixed DBCS", - "935" to "935 - Simplified Chinese Mixed DBCS", - "937" to "937 - Traditional Chinese Mixed DBCS", - "933" to "933 - Korean Mixed DBCS" - ) + val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES val selectedCpLabel = codePagesList.firstOrNull { it.first == defaultCodePage }?.second ?: "Code Page $defaultCodePage" Column(modifier = Modifier.fillMaxWidth()) {