This commit is contained in:
2026-08-31 00:28:47 +00:00
parent 600b626e94
commit 2ff6cf4416
7 changed files with 230 additions and 21 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 4 versionCode 5
versionName "0.1.3" versionName "1.0.0"
} }
buildTypes { buildTypes {
@@ -149,6 +149,7 @@ class MainActivity : ComponentActivity() {
return 0 return 0
} }
@Suppress("DEPRECATION")
override fun dispatchKeyEvent(event: KeyEvent): Boolean { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0) val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) { if (isShiftPressedState.value != shift) {
@@ -506,8 +507,8 @@ fun MainScreen(
viewModel.selectLightPen(addr) viewModel.selectLightPen(addr)
terminalInputViewRef?.showSoftKeyboard() terminalInputViewRef?.showSoftKeyboard()
}, },
onGraphicTouch = { px, py -> onGraphicTouch = { clickAddr, px, py ->
viewModel.handleGraphicTouch(px, py) viewModel.handleGraphicTouch(clickAddr, px, py)
}, },
onPasteText = { text -> onPasteText = { text ->
viewModel.pasteString(text) viewModel.pasteString(text)
@@ -330,14 +330,14 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val curRow = curAddr / cols val curRow = curAddr / cols
val curCol = curAddr % cols val curCol = curAddr % cols
var startRow = curRow val startRow: Int
var startCol = curCol val startCol: Int
if (forward) { if (forward) {
startCol = (curCol + 1) % cols startCol = (curCol + 1) % cols
if (startCol == 0) startRow = (curRow + 1) % rows startRow = if (startCol == 0) (curRow + 1) % rows else curRow
} else { } else {
startCol = (curCol - 1 + cols) % cols 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 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 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 c = client ?: return
val goca = c.gocaDecoder ?: return val goca = c.gocaDecoder ?: return
if (clickAddr >= 0) {
c.screenBuffer?.cursorAddress = clickAddr
_cursorAddress.value = clickAddr
}
if (goca.isGraphicsCursorActive) { if (goca.isGraphicsCursorActive) {
goca.setGraphicCursorFromPixel(px, py) goca.setGraphicCursorFromPixel(px, py)
c.inputProcessor.sendGraphicMouseAid( c.inputProcessor.sendGraphicMouseAid(
@@ -31,7 +31,7 @@ fun OiaStatusBar(
isLightPenMode: Boolean = false, isLightPenMode: Boolean = false,
modifier: Modifier = Modifier 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 col = if (cols > 0) (cursorAddr % cols) + 1 else 1
val posStr = String.format("%02d/%02d", row, col) val posStr = String.format("%02d/%02d", row, col)
@@ -416,15 +416,40 @@ private fun generateBitmap(
currentProt = (faVal and FA_PROTECT) != 0 currentProt = (faVal and FA_PROTECT) != 0
currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL currentHi = (faVal and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
} else if (!currentHidden || !maskHidden) { } else {
val ch = cell.ucs4 val csVal = cell.cs.toInt() and 0xFF
if (ch in ' '..'~' || ch > '\u007F') { val ecVal = cell.ec.toInt() and 0xFF
textPaint.color = when { var drawnAsPs = false
currentHi -> android.graphics.Color.WHITE if (csVal >= 0x40 && programSymbolManager != null) {
currentProt -> android.graphics.Color.parseColor("#3399FF") val slot = programSymbolManager.getSymbol(csVal, ecVal)
else -> android.graphics.Color.parseColor("#00FF66") 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)
} }
} }
} }
@@ -68,7 +68,7 @@ fun TerminalView(
searchHighlightLen: Int = 0, searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit, onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null, onLightPenSelect: ((Int) -> Unit)? = null,
onGraphicTouch: ((Int, Int) -> Unit)? = null, onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {}, onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -162,7 +162,8 @@ fun TerminalView(
val gHeight = graphicsPlane?.canvasHeight ?: gridH.toInt() 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 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() 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) { } else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr) onLightPenSelect?.invoke(clickAddr)
} else { } else {
@@ -351,7 +352,7 @@ fun TerminalView(
val symHeight = slot.height val symHeight = slot.height
val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb()) val rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) { 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( drawContext.canvas.nativeCanvas.drawBitmap(
bmp, bmp,
null, null,
@@ -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<ByteArray> = 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<ByteArray>()
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)
}
}