3 Commits
Author SHA1 Message Date
rudi ecae8c0669 Version bump
Build and Test a3270 / Build Android APK (push) Successful in 5m36s
Release a3270 / Build & Publish Release (push) Successful in 5m40s
2026-10-28 18:09:40 +00:00
rudi 207fa48f15 Add screenshots
Build and Test a3270 / Build Android APK (push) Successful in 3m35s
2026-10-19 18:47:01 +00:00
rudi ffae82bab2 Patch memory hole cleaner, and bounce version
Build and Test a3270 / Build Android APK (push) Successful in 6m47s
2026-10-19 00:44:55 +00:00
10 changed files with 134 additions and 79 deletions
+8
View File
@@ -144,6 +144,14 @@ build/outputs/apk/debug/a3270-debug.apk
---
## Screenshots
| z/VM Logon | GDDM / GOCA Graphics |
| :---: | :---: |
| ![z/VM Logon](Screenshots/Screenshot_20261019-143416.png) | ![GDDM / GOCA Graphics](Screenshots/Screenshot_20261019-143446.png) |
---
## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 9
versionName "1.2.4"
versionCode 10
versionName "1.2.5"
}
buildTypes {
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="9"
android:versionName="1.2.4">
android:versionCode="10"
android:versionName="1.2.5">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -94,8 +94,8 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
// Non-blocking FIFO input channel buffer
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
// Bounded FIFO input channel buffer (prevents unbounded queue growth if worker blocks)
private val inputChannel = Channel<TerminalInputAction>(256)
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@@ -508,9 +508,14 @@ fun FileTransferDialog(
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
// TSO / CMS Allocation & Record Format Options
if (!isReceive && (hostType == FTConfig.HostType.TSO || hostType == FTConfig.HostType.CMS)) {
Text(
if (hostType == FTConfig.HostType.TSO) "TSO Dataset Allocation:" else "VM/CMS File Format:",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -542,64 +547,70 @@ fun FileTransferDialog(
onValueChange = { lreclStr = it.filter { c -> c.isDigit() } },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = if (hostType == FTConfig.HostType.TSO) Modifier.weight(1f) else Modifier.fillMaxWidth()
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Units:", fontSize = 11.sp)
Row {
listOf(
FTConfig.AllocationUnit.TRACKS to "Tracks",
FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
FTConfig.AllocationUnit.AVBLOCK to "Avblock",
FTConfig.AllocationUnit.DEFAULT to "Default"
).forEach { (u, label) ->
FilterChip(
selected = (units == u),
onClick = { units = u },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
if (hostType == FTConfig.HostType.TSO) {
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it.filter { c -> c.isDigit() } },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = spaceStr,
onValueChange = { spaceStr = it },
label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
placeholder = { Text("e.g. 5,2 or 1,1") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.62f)
)
OutlinedTextField(
value = dirBlocks,
onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
label = { Text("Dir (PDS)", fontSize = 11.sp) },
placeholder = { Text("e.g. 10") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(0.38f)
)
if (hostType == FTConfig.HostType.TSO) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Units:", fontSize = 11.sp)
Row {
listOf(
FTConfig.AllocationUnit.TRACKS to "Tracks",
FTConfig.AllocationUnit.CYLINDERS to "Cylinders",
FTConfig.AllocationUnit.AVBLOCK to "Avblock",
FTConfig.AllocationUnit.DEFAULT to "Default"
).forEach { (u, label) ->
FilterChip(
selected = (units == u),
onClick = { units = u },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = spaceStr,
onValueChange = { spaceStr = it },
label = { Text("SPACE (Pri, Sec)", fontSize = 11.sp) },
placeholder = { Text("e.g. 5,2 or 1,1") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.62f)
)
OutlinedTextField(
value = dirBlocks,
onValueChange = { dirBlocks = it.filter { c -> c.isDigit() } },
label = { Text("Dir (PDS)", fontSize = 11.sp) },
placeholder = { Text("e.g. 10") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.weight(0.38f)
)
}
}
}
@@ -637,13 +648,17 @@ fun FileTransferDialog(
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
setHostType(hostType)
setExistAction(if (overwrite) FTConfig.ExistAction.REPLACE else FTConfig.ExistAction.KEEP)
if (!isReceive && hostType == FTConfig.HostType.TSO) {
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
if (!isReceive) {
if (hostType == FTConfig.HostType.TSO || hostType == FTConfig.HostType.CMS) {
setRecfm(recfm)
lreclStr.trim().toIntOrNull()?.let { setLrecl(it) }
}
if (hostType == FTConfig.HostType.TSO) {
blksizeStr.trim().toIntOrNull()?.let { setBlksize(it) }
setUnits(units)
if (spaceStr.isNotBlank()) setSpace(spaceStr.trim())
if (dirBlocks.isNotBlank()) setDirectoryBlocks(dirBlocks.trim())
}
}
if (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim())
@@ -77,7 +77,12 @@ fun MacroDialog(
fun appendLog(msg: String) {
val line = "[${java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US).format(java.util.Date())}] $msg\n"
executionLog += line
val combined = executionLog + line
executionLog = if (combined.length > 131072) {
combined.substring(combined.length - 131072)
} else {
combined
}
}
DisposableEffect(client) {
@@ -126,6 +126,15 @@ fun TerminalView(
}
}
val paint = remember {
Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textAlign = Paint.Align.CENTER
}
}
var cachedGraphicsBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
@@ -289,12 +298,6 @@ fun TerminalView(
val offsetX = metrics.offsetX
val offsetY = metrics.offsetY
val paint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textAlign = Paint.Align.CENTER
}
// Calculate optimal font size ensuring characters fit cleanly in cell without crowding
val baseTextSize = cellHeight * 0.78f
paint.textSize = baseTextSize
@@ -335,8 +338,13 @@ fun TerminalView(
val gWidth = graphicsPlane.canvasWidth
val gHeight = graphicsPlane.canvasHeight
val rgb = graphicsPlane.rgbBuffer
if (gridW > 0 && gridH > 0 && rgb != null && gWidth > 0 && gHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888)
if (gridW > 0 && gridH > 0 && rgb != null && gWidth > 0 && gHeight > 0 && rgb.size >= gWidth * gHeight) {
var bmp = cachedGraphicsBitmap
if (bmp == null || bmp.width != gWidth || bmp.height != gHeight || bmp.isRecycled) {
bmp = android.graphics.Bitmap.createBitmap(gWidth, gHeight, android.graphics.Bitmap.Config.ARGB_8888)
cachedGraphicsBitmap = bmp
}
bmp.setPixels(rgb, 0, gWidth, 0, 0, gWidth, gHeight)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
@@ -229,6 +229,25 @@ class ConnectionAndHostDirectoryTest {
assertEquals(FTConfig.HostType.CMS, state.hostType)
}
@Test
fun testCmsFileTransferCommandGeneration() {
val cfg = FTConfig().apply {
setHostFilename("PROFILE EXEC A")
setLocalFilename("/tmp/profile.exec")
setDirection(FTConfig.Direction.SEND)
setTransferMode(FTConfig.TransferMode.ASCII)
setHostType(FTConfig.HostType.CMS)
setExistAction(FTConfig.ExistAction.REPLACE)
setRecfm(FTConfig.RecordFormat.FIXED)
setLrecl(80)
}
val cmd = cfg.buildCommand()
assertTrue("CMS command must contain RECFM F", cmd.contains("RECFM F"))
assertTrue("CMS command must contain LRECL 80", cmd.contains("LRECL 80"))
assertFalse("CMS command must not contain REPLACE keyword", cmd.contains("REPLACE"))
assertFalse("CMS command must not contain BLOCK keyword", cmd.contains("BLOCK"))
}
@Test
fun testConnectChoiceEnum() {
val choices = ConnectChoice.values()