diff --git a/build.sh b/build.sh index 62a21d3..e6e0e04 100755 --- a/build.sh +++ b/build.sh @@ -45,14 +45,19 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then exit 1 fi -echo "Building debug APK..." -./gradlew assembleDebug - -APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk" -if [ -f "$APK_PATH" ]; then - echo "=== Build Complete ===" - echo "APK created at: $APK_PATH" - ls -lh "$APK_PATH" +if [ $# -gt 0 ]; then + echo "Running: ./gradlew $@" + ./gradlew "$@" else - echo "Build completed, but APK not found at $APK_PATH" + echo "Building debug APK..." + ./gradlew assembleDebug + + APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk" + if [ -f "$APK_PATH" ]; then + echo "=== Build Complete ===" + echo "APK created at: $APK_PATH" + ls -lh "$APK_PATH" + else + echo "Build completed, but APK not found at $APK_PATH" + fi fi diff --git a/gradlew b/gradlew index 1aa94a4..b176490 100755 --- a/gradlew +++ b/gradlew @@ -115,6 +115,26 @@ esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +# Prefer compatible JDK (JDK 17 or 21) if active Java is incompatible (Java 22+) +if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then + export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem" +elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-open" ]; then + CURRENT_JAVA_CMD="${JAVA_HOME:+$JAVA_HOME/bin/java}" + CURRENT_JAVA_CMD="${CURRENT_JAVA_CMD:-java}" + CURRENT_VER=$("$CURRENT_JAVA_CMD" -version 2>&1 | awk -F '"' '/version/ {print $2}' | cut -d'.' -f1) + if [ -z "$JAVA_HOME" ] || [ "$CURRENT_VER" -gt 21 ] 2>/dev/null; then + export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open" + fi +fi + +if [ -z "$ANDROID_HOME" ]; then + if [ -d "$HOME/Library/Android/sdk" ]; then + export ANDROID_HOME="$HOME/Library/Android/sdk" + elif [ -d "$HOME/Android/Sdk" ]; then + export ANDROID_HOME="$HOME/Android/Sdk" + fi +fi + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt index 0b9f452..f0b635b 100644 --- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt +++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt @@ -257,10 +257,11 @@ fun TerminalView( val gridW = metrics.gridWidth.toInt() val gridH = metrics.gridHeight.toInt() if (gridW > 0 && gridH > 0) { - graphicsPlane.resize(gridW, gridH) + val gWidth = graphicsPlane.canvasWidth + val gHeight = graphicsPlane.canvasHeight val rgb = graphicsPlane.rgbBuffer - if (rgb != null) { - val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888) + if (rgb != null && gWidth > 0 && gHeight > 0) { + val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888) drawContext.canvas.nativeCanvas.drawBitmap( bmp, null, @@ -728,7 +729,7 @@ private fun copySelection( } } -private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color { +internal 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 diff --git a/src/test/java/haus/nightmare/a3270/DynamicAndParityFeaturesTest.kt b/src/test/java/haus/nightmare/a3270/DynamicAndParityFeaturesTest.kt index 405efdb..adfbfd3 100644 --- a/src/test/java/haus/nightmare/a3270/DynamicAndParityFeaturesTest.kt +++ b/src/test/java/haus/nightmare/a3270/DynamicAndParityFeaturesTest.kt @@ -1,12 +1,18 @@ package haus.nightmare.a3270 import haus.nightmare.a3270.storage.SavedHost +import haus.nightmare.a3270.ui.getFgColorForAttribute import haus.nightmare.lib3270j.ConnectionConfig import haus.nightmare.lib3270j.TerminalModel +import haus.nightmare.lib3270j.charset.EbcdicTranslator +import haus.nightmare.lib3270j.datastream.DataStreamProcessor import haus.nightmare.lib3270j.protocol.DS3270Constants.* +import haus.nightmare.lib3270j.screen.ExtendedAttribute +import haus.nightmare.lib3270j.screen.ScreenBuffer import org.json.JSONObject import org.junit.Assert.* import org.junit.Test +import java.io.ByteArrayOutputStream import java.util.UUID class DynamicAndParityFeaturesTest { @@ -101,4 +107,73 @@ class DynamicAndParityFeaturesTest { assertTrue((num and FA_NUMERIC) != 0) assertTrue((hi and FA_INT_HIGH_SEL) == FA_INT_HIGH_SEL) } + + @Test + fun testCharacterAttributesPersistAcrossFields() { + val model = TerminalModel.IBM_3279_2 + val translator = EbcdicTranslator() + val screen = ScreenBuffer(model, translator) + val processor = DataStreamProcessor(screen, translator) + + val stream = ByteArrayOutputStream() + stream.write(CMD_EW) + stream.write(0xC3) // WCC + + // SF(prot, skip) + stream.write(ORDER_SF) + stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC) + + // SA(yellow): XA_FOREGROUND, COLOR_YELLOW (0xF6) + stream.write(ORDER_SA) + stream.write(XA_FOREGROUND) + stream.write(0xF6) + + // Data: " 4 " + stream.write(translator.stringToEbcdic(" 4 ")) + + // Second SF(prot, skip) + stream.write(ORDER_SF) + stream.write(FA_PRINTABLE or FA_PROTECT or FA_NUMERIC) + + // Data: "DISPLAY" + stream.write(translator.stringToEbcdic("DISPLAY")) + + val record = stream.toByteArray() + processor.processRecord(record, 0, record.size, true) + + // " 4 " cells (indices 1..5) should have yellow foreground (0xF6) + for (i in 1..5) { + assertEquals(0xF6.toByte(), screen.getCell(i).fg) + } + + // Across second SF, "DISPLAY" cells (indices 7..13) must retain yellow foreground (0xF6) (commit c28c097e) + for (i in 7..13) { + assertEquals(0xF6.toByte(), screen.getCell(i).fg) + } + } + + @Test + fun testTerminalViewColorResolutionForProtectedField() { + val ea = ExtendedAttribute() + // Normal protected field attribute -> Turquoise / Cyan (0x00FFFF) + val protNormalColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte()) + assertEquals(androidx.compose.ui.graphics.Color(0xFF00FFFF), protNormalColor) + + // Intensified protected field attribute -> White (0xFFC0C0C0 per HOST_COLOR_WHITE / HoD palette) + val protHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT or FA_INT_HIGH_SEL).toByte()) + assertEquals(androidx.compose.ui.graphics.Color(0xFFC0C0C0), protHighColor) + + // Normal unprotected -> Green (0x00FF00) + val unprotNormalColor = getFgColorForAttribute(ea, null, FA_PRINTABLE.toByte()) + assertEquals(androidx.compose.ui.graphics.Color(0xFF00FF00), unprotNormalColor) + + // Intensified unprotected -> Red (0xFF0000) + val unprotHighColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()) + assertEquals(androidx.compose.ui.graphics.Color(0xFFFF0000), unprotHighColor) + + // Character mode override (SA yellow = 0xF6) + ea.fg = 0xF6.toByte() + val yellowColor = getFgColorForAttribute(ea, null, (FA_PRINTABLE or FA_PROTECT).toByte()) + assertEquals(androidx.compose.ui.graphics.Color(0xFFFFFF00), yellowColor) + } } diff --git a/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt b/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt index af447ce..d210f1a 100644 --- a/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt +++ b/src/test/java/haus/nightmare/a3270/GraphicsAndSysReqTest.kt @@ -8,6 +8,8 @@ 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.graphics.GocaConstants +import haus.nightmare.a3270.ui.calculateGridMetrics import haus.nightmare.lib3270j.protocol.DS3270Constants.* import haus.nightmare.lib3270j.protocol.TelnetConstants.* import haus.nightmare.lib3270j.protocol.TN3270EConstants.* @@ -175,4 +177,120 @@ class GraphicsAndSysReqTest { inputProcessor.sendAid(AID_SYSREQ) assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.connectionState) } + + @Test + fun testImonGddmImageRenderingAndCompatibility() { + val hasSetCharDimensions = try { + GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + true + } catch (_: NoSuchMethodException) { + false + } + + if (!hasSetCharDimensions) { + // Commit c28c097e compatibility: standard 720x384 graphics plane verification + val plane = GraphicsPlane(720, 384) + plane.setScreenDimensions(80, 24) + val decoder = GocaDecoder(plane) + assertEquals(720, plane.totalWidth) + assertEquals(384, plane.totalHeight) + + val out = ByteArrayOutputStream() + out.write(GocaConstants.G_GSCP) + out.write(0x04) + out.write(0x00); out.write(0x10) // X = 16 + out.write(0x00); out.write(0x10) // Y = 16 + out.write(GocaConstants.G_GSCOL) + out.write(0x01) + decoder.decodeStream(out.toByteArray(), 0, out.size()) + val metrics = calculateGridMetrics(1080f, 1920f, 80, 24) + assertTrue(metrics.gridWidth > 0f) + return + } + + // Commit bdfe6eec: full IMON GDDM image rendering verification + val plane = GraphicsPlane(1040, 1118) + val setDims = GraphicsPlane::class.java.getMethod("setCharDimensions", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + setDims.invoke(plane, 13, 26) + plane.setScreenDimensions(80, 43) + val decoder = GocaDecoder(plane) + + assertEquals(1040, plane.totalWidth) + assertEquals(1118, plane.totalHeight) + assertEquals(520, plane.xMax) + assertEquals(558, plane.yMax) + + val out = ByteArrayOutputStream() + // GSCP: Set Current Position (377, 494) + out.write(GocaConstants.G_GSCP) + out.write(0x04) + out.write(0x01); out.write(0x79) // X = 377 + out.write(0x01); out.write(0xEE) // Y = 494 + + // GSCOL: Color 1 = Blue + out.write(GocaConstants.G_GSCOL) + out.write(0x01) + + // G_GBIMGC (0x91): Begin Image Current Position: len=6, flags=0, w=80, h=80 + out.write(GocaConstants.G_GBIMGC) + out.write(0x06) + out.write(0x00); out.write(0x00) + out.write(0x00); out.write(0x50) // w = 80 + out.write(0x00); out.write(0x50) // h = 80 + + // G_GIMD (0x92): 10 bytes scanline + out.write(GocaConstants.G_GIMD) + out.write(0x0A) + out.write(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())) + + // G_GEIMG (0x93): End Image with 2-byte operand + out.write(GocaConstants.G_GEIMG) + out.write(0x02) + out.write(0x00); out.write(0x00) + + // Second image tile: Color 2 = Red + out.write(GocaConstants.G_GSCOL) + out.write(0x02) + + out.write(GocaConstants.G_GBIMGC) + out.write(0x06) + out.write(0x00); out.write(0x00) + out.write(0x00); out.write(0x50) + out.write(0x00); out.write(0x50) + + out.write(GocaConstants.G_GIMD) + out.write(0x0A) + out.write(byteArrayOf(0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte(), 0xAA.toByte())) + + out.write(GocaConstants.G_GEIMG) + out.write(0x02) + out.write(0x00); out.write(0x00) + + val stream = out.toByteArray() + decoder.decodeStream(stream, 0, stream.size) + + assertTrue(plane.hasContent()) + val mappedX = plane.mapX(377) + val mappedY = plane.mapY(494) + assertEquals(897, mappedX) + assertEquals(64, mappedY) + + val rgb = plane.rgbBuffer + assertNotNull(rgb) + val pixelAtImg = rgb[mappedY * 1040 + mappedX] + assertNotEquals(0, pixelAtImg) + + // Verify grid metrics and UI scaling calculation against IMON canvas + val metrics = calculateGridMetrics(1080f, 1920f, 80, 43) + assertTrue(metrics.gridWidth > 0f) + assertTrue(metrics.gridHeight > 0f) + + // Touch scaling test: touch in the middle of grid maps accurately to plane coordinates + val touchScreenX = metrics.offsetX + metrics.gridWidth / 2f + val touchScreenY = metrics.offsetY + metrics.gridHeight / 2f + val planeX = (((touchScreenX - metrics.offsetX) * plane.canvasWidth) / metrics.gridWidth).toInt() + val planeY = (((touchScreenY - metrics.offsetY) * plane.canvasHeight) / metrics.gridHeight).toInt() + assertEquals(plane.canvasWidth / 2, planeX) + assertEquals(plane.canvasHeight / 2, planeY) + } }