From a7cc4b9d506a7ad93c1d0c9629a7f9129c9df372 Mon Sep 17 00:00:00 2001 From: Rudi Date: Mon, 31 Aug 2026 00:28:47 +0000 Subject: [PATCH] v1 --- build.gradle | 4 +- .../java/haus/nightmare/a3270/MainActivity.kt | 5 +- .../haus/nightmare/a3270/TerminalViewModel.kt | 14 +- .../haus/nightmare/a3270/ui/OiaStatusBar.kt | 2 +- .../a3270/ui/ScreenExporterDialog.kt | 41 +++- .../haus/nightmare/a3270/ui/TerminalView.kt | 7 +- .../nightmare/a3270/GraphicsAndSysReqTest.kt | 178 ++++++++++++++++++ 7 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt diff --git a/build.gradle b/build.gradle index ab49bec..ed5f950 100644 --- a/build.gradle +++ b/build.gradle @@ -11,8 +11,8 @@ android { applicationId "haus.nightmare.a3270" minSdk 24 targetSdk 34 - versionCode 4 - versionName "0.1.3" + versionCode 5 + versionName "1.0.0" } buildTypes { diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index 9b4fdb9..6b81a97 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -149,6 +149,7 @@ class MainActivity : ComponentActivity() { return 0 } + @Suppress("DEPRECATION") override fun dispatchKeyEvent(event: KeyEvent): Boolean { val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0) if (isShiftPressedState.value != shift) { @@ -506,8 +507,8 @@ fun MainScreen( viewModel.selectLightPen(addr) terminalInputViewRef?.showSoftKeyboard() }, - onGraphicTouch = { px, py -> - viewModel.handleGraphicTouch(px, py) + onGraphicTouch = { clickAddr, px, py -> + viewModel.handleGraphicTouch(clickAddr, px, py) }, onPasteText = { text -> viewModel.pasteString(text) diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index 9763b48..2e22f27 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -330,14 +330,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val curRow = curAddr / cols val curCol = curAddr % cols - var startRow = curRow - var startCol = curCol + val startRow: Int + val startCol: Int if (forward) { startCol = (curCol + 1) % cols - if (startCol == 0) startRow = (curRow + 1) % rows + startRow = if (startCol == 0) (curRow + 1) % rows else curRow } else { startCol = (curCol - 1 + cols) % cols - if (startCol == cols - 1) startRow = (curRow - 1 + rows) % rows + startRow = if (startCol == cols - 1) (curRow - 1 + rows) % rows else curRow } val dir = if (forward) haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_FORWARD else haus.nightmare.lib3270j.ecl.ECLConstants.SEARCH_BACKWARD @@ -752,9 +752,13 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application fun getGocaDecoder(): haus.nightmare.lib3270j.graphics.GocaDecoder? = client?.gocaDecoder - fun handleGraphicTouch(px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) { + fun handleGraphicTouch(clickAddr: Int = -1, px: Int, py: Int, button: Int = 1, shift: Boolean = false, ctrl: Boolean = false) { val c = client ?: return val goca = c.gocaDecoder ?: return + if (clickAddr >= 0) { + c.screenBuffer?.cursorAddress = clickAddr + _cursorAddress.value = clickAddr + } if (goca.isGraphicsCursorActive) { goca.setGraphicCursorFromPixel(px, py) c.inputProcessor.sendGraphicMouseAid( diff --git a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt index 0548e15..de95186 100644 --- a/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt +++ b/src/main/java/haus/nightmare/a3270/ui/OiaStatusBar.kt @@ -31,7 +31,7 @@ fun OiaStatusBar( isLightPenMode: Boolean = false, modifier: Modifier = Modifier ) { - val row = if (cols > 0) (cursorAddr / cols) + 1 else 1 + val row = if (cols > 0 && rows > 0) ((cursorAddr / cols) % rows) + 1 else 1 val col = if (cols > 0) (cursorAddr % cols) + 1 else 1 val posStr = String.format("%02d/%02d", row, col) diff --git a/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt index a451687..0fd193d 100644 --- a/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt @@ -416,15 +416,40 @@ private fun generateBitmap( currentProt = (faVal and FA_PROTECT) != 0 currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 - } else if (!currentHidden || !maskHidden) { - val ch = cell.ucs4 - if (ch in ' '..'~' || ch > '\u007F') { - textPaint.color = when { - currentHi -> android.graphics.Color.WHITE - currentProt -> android.graphics.Color.parseColor("#3399FF") - else -> android.graphics.Color.parseColor("#00FF66") + } else { + val csVal = cell.cs.toInt() and 0xFF + val ecVal = cell.ec.toInt() and 0xFF + var drawnAsPs = false + if (csVal >= 0x40 && programSymbolManager != null) { + val slot = programSymbolManager.getSymbol(csVal, ecVal) + if (slot != null) { + val symWidth = slot.width + val symHeight = slot.height + val fgArgb = when { + currentHi -> android.graphics.Color.WHITE + currentProt -> android.graphics.Color.parseColor("#3399FF") + else -> android.graphics.Color.parseColor("#00FF66") + } + val bgArgb = android.graphics.Color.parseColor("#0A0A0A") + val rgbArray = slot.getRgbPixels(fgArgb, bgArgb) + if (rgbArray != null && symWidth > 0 && symHeight > 0) { + val symBmp = Bitmap.createBitmap(rgbArray, symWidth, symHeight, Bitmap.Config.ARGB_8888) + canvas.drawBitmap(symBmp, null, android.graphics.RectF(left, top, left + charWidth, top + charHeight), null) + drawnAsPs = true + } + } + } + + if (!drawnAsPs && (!currentHidden || !maskHidden)) { + val ch = cell.ucs4 + if (ch in ' '..'~' || ch > '\u007F') { + textPaint.color = when { + currentHi -> android.graphics.Color.WHITE + currentProt -> android.graphics.Color.parseColor("#3399FF") + else -> android.graphics.Color.parseColor("#00FF66") + } + canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint) } - canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint) } } } diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index 108273d..133d0fa 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -68,7 +68,7 @@ fun TerminalView( searchHighlightLen: Int = 0, onTapAddress: (Int) -> Unit, onLightPenSelect: ((Int) -> Unit)? = null, - onGraphicTouch: ((Int, Int) -> Unit)? = null, + onGraphicTouch: ((Int, Int, Int) -> Unit)? = null, onPasteText: (String) -> Unit = {}, modifier: Modifier = Modifier ) { @@ -162,7 +162,8 @@ fun TerminalView( val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt() val px = if (gridW > 0 && gWidth > 0) (((startPos.x - metrics.offsetX) * gWidth) / gridW).toInt().coerceIn(0, gWidth - 1) else (startPos.x - metrics.offsetX).toInt() val py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt() - onGraphicTouch?.invoke(px, py) + screenBuffer?.cursorAddress = clickAddr + onGraphicTouch?.invoke(clickAddr, px, py) } else if (isLightPenMode) { onLightPenSelect?.invoke(clickAddr) } else { @@ -351,7 +352,7 @@ fun TerminalView( val symHeight = slot.height val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb()) if (rgbArray != null && symWidth > 0 && symHeight > 0) { - val bmp = android.graphics.Bitmap.createBitmap(rgbArray as IntArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888) + val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888) drawContext.canvas.nativeCanvas.drawBitmap( bmp, null, diff --git a/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt b/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt new file mode 100644 index 0000000..af447ce --- /dev/null +++ b/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt @@ -0,0 +1,178 @@ +package haus.nightmare.a3270 + +import haus.nightmare.lib3270j.ConnectionConfig +import haus.nightmare.lib3270j.ConnectionState +import haus.nightmare.lib3270j.TerminalModel +import haus.nightmare.lib3270j.charset.EbcdicTranslator +import haus.nightmare.lib3270j.datastream.DataStreamProcessor +import haus.nightmare.lib3270j.graphics.GocaDecoder +import haus.nightmare.lib3270j.graphics.GraphicsPlane +import haus.nightmare.lib3270j.input.InputProcessor +import haus.nightmare.lib3270j.protocol.DS3270Constants.* +import haus.nightmare.lib3270j.protocol.TelnetConstants.* +import haus.nightmare.lib3270j.protocol.TN3270EConstants.* +import haus.nightmare.lib3270j.screen.ScreenBuffer +import haus.nightmare.lib3270j.telnet.TelnetConnection +import haus.nightmare.lib3270j.telnet.TelnetFSM +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.util.ArrayList +import java.util.concurrent.atomic.AtomicReference + +class GraphicsAndSysReqTest { + + private lateinit var screenBuffer: ScreenBuffer + private lateinit var translator: EbcdicTranslator + private lateinit var graphicsPlane: GraphicsPlane + private lateinit var gocaDecoder: GocaDecoder + + private class MockConnection(config: ConnectionConfig, fsm: TelnetFSM) : TelnetConnection(config, fsm) { + val sentData: MutableList = ArrayList() + + override fun sendRaw(data: ByteArray) { + sentData.add(data.clone()) + } + + override fun sendRaw(data: ByteArray, offset: Int, length: Int) { + val b = ByteArray(length) + System.arraycopy(data, offset, b, 0, length) + sentData.add(b) + } + } + + @Before + fun setUp() { + val model = TerminalModel.IBM_3279_2 // 24x80 + translator = EbcdicTranslator() + screenBuffer = ScreenBuffer(model, translator) + graphicsPlane = GraphicsPlane(800, 600) + gocaDecoder = GocaDecoder(graphicsPlane) + } + + @Test + fun testScreenBufferSetCursorPosition() { + screenBuffer.setCursorPosition(5, 10) + assertEquals(5 * 80 + 10, screenBuffer.cursorAddress) + assertEquals(5, screenBuffer.cursorRow) + assertEquals(10, screenBuffer.cursorCol) + + // Clamping check + screenBuffer.setCursorPosition(-5, -10) + assertEquals(0, screenBuffer.cursorAddress) + + screenBuffer.setCursorPosition(100, 200) + assertEquals(23 * 80 + 79, screenBuffer.cursorAddress) + } + + @Test + fun testGraphicsCursorFramingOnSendAid() { + screenBuffer.erase(false) + screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte()) + screenBuffer.getCell(1).ec = 0xC1.toByte() // 'A' + screenBuffer.setCellFA(5, (FA_PRINTABLE or FA_PROTECT).toByte()) + screenBuffer.cursorAddress = 2 + + gocaDecoder.isGraphicsCursorActive = true + gocaDecoder.setGraphicCursorPosition(200, -100) + + val sentRef = AtomicReference() + val inputProcessor = object : InputProcessor(screenBuffer, translator, null) { + override fun sendAidResponse(data: ByteArray) { + sentRef.set(data) + } + } + inputProcessor.gocaDecoder = gocaDecoder + + inputProcessor.sendAid(AID_ENTER) + + val result = sentRef.get() + assertNotNull(result) + // Expected: AID_SF (0x88) + SF(56 bytes) + AID_ENTER(0x7D) + CursorAddr(2) + SBA(1) + FieldAddr(2) + Data 'A'(1) = 64 bytes + assertEquals(64, result.size) + assertEquals(AID_SF.toByte(), result[0]) + assertEquals(0x00.toByte(), result[1]) + assertEquals(0x34.toByte(), result[2]) // SF len 52 + assertEquals(0x0F.toByte(), result[3]) + assertEquals(0x0F.toByte(), result[4]) + + // Coordinates at index 25, 27 + val gx = ((result[25].toInt() and 0xFF) shl 8) or (result[26].toInt() and 0xFF) + val gy = ((result[27].toInt() and 0xFF) shl 8) or (result[28].toInt() and 0xFF) + assertEquals(200, gx.toShort().toInt()) + assertEquals(-100, gy.toShort().toInt()) + + // Trailing AID at index 57 + assertEquals(AID_ENTER.toByte(), result[57]) + } + + @Test + fun testSysReqOutOfBandRoutingAndStateToggle() { + val config = ConnectionConfig("127.0.0.1", 23, TerminalModel.IBM_3279_2).apply { + isTn3270eEnabled = true + } + val dsProcessor = DataStreamProcessor(screenBuffer, translator) + val fsm = TelnetFSM(config, screenBuffer, dsProcessor) + val inputProcessor = InputProcessor(screenBuffer, translator, fsm) + dsProcessor.inputProcessor = inputProcessor + val connection = MockConnection(config, fsm) + fsm.setConnection(connection) + fsm.onConnected() + + // 1. Complete TN3270E negotiation + fsm.feedBytes(byteArrayOf(IAC.toByte(), DO.toByte(), TELOPT_TN3270E.toByte()), 0, 3) + val devTypeIs = byteArrayOf( + IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(), + 0x02, 0x04, 'I'.code.toByte(), 'B'.code.toByte(), 'M'.code.toByte(), '-'.code.toByte(), + '3'.code.toByte(), '2'.code.toByte(), '7'.code.toByte(), '9'.code.toByte(), '-'.code.toByte(), + '2'.code.toByte(), '-'.code.toByte(), 'E'.code.toByte(), + IAC.toByte(), SE.toByte() + ) + fsm.feedBytes(devTypeIs, 0, devTypeIs.size) + + val funcsReq = byteArrayOf( + IAC.toByte(), SB.toByte(), TELOPT_TN3270E.toByte(), + OP_FUNCTIONS.toByte(), OP_REQUEST.toByte(), + FUNC_BIND_IMAGE.toByte(), FUNC_RESPONSES.toByte(), FUNC_SYSREQ.toByte(), + IAC.toByte(), SE.toByte() + ) + fsm.feedBytes(funcsReq, 0, funcsReq.size) + + // BIND image + val bindPacket = ByteArray(EH_SIZE + 35).apply { + this[0] = DT_BIND_IMAGE.toByte() + this[4] = 1 + this[EH_SIZE + 24] = 0x02 + } + val bStream = ByteArrayOutputStream().apply { + write(bindPacket) + write(IAC) + write(EOR) + } + val bindBytes = bStream.toByteArray() + fsm.feedBytes(bindBytes, 0, bindBytes.size) + + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState) + assertTrue(fsm.isTn3270eBound) + connection.sentData.clear() + + // 2. Lock keyboard and send SYSREQ + inputProcessor.isKeyboardLocked = true + inputProcessor.sendAid(AID_SYSREQ) + + assertFalse(inputProcessor.isKeyboardLocked) + var foundIacAo = false + for (pkt in connection.sentData) { + if (pkt.size == 2 && (pkt[0].toInt() and 0xFF) == IAC && (pkt[1].toInt() and 0xFF) == AO) { + foundIacAo = true + } + } + assertTrue("Expected IAC AO out-of-band telnet command", foundIacAo) + assertEquals(ConnectionState.CONNECTED_SSCP, fsm.connectionState) + + // 3. Send second SYSREQ to toggle back + inputProcessor.sendAid(AID_SYSREQ) + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState) + } +}