diff --git a/build.gradle b/build.gradle index d654f6c..9fc5807 100644 --- a/build.gradle +++ b/build.gradle @@ -11,8 +11,8 @@ android { applicationId "haus.nightmare.a3270" minSdk 24 targetSdk 34 - versionCode 8 - versionName "1.2.3" + versionCode 9 + versionName "1.2.4" } buildTypes { diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 64eee79..cf9066f 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ + android:versionCode="9" + android:versionName="1.2.4"> diff --git a/src/main/java/haus/nightmare/a3270/MainActivity.kt b/src/main/java/haus/nightmare/a3270/MainActivity.kt index fe6f3a6..a08b3f2 100644 --- a/src/main/java/haus/nightmare/a3270/MainActivity.kt +++ b/src/main/java/haus/nightmare/a3270/MainActivity.kt @@ -555,7 +555,7 @@ fun MainScreen( val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) { val buf = screenBuffer if (buf != null && buf.isFormatted) { - val cell = buf.getCell(cursorAddr) + val cell = buf.getDisplayCell(cursorAddr) val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT || cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT || cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS)) diff --git a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt index 629f8d9..9b53c08 100644 --- a/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt +++ b/src/main/java/haus/nightmare/a3270/TerminalViewModel.kt @@ -481,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application } val c = client if (c != null) { - _cursorAddress.value = c.screenBuffer.cursorAddress + _cursorAddress.value = c.screenBuffer.displayCursorAddress + val snapVersion = c.screenBuffer.snapshotVersion + _screenVersion.value = if (snapVersion > 0) snapVersion else System.currentTimeMillis() + } else { + _screenVersion.value = System.currentTimeMillis() } - _screenVersion.value = System.currentTimeMillis() } } } @@ -1096,16 +1099,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application val buf = newClient.screenBuffer _screenBuffer.value = buf if (buf != null) { - _rows.value = buf.rows - _cols.value = buf.cols - _cursorAddress.value = buf.cursorAddress + val snap = buf.displaySnapshot + if (snap != null) { + _rows.value = snap.rows + _cols.value = snap.cols + _cursorAddress.value = snap.cursorAddress + _screenVersion.value = snap.version + } else { + _rows.value = buf.displayRows + _cols.value = buf.displayCols + _cursorAddress.value = buf.displayCursorAddress + _screenVersion.value = buf.snapshotVersion + } + } else { + _screenVersion.value = System.currentTimeMillis() } _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isInsertMode.value = newClient.isInsertMode _isDocMode.value = newClient.isDocMode _isWordWrap.value = newClient.isWordWrap _isAplMode.value = newClient.isAplMode - _screenVersion.value = System.currentTimeMillis() // Drive CUT mode file transfers if active fileTransferCoordinator?.onScreenUpdated() @@ -1478,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String { val sb = StringBuilder() + val snap = buf.displaySnapshot for (c in 0 until cols) { val addr = row * cols + c - val cell = buf.getCell(addr) + val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr) if (cell != null && !cell.isFieldAttribute) { val ch = cell.ucs4 if (ch in '!'..'~' || ch.code > 127) { @@ -1498,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application private fun computeScreenContentHash(buf: ScreenBuffer?): Int { if (buf == null) return 0 val sb = StringBuilder() - val total = buf.rows * buf.cols + val snap = buf.displaySnapshot + val total = (snap?.rows ?: buf.displayRows) * (snap?.cols ?: buf.displayCols) for (i in 0 until total) { - val cell = buf.getCell(i) + val cell = snap?.getCell(i) ?: buf.getDisplayCell(i) if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) { sb.append(cell.ucs4) } diff --git a/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt index 79b3c45..2e5e3a7 100644 --- a/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt +++ b/src/main/java/haus/nightmare/a3270/ui/ScreenExporterDialog.kt @@ -285,6 +285,7 @@ fun ScreenExporterDialog( private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { if (screenBuffer == null) return "" + screenBuffer.updateDisplaySnapshot() val sb = StringBuilder() var currentHidden = false @@ -292,7 +293,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, val rowSb = StringBuilder() for (c in 0 until cols) { val addr = r * cols + c - val cell = screenBuffer.getCell(addr) + val cell = screenBuffer.getDisplayCell(addr) ?: continue if (cell.fa.toInt() != 0) { val faVal = cell.fa.toInt() and 0xFF currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 @@ -317,6 +318,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { if (screenBuffer == null) return "
"
+    screenBuffer.updateDisplaySnapshot()
     val sb = StringBuilder()
     sb.append("\n\n\n")
     sb.append("\n")
@@ -343,7 +345,7 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
     for (r in 0 until rows) {
         for (c in 0 until cols) {
             val addr = r * cols + c
-            val cell = screenBuffer.getCell(addr)
+            val cell = screenBuffer.getDisplayCell(addr) ?: continue
             if (cell.fa.toInt() != 0) {
                 val faVal = cell.fa.toInt() and 0xFF
                 currentProt = (faVal and FA_PROTECT) != 0
@@ -430,6 +432,7 @@ private fun generateBitmap(
     canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
 
     if (screenBuffer == null) return bitmap
+    screenBuffer.updateDisplaySnapshot()
 
     // Layer 1: Vector graphics plane (under text)
     if (graphicsPlane != null && graphicsPlane.hasContent()) {
@@ -457,7 +460,7 @@ private fun generateBitmap(
     for (r in 0 until rows) {
         for (c in 0 until cols) {
             val addr = r * cols + c
-            val cell = screenBuffer.getCell(addr)
+            val cell = screenBuffer.getDisplayCell(addr) ?: continue
             val left = (c * charWidth).toFloat()
             val top = (r * charHeight).toFloat()
 
diff --git a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt
index 2732d30..2373940 100644
--- a/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt
+++ b/src/main/java/haus/nightmare/a3270/ui/TerminalView.kt
@@ -219,7 +219,7 @@ fun TerminalView(
                                             if (buf != null && buf.isFormatted) {
                                                 val faPos = buf.findFieldAttribute(clickAddr)
                                                 if (faPos >= 0) {
-                                                    val fa = buf.getCell(faPos).fa.toInt() and 0xFF
+                                                    val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
                                                     if (faIsSelectable(fa) && !faIsZero(fa)) {
                                                         onLightPenSelect?.invoke(clickAddr)
                                                         isDetectable = true
@@ -232,7 +232,7 @@ fun TerminalView(
                                                 var addr = clickAddr
                                                 if (buf != null && buf.isFormatted) {
                                                     val faVal = buf.getFieldAttributeAt(addr)
-                                                    if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
+                                                    if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
                                                         addr = buf.findNextUnprotected(addr)
                                                     }
                                                 }
@@ -247,10 +247,15 @@ fun TerminalView(
                 }
         ) {
             val version = currentScreenVersion
-            val activeCursorAddr = currentCursorAddr
             if (version == -1L) return@Canvas
 
-            val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
+            val buf = screenBuffer
+            val snapshot = buf?.displaySnapshot
+            val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
+            val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
+            val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
+
+            val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
             if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
 
             val cellWidth = metrics.cellWidth
@@ -272,8 +277,7 @@ fun TerminalView(
                 paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
             }
 
-            val buf = screenBuffer
-            val totalCells = rows * cols
+            val totalCells = snapRows * snapCols
 
             var currentFA: Byte = 0
             var currentFieldEa: ExtendedAttribute? = null
@@ -287,10 +291,10 @@ fun TerminalView(
             val start = selectionStart
             val end = selectionEnd
             if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
-                val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
-                val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
-                val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
-                val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
+                val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
+                val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
+                val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
+                val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
 
                 selMinRow = minOf(startRow, endRow)
                 selMaxRow = maxOf(startRow, endRow)
@@ -317,11 +321,11 @@ fun TerminalView(
             }
 
             // 2. Draw Crosshair Ruler if enabled
-            if (crosshairRuler && cols > 0 && rows > 0) {
-                val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
-                val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
-                val gridW = cols * cellWidth
-                val gridH = rows * cellHeight
+            if (crosshairRuler && snapCols > 0 && snapRows > 0) {
+                val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
+                val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
+                val gridW = snapCols * cellWidth
+                val gridH = snapRows * cellHeight
                 val cx = offsetX + curCol * cellWidth
                 val cy = offsetY + curRow * cellHeight
 
@@ -337,9 +341,9 @@ fun TerminalView(
                 )
             }
 
-            for (r in 0 until rows) {
-                for (c in 0 until cols) {
-                    val addr = r * cols + c
+            for (r in 0 until snapRows) {
+                for (c in 0 until snapCols) {
+                    val addr = r * snapCols + c
                     if (addr >= totalCells) break
 
                     val left = offsetX + c * cellWidth
@@ -355,7 +359,7 @@ fun TerminalView(
                     var csVal = 0
                     var ecVal = 0
 
-                    val ea = if (buf != null) buf.getCell(addr) else null
+                    val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
 
                     if (ea != null) {
                         if (ea.isFieldAttribute) {
@@ -521,7 +525,7 @@ fun TerminalView(
                     }
 
                     // Field Outlining (0xC2 / TY_FOUTLINE)
-                    val cellEa = if (buf != null) buf.getCell(addr) else null
+                    val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
                     var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
                     val fEa = currentFieldEa
                     val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
@@ -536,11 +540,13 @@ fun TerminalView(
 
                         val size = totalCells
                         val prevAddr = (addr - 1 + size) % size
-                        if (buf != null && (buf.getDisplayCell(prevAddr)?.isFieldAttribute == true || c == 0)) {
+                        val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
+                        if (prevCell?.isFieldAttribute == true || c == 0) {
                             if ((fol and olLeft) != 0) outline = outline or olLeft
                         }
                         val nextAddr = (addr + 1) % size
-                        if (buf != null && (buf.getDisplayCell(nextAddr)?.isFieldAttribute == true || c == cols - 1)) {
+                        val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
+                        if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
                             if ((fol and olRight) != 0) outline = outline or olRight
                         }
                     }
@@ -889,7 +895,7 @@ private fun copySelection(
             for (c in colStart..colEnd) {
                 val addr = r * cols + c
                 if (addr < (rows * cols)) {
-                    val cell = screenBuffer.getCell(addr)
+                    val cell = screenBuffer.getDisplayCell(addr)
                     if (cell != null && !cell.isFieldAttribute) {
                         if (cell.db == ExtendedAttribute.DB_RIGHT) {
                             val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
diff --git a/src/test/java/haus/nightmare/a3270/DisplaySnapshotIntegrationTest.kt b/src/test/java/haus/nightmare/a3270/DisplaySnapshotIntegrationTest.kt
new file mode 100644
index 0000000..87db1fa
--- /dev/null
+++ b/src/test/java/haus/nightmare/a3270/DisplaySnapshotIntegrationTest.kt
@@ -0,0 +1,204 @@
+package haus.nightmare.a3270
+
+import haus.nightmare.lib3270j.TerminalModel
+import haus.nightmare.lib3270j.charset.EbcdicTranslator
+import haus.nightmare.lib3270j.screen.ExtendedAttribute
+import haus.nightmare.lib3270j.screen.ScreenBuffer
+import org.junit.Assert.*
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicLong
+
+class DisplaySnapshotIntegrationTest {
+
+    @Test
+    fun testSnapshotCaptureAndLockFreeReads() {
+        val translator = EbcdicTranslator()
+        val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
+
+        sb.erase(false)
+        val initialVersion = sb.snapshotVersion
+
+        sb.getCell(0).ucs4 = 'H'
+        sb.getCell(1).ucs4 = 'E'
+        sb.getCell(2).ucs4 = 'L'
+        sb.getCell(3).ucs4 = 'L'
+        sb.getCell(4).ucs4 = 'O'
+        sb.setCursorAddress(42)
+        sb.updateDisplaySnapshot()
+
+        val snap = sb.displaySnapshot
+        assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
+        assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
+        assertEquals(24, snap.rows)
+        assertEquals(80, snap.cols)
+        assertEquals(42, snap.cursorAddress)
+        assertEquals('H', snap.getCell(0)?.ucs4)
+        assertEquals('E', snap.getCell(1)?.ucs4)
+        assertEquals('L', snap.getCell(2)?.ucs4)
+        assertEquals('L', snap.getCell(3)?.ucs4)
+        assertEquals('O', snap.getCell(4)?.ucs4)
+
+        // getDisplayCell fallback
+        assertEquals('H', sb.getDisplayCell(0)?.ucs4)
+        assertEquals(42, sb.displayCursorAddress)
+        assertEquals(24, sb.displayRows)
+        assertEquals(80, sb.displayCols)
+    }
+
+    @Test
+    fun testTearFreeRenderingUnderConcurrentBufferMutation() {
+        val translator = EbcdicTranslator()
+        val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
+        sb.erase(false)
+
+        val size = 24 * 80
+        val totalUpdates = 2000
+        val workerCount = 4
+        val executor = Executors.newFixedThreadPool(workerCount + 1)
+        val running = AtomicBoolean(true)
+        val tornCellCount = AtomicInteger(0)
+        val exceptionCount = AtomicInteger(0)
+        val totalReads = AtomicLong(0)
+
+        val startLatch = CountDownLatch(1)
+        val writersDoneLatch = CountDownLatch(1)
+
+        // Reader workers simulating Compose Canvas draw passes
+        for (w in 0 until workerCount) {
+            executor.submit {
+                try {
+                    startLatch.await()
+                    while (running.get()) {
+                        val snap = sb.displaySnapshot ?: continue
+                        val snapSize = snap.rows * snap.cols
+
+                        for (addr in 0 until snapSize) {
+                            val cell = snap.getCell(addr) ?: continue
+                            totalReads.incrementAndGet()
+
+                            // Patterned validation:
+                            // ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
+                            if (cell.ucs4 in 'A'..'Z') {
+                                val ucsOffset = cell.ucs4 - 'A'
+                                val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
+                                val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
+                                val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
+
+                                if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
+                                    tornCellCount.incrementAndGet()
+                                }
+                            }
+                        }
+                    }
+                } catch (t: Throwable) {
+                    exceptionCount.incrementAndGet()
+                }
+            }
+        }
+
+        // Writer task simulating background Telnet network receiver thread
+        executor.submit {
+            try {
+                startLatch.await()
+                for (iter in 0 until totalUpdates) {
+                    val mod = iter % 26
+                    val ch = ('A' + mod)
+                    val ec = (0x10 + mod).toByte()
+                    val fg = (0x30 + mod).toByte()
+                    val bg = (0x50 + mod).toByte()
+
+                    val rawBuf = sb.buffer
+                    for (i in 0 until size) {
+                        val cell = rawBuf[i]
+                        cell.ucs4 = ch
+                        cell.ec = ec
+                        cell.fg = fg
+                        cell.bg = bg
+                    }
+                    sb.setCursorAddress(iter % size)
+                    sb.updateDisplaySnapshot()
+                }
+            } catch (t: Throwable) {
+                exceptionCount.incrementAndGet()
+            } finally {
+                writersDoneLatch.countDown()
+            }
+        }
+
+        startLatch.countDown()
+        val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
+        assertTrue("Writer must complete updates without deadlock", finished)
+
+        running.set(false)
+        executor.shutdown()
+        assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
+
+        assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
+        assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
+        assertTrue("Total reads must be significant", totalReads.get() > 50_000)
+    }
+
+    @Test
+    fun testConcurrentReadsDuringScreenResizing() {
+        val translator = EbcdicTranslator()
+        val sb = ScreenBuffer(24, 80, 27, 132, translator)
+        val workerCount = 4
+        val executor = Executors.newFixedThreadPool(workerCount + 1)
+        val running = AtomicBoolean(true)
+        val exceptionCount = AtomicInteger(0)
+        val successfulReads = AtomicLong(0)
+
+        val startLatch = CountDownLatch(1)
+        val writerLatch = CountDownLatch(1)
+
+        for (w in 0 until workerCount) {
+            executor.submit {
+                try {
+                    startLatch.await()
+                    while (running.get()) {
+                        val snap = sb.displaySnapshot ?: continue
+                        val total = snap.rows * snap.cols
+
+                        for (i in 0 until total) {
+                            val cell = snap.getCell(i)
+                            if (cell != null) {
+                                successfulReads.incrementAndGet()
+                            }
+                        }
+                    }
+                } catch (t: Throwable) {
+                    exceptionCount.incrementAndGet()
+                }
+            }
+        }
+
+        // Writer alternates dimensions between 24x80 and 27x132
+        executor.submit {
+            try {
+                startLatch.await()
+                for (iter in 0 until 500) {
+                    sb.erase((iter % 2) != 0)
+                }
+            } catch (t: Throwable) {
+                exceptionCount.incrementAndGet()
+            } finally {
+                writerLatch.countDown()
+            }
+        }
+
+        startLatch.countDown()
+        assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
+
+        running.set(false)
+        executor.shutdown()
+        assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
+
+        assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
+        assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
+    }
+}