31 Commits

Author SHA1 Message Date
rudi 8608babb36 Hotspot detection debugging android specific
Build and Test a3270 / Build Android APK (push) Successful in 3m34s
Release a3270 / Build & Publish Release (push) Successful in 3m18s
2026-10-02 14:23:58 +00:00
rudi 1f98c0d70f Hotspot detection debugging
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
Release a3270 / Build & Publish Release (push) Successful in 5m16s
2026-10-02 13:55:18 +00:00
rudi 3843e0fd13 Fix misinterpreted if statement
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
2026-09-28 04:18:51 +00:00
rudi 4754732642 Update rendering pipeline
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
2026-09-28 02:17:46 +00:00
rudi 79ce0a7dc6 Bring up to date with Connection exporting and file transfer updates
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
Release a3270 / Build & Publish Release (push) Successful in 1m46s
2026-09-24 12:47:50 +00:00
rudi fe185bd1f0 Bring up to date with Connection exporting and file transfer updates 2026-09-24 12:46:55 +00:00
rudi ee44759668 Update Status Bar, Settings, Terminal alignment and codepage testing
Build and Test a3270 / Build Android APK (push) Successful in 1m58s
2026-09-22 13:41:10 +00:00
rudi 69ebe6bfb5 merge
Release a3270 / Build & Publish Release (push) Successful in 4m43s
Build and Test a3270 / Build Android APK (push) Successful in 5m42s
2026-09-18 18:49:09 +00:00
rudi 63d14bd2d9 Hotspot detection and screen buffer 2026-09-18 18:47:39 +00:00
rudi 377626013d Bump version to 1.2.1
Build and Test a3270 / Build Android APK (push) Successful in 5m35s
Release a3270 / Build & Publish Release (push) Successful in 4m35s
2026-09-16 17:17:29 -04:00
rudi bf5eefd190 Fix the builder
Build and Test a3270 / Build Android APK (push) Successful in 1m55s
Release a3270 / Build & Publish Release (push) Successful in 4m42s
2026-09-15 23:49:48 +00:00
rudi c941fd3a2a Add new settings from j3270
Build and Test a3270 / Build Android APK (push) Failing after 26s
2026-09-15 23:40:22 +00:00
rudi b71f0f5ba4 Add icon
Build and Test a3270 / Build Android APK (push) Successful in 2m22s
2026-09-14 12:24:07 +00:00
rudi b81a244bb9 Add bugfixes from testing
Build and Test a3270 / Build Android APK (push) Successful in 2m36s
Release a3270 / Build & Publish Release (push) Successful in 3m58s
2026-09-12 17:22:19 -04:00
rudi 351379b5b3 Merge branch 'main' of git.hugfreevikings.wtf:rudi/a3270
Build and Test a3270 / Build Android APK (push) Successful in 4m20s
# Conflicts:
#	build.sh
#	src/main/java/haus/nightmare/a3270/ui/TerminalView.kt
2026-09-12 15:54:59 +00:00
rudi eba1406cbb updtaes from j3270 2026-09-12 15:50:42 +00:00
rudi 88e3ffcbe9 Fix 3dot menu bug
Build and Test a3270 / Build Android APK (push) Successful in 6m3s
Release a3270 / Build & Publish Release (push) Successful in 3m56s
2026-09-08 11:40:39 -04:00
rudi f389e626ea Adjust graphics from j3270 changes
Build and Test a3270 / Build Android APK (push) Successful in 6m18s
2026-09-07 20:17:11 -04:00
rudi 0eaab32e2f Update Status/FileTransfer
Build and Test a3270 / Build Android APK (push) Successful in 3m39s
2026-09-06 09:30:26 -04:00
rudi da289730db Update README
Build and Test a3270 / Build Android APK (push) Successful in 2m54s
2026-09-05 14:24:32 -04:00
rudi f77f51474c Catch up to j3270
Build and Test a3270 / Build Android APK (push) Successful in 7m33s
2026-09-04 20:58:34 -04:00
rudi e82d01003b IBM-DYNAMIC
Build and Test a3270 / Build Android APK (push) Successful in 7m24s
Release a3270 / Build & Publish Release (push) Successful in 7m53s
2026-09-03 16:49:49 -04:00
rudi 31b85f491c Overnight churning adaptations
Build and Test a3270 / Build Android APK (push) Successful in 7m23s
Release a3270 / Build & Publish Release (push) Successful in 7m48s
2026-09-01 10:11:29 -04:00
rudi 9f96555c9d Begin v1.0.1 commits
Build and Test a3270 / Build Android APK (push) Successful in 7m26s
2026-08-31 10:30:32 -04:00
rudi a7cc4b9d50 v1
Build and Test a3270 / Build Android APK (push) Successful in 7m29s
Release a3270 / Build & Publish Release (push) Successful in 7m18s
2026-08-31 00:28:47 +00:00
rudi 600b626e94 And now with more buttons
Build and Test a3270 / Build Android APK (push) Successful in 7m2s
2026-08-28 14:17:25 -04:00
rudi 18c4203c0d Add lib3270j updates
Build and Test a3270 / Build Android APK (push) Successful in 6m57s
2026-08-28 14:13:41 -04:00
rudi f6d69bf0ff Refactor for consistency
Build and Test a3270 / Build Android APK (push) Successful in 6m54s
2026-08-25 16:05:28 +00:00
rudi aa008664bc Fix bug with xedit
Build and Test a3270 / Build Android APK (push) Successful in 4m37s
2026-08-25 01:12:09 +00:00
rudi 8344b9f4f7 j3270 updates applied for lightpen
Build and Test a3270 / Build Android APK (push) Successful in 4m34s
2026-08-24 20:46:09 +00:00
rudi 052a6619ae Add some cleaning
Build and Test a3270 / Build Android APK (push) Successful in 6m39s
2026-08-21 18:56:22 -04:00
65 changed files with 14387 additions and 3085 deletions
+8
View File
@@ -36,11 +36,19 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Make scripts executable
run: |
chmod +x ./gradlew ./build.sh
- name: Run Unit Tests
run: |
./gradlew testDebugUnitTest --no-daemon
env:
J3270_DIR: ${{ github.workspace }}/j3270
- name: Build Android APK
run: |
./gradlew assembleDebug --no-daemon
+2
View File
@@ -34,6 +34,8 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: ''
- name: Make scripts executable
run: |
+2
View File
@@ -9,3 +9,5 @@ captures/
.cxx
*.jar
!gradle/wrapper/gradle-wrapper.jar
*.txt
*.log*
+64 -21
View File
@@ -2,7 +2,7 @@
[![Build & Test](https://git.hugfreevikings.wtf/rudi/a3270/actions/workflows/build.yaml/badge.svg)](https://git.hugfreevikings.wtf/rudi/a3270/actions)
[![Android](https://img.shields.io/badge/Android-API%2024%2B%20%7C%20Compose-green.svg)](https://developer.android.com)
[![License](https://img.shields.io/badge/License-MIT%20%2F%20BSD-green.svg)](LICENSE)
[![License: Unlicense](https://img.shields.io/badge/License-Unlicense-blue.svg)](LICENSE)
An **x3270-aligned IBM 3270 mainframe terminal emulator for Android**, written in Kotlin using **Jetpack Compose** and powered by the `lib3270j` protocol engine from [j3270](https://git.hugfreevikings.wtf/rudi/j3270). Designed for mobile access to IBM z/OS, z/VM, CMS, and TSO systems over TN3270 and TN3270E.
@@ -34,20 +34,70 @@ adb install -r a3270.apk
---
## 📱 Compatibility & System Requirements
- **Minimum Android Version**: Android 7.0 (Nougat) — **API Level 24** (`minSdk 24`)
- **Target Android Version**: Android 14 — **API Level 34** (`targetSdk 34`)
- **Compile SDK**: **API Level 34** (`compileSdk 34`)
- **Architecture**: Universal — pure Kotlin/Java running on ART, fully compatible with all CPU ABIs (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`)
- **Form Factors**: Adaptive UI scaling optimized for smartphones, foldables, tablets, and Chromebooks (Android runtime)
- **Input Devices**: Full touch-screen support, native virtual keyboards, and external physical/Bluetooth keyboards
---
## ✨ Features
- **Modern Jetpack Compose UI**: Responsive, touch-optimized 3270 terminal screen rendering.
- **TN3270 & TN3270E Protocol Support**: RFC 2355 compliant state machine, negotiation, Device-Type query, and SSL/TLS encryption via `lib3270j`.
- **On-Screen Function Key Bar**: Quick-access touch bar for PF1–PF24, PA1–PA3, Enter, Clear, Reset, and Attn keys.
- **IND$FILE File Transfer**: Full mobile UI for **CUT** and **DFT (DDM)** high-speed structured field file transfers with ASCII/binary translation, CRLF handling, and recfm/lrecl formatting for TSO, VM/CMS, and CICS.
- **OIA Status Line**: Real-time Operator Information Area displaying connection state, keyboard lock, insert mode, cursor row/col, and LU name.
- **Host Profile Storage**: Save and manage multiple mainframe connection profiles with custom ports, models, and SSL/TLS options.
- **Background Session Persistence**: Android foreground service keeps your terminal session alive when multitasking.
- **Terminal Models**:
- Model 2 (24x80)
- Model 3 (32x80)
- Model 4 (43x80)
- Model 5 (27x132)
### 🖥️ Terminal Emulation & Protocol
- **RFC 2355 TN3270 & TN3270E Engine**: Robust state machine, Option Negotiation, Device-Type Query, and LU name specification powered by `lib3270j`.
- **Standard & Dynamic Terminal Models**:
- Model 2 (24 × 80)
- Model 3 (32 × 80)
- Model 4 (43 × 80)
- Model 5 (27 × 132)
- **Dynamic Screen Dimensions**: Configurable custom geometry (rows & columns up to 255 × 255).
- **EBCDIC Code Pages**: Full character translation across international code pages (037, 1047, 500, etc.).
### 📊 Graphics & Peripherals
- **GDDM & GOCA Graphics**: Native rendering of Programmed Symbols (PS) and GOCA vector graphics directly in the Compose canvas.
- **Light Pen Simulation**: Interactive touch support to simulate light pen selection and graphic cursor pick correlation.
- **IBM 3287 Printer Emulation**: Integrated printer session support for spool/report capture (via associated display LU or dedicated printer LU) with direct printing through Android PrintManager (PDF export / system print).
### 📁 IND$FILE File Transfer
- Full mobile UI supporting both **CUT** and **DFT (DDM)** high-speed structured field transfers.
- Compatible with **TSO**, **VM/CMS**, and **CICS** environments.
- ASCII/binary translation, CRLF conversions, and configurable record format (`recfm`), record length (`lrecl`), and allocation units.
### 🔍 Developer & Productivity Tools
- **Field Inspector**: Live interactive inspector displaying start/end addresses, length, protection, numeric status, MDT (Modified Data Tag), display intensity, and extended highlighting/color, with one-tap "Jump to Field".
- **Screen Search / Find**: On-screen text search with case sensitivity, forward/backward navigation, and visual match highlighting.
- **Screen Exporter**: Export the current screen buffer to **Plain Text**, formatted **HTML**, or **PNG image**, with one-tap clipboard copy and Android system share sheet integration.
- **Script & Macro Automation**: Built-in script runner supporting automated keystroke and wait sequences (e.g. `[enter]`, `[wait]`, `[tab]`) for login macros and repetitive tasks.
### ✍️ Entry Assist & Typing Modes
- **Document (DOC) Mode**: Configurable left/right margins, custom tab stops, and end-of-line bell warning.
- **Word Wrap**: Automatic word wrapping across lines during input.
- **APL Mode**: Support for APL extended character sets and symbols.
- **Field Handling**: Automatic field skip (Auto-Skip), Numeric field lock, and Insert Mode auto-reset on AID transmission.
### 🎨 Display, Themes & Ergonomics
- **Jetpack Compose UI**: Smooth, hardware-accelerated 3270 terminal screen rendering with touch-to-position cursor.
- **Color Themes**: Light and Dark application themes, 3270 extended color support, and 4-color legacy override mode.
- **Cursor Customization**: Block and Underline cursor styles with toggleable blinking.
- **Crosshair Ruler**: Toggleable horizontal and vertical ruler lines following the cursor for easy data tracking.
- **Block Selection**: Rectangular text selection and copy mode.
- **OIA Status Line**: Real-time Operator Information Area displaying connection status, keyboard lock, insert mode, cursor row/col, LU name, and inhibit reason codes.
### 🔒 Security, Networking & Storage
- **SSL / TLS Encryption**: Secure encrypted mainframe sessions with TLS certificate verification.
- **Interactive Untrusted Certificate Prompt**: Inspect SSL certificates, view full certificate chain details, and accept/reject unverified connections.
- **Proxy Support**: Connect via HTTP CONNECT, SOCKS4, SOCKS5, and Telnet proxies with optional user/password authentication.
- **Background Session Persistence**: Android foreground service (`specialUse`) keeps your terminal TCP connection alive with status notifications when multitasking or screen is off.
- **Host Profile Manager & INI Export**: Save multiple mainframe profiles, configure auto-connect on launch, and export/import configuration in INI format (interoperable with desktop `j3270`).
### ⌨️ Input & Keyboards
- **On-Screen Function Key Bar**: Two-row responsive touch bar providing PF1–PF24, PA1–PA3, Enter, Clear, Reset, Attn, SysReq, Erase EOF, Erase Input, Tab, BackTab, and D-pad cursor controls with haptic feedback.
- **Termux-Style Native Input**: Low-latency native input connection with permanent number row and lock protection against soft-keyboard resets.
- **Hardware Keyboard Support**: Complete physical keyboard integration with F1–F12 (mapped to PF1–PF12 / PF13–PF24 with Shift/Alt), Ctrl shortcuts, dead keys, and international character input.
---
@@ -56,6 +106,7 @@ adb install -r a3270.apk
### Prerequisites
- **Java Development Kit (JDK)**: JDK 17 or JDK 21
- **Android SDK**: API 34 platforms and build-tools (`ANDROID_HOME` or `ANDROID_SDK_ROOT` set)
- **Minimum Target Device**: Android 7.0+ (API 24+)
### 1. Clone Repositories Side-by-Side
For the easiest setup, clone both `j3270` and `a3270` into the same parent folder:
@@ -93,14 +144,6 @@ build/outputs/apk/debug/a3270-debug.apk
---
## 🤝 Contributing
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
I have contributed no code to this project, it was entirely written by LLMs with my guidance only.
---
## 📜 Acknowledgements
- [j3270](https://git.hugfreevikings.wtf/rudi/j3270) — Desktop emulator and `lib3270j` protocol engine
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="124 738 320 320"
width="320"
height="320"
xml:space="preserve"
id="svg_a3270_icon"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="124" y="738" width="320" height="320" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="144 814 280 169"
width="280"
height="169"
xml:space="preserve"
id="svg_lib3270j_cropped"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="144" y="814" width="280" height="169" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+5 -4
View File
@@ -4,15 +4,15 @@ plugins {
}
android {
namespace 'org.pubvm.a3270'
namespace 'haus.nightmare.a3270'
compileSdk 34
defaultConfig {
applicationId "org.pubvm.a3270"
applicationId "haus.nightmare.a3270"
minSdk 24
targetSdk 34
versionCode 4
versionName "0.1.3"
versionCode 9
versionName "1.2.4"
}
buildTypes {
@@ -71,5 +71,6 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
}
+20 -11
View File
@@ -9,9 +9,11 @@ echo "=== Building a3270 (Android APK) ==="
# Set up JAVA_HOME (prefer JDK 21 if available)
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
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.2-open"
elif [ -z "$JAVA_HOME" ]; then
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
if [ -d "$HOME/.sdkman/candidates/java/current" ]; then
export JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi
fi
@@ -19,6 +21,8 @@ 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
@@ -41,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 --no-daemon
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
+1
View File
@@ -1,3 +1,4 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
kotlin.compiler.execution.strategy=in-process
Vendored
+20
View File
@@ -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
+15 -1
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="9"
android:versionName="1.2.4">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -9,6 +11,8 @@
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="a3270"
android:supportsRtl="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
@@ -33,6 +37,16 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="3270 Terminal TCP socket connection persistence" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+146
View File
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="124 738 320 320"
width="320"
height="320"
xml:space="preserve"
id="svg_a3270_icon"
version="1.1">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<!-- Pure black background (#000000) for classic 3270 CRT terminal look -->
<rect x="124" y="738" width="320" height="320" fill="#000000" />
<g transform="matrix(1.3333333,0,0,-1.3333333,0,1122.6667)" id="g10">
<g transform="scale(0.1)" id="g12">
<g id="g14">
<!-- Green Terminal Screen Frame -->
<path
id="path28"
style="fill:none;stroke:#00ff00;stroke-width:3.75079012;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
id="path30"
style="fill:none;stroke:#0000ff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
id="path32"
style="fill:none;stroke:#ff0000;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
id="path34"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- 'l' -->
<path
id="path36"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<path
id="path38"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
id="path40"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<path
id="path42"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
id="path44"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<path
id="path46"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
id="path48"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<path
id="path50"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
id="path52"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<path
id="path54"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
id="path56"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<path
id="path58"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
id="path60"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<path
id="path62"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
id="path64"
style="fill:#ff00ff;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<path
id="path66"
style="fill:none;stroke:#ff00ff;stroke-width:1.21700001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smile -->
<path
id="path68"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye (cyan) -->
<path
id="path70"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye (cyan) -->
<path
id="path72"
style="fill:none;stroke:#00ffff;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
id="path74"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2892.03,1528.23 -29.21,-20.68" />
<path
id="path76"
style="fill:none;stroke:#ffff00;stroke-width:7.50158978;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
d="m 2862.82,1507.55 h 32.86" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
package org.pubvm.a3270.ft
package haus.nightmare.a3270.ft
import android.os.Handler
import android.os.Looper
import org.lib3270j.Telnet3270Client
import org.lib3270j.ft.FTConfig
import org.lib3270j.ft.FTConstants.FTState
import org.lib3270j.ft.FTCut
import org.lib3270j.ft.FTDft
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.FTConstants.FTState
import haus.nightmare.lib3270j.ft.FTCut
import haus.nightmare.lib3270j.ft.FTDft
import java.io.File
import java.io.IOException
import java.util.Timer
@@ -0,0 +1,49 @@
package haus.nightmare.a3270.graphics
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import haus.nightmare.lib3270j.graphics.PixelBuffer
/**
* Android bridge for translating lib3270j PixelBuffer into Android Bitmaps and rendering onto Canvas.
*/
object AndroidCanvasRenderer {
/**
* Converts a PixelBuffer into an Android Bitmap using ARGB_8888 configuration.
*/
fun toBitmap(buffer: PixelBuffer?): Bitmap? {
if (buffer == null) return null
val w = buffer.width
val h = buffer.height
if (w <= 0 || h <= 0) return null
val pixels = buffer.pixels ?: return null
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
}
/**
* Draws the given PixelBuffer onto an Android Canvas scaled to the destination rectangle.
*/
fun drawPixelBuffer(
canvas: Canvas,
buffer: PixelBuffer?,
dstRect: RectF,
paint: Paint? = null
) {
val bmp = toBitmap(buffer) ?: return
canvas.drawBitmap(bmp, null, dstRect, paint)
}
/**
* Copies the PixelBuffer contents into an existing mutable Bitmap.
*/
fun copyIntoBitmap(buffer: PixelBuffer, target: Bitmap) {
val pixels = buffer.pixels ?: return
val w = minOf(buffer.width, target.width)
val h = minOf(buffer.height, target.height)
if (w <= 0 || h <= 0) return
target.setPixels(pixels, 0, buffer.width, 0, 0, w, h)
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.service
package haus.nightmare.a3270.service
import android.app.Notification
import android.app.NotificationChannel
@@ -10,8 +10,8 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.pubvm.a3270.MainActivity
import org.pubvm.a3270.R
import haus.nightmare.a3270.MainActivity
import haus.nightmare.a3270.R
class TerminalService : Service() {
@@ -0,0 +1,555 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object AppSettings {
private const val PREFS_NAME = "a3270_settings"
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
private const val KEY_CURSOR_BLINK = "cursor_blink"
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private const val KEY_DEFAULT_CODE_PAGE = "default_code_page"
private const val KEY_DEFAULT_HOST_TYPE = "default_host_type"
// New settings aligned with j3270
private const val KEY_UI_THEME = "ui_theme"
private const val KEY_CROSSHAIR_RULER = "crosshair_ruler"
private const val KEY_CURSOR_STYLE = "cursor_style"
private const val KEY_BLOCK_SELECT_MODE = "block_select_mode"
private const val KEY_BLOCK_PASTE = "block_paste"
private const val KEY_STARTUP_BEHAVIOR = "startup_behavior"
private const val KEY_DYNAMIC_ROWS = "dynamic_rows"
private const val KEY_DYNAMIC_COLS = "dynamic_cols"
// Entry Assist & Modes aligned with j3270
private const val KEY_ENTRY_ASSIST_DOC_MODE = "entry_assist_doc_mode"
private const val KEY_ENTRY_ASSIST_WORD_WRAP = "entry_assist_word_wrap"
private const val KEY_ENTRY_ASSIST_START_COL = "entry_assist_start_col"
private const val KEY_ENTRY_ASSIST_END_COL = "entry_assist_end_col"
private const val KEY_ENTRY_ASSIST_BELL = "entry_assist_bell"
private const val KEY_ENTRY_ASSIST_BELL_COL = "entry_assist_bell_col"
private const val KEY_ENTRY_ASSIST_TAB_STOPS = "entry_assist_tab_stops"
private const val KEY_INSERT_OFF_ON_AID = "insert_off_on_aid"
private const val KEY_FOUR_COLOR_OVERRIDE = "four_color_override"
private const val KEY_NUMERIC_FIELD_LOCK = "numeric_field_lock"
private const val KEY_AUTO_SKIP_ENABLED = "auto_skip_enabled"
// Hotspots aligned with j3270 v1.2.2
private const val KEY_HOTSPOTS_ENABLED = "hotspots_enabled"
private const val KEY_HOTSPOTS_URL_ENABLED = "hotspots_url_enabled"
private const val KEY_HOTSPOTS_PF_ENABLED = "hotspots_pf_enabled"
private const val KEY_HOTSPOTS_MENU_CHOICES_ENABLED = "hotspots_menu_choices_enabled"
// Selection & Trim aligned with j3270 v1.2.2
private const val KEY_TRIM_RECT_SIZING_HANDLES = "trim_rect_sizing_handles"
private const val KEY_TRIM_RECT_REMAIN_AFTER_EDIT = "trim_rect_remain_after_edit"
private const val KEY_PASTE_WORD_BREAK = "paste_word_break"
enum class StartupBehavior {
SHOW_CONNECT,
DO_NOTHING,
AUTO_CONNECT
}
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isMaskHiddenInputEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
}
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
}
fun isCursorBlinkEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
}
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
}
fun isHapticFeedbackEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
}
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
}
fun isVerifyCertsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
}
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
}
fun getDefaultGraphicsMode(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
}
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
fun getDefaultCodePage(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_CODE_PAGE, "037") ?: "037"
}
fun setDefaultCodePage(context: Context, codePage: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_CODE_PAGE, codePage).apply()
}
fun getDefaultHostType(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_HOST_TYPE, "TSO") ?: "TSO"
}
fun setDefaultHostType(context: Context, hostType: String) {
val normalized = if (hostType.isNotBlank()) hostType.trim().uppercase(Locale.US) else "TSO"
getPrefs(context).edit().putString(KEY_DEFAULT_HOST_TYPE, normalized).apply()
}
// ========== UI Theme ==========
fun getUiTheme(context: Context): String {
return getPrefs(context).getString(KEY_UI_THEME, "DARK") ?: "DARK"
}
fun setUiTheme(context: Context, theme: String) {
getPrefs(context).edit().putString(KEY_UI_THEME, if (theme.equals("LIGHT", ignoreCase = true)) "LIGHT" else "DARK").apply()
}
// ========== Crosshair Ruler ==========
fun isCrosshairRulerEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CROSSHAIR_RULER, false)
}
fun setCrosshairRulerEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CROSSHAIR_RULER, enabled).apply()
}
// ========== Cursor Style ==========
fun getCursorStyle(context: Context): String {
return getPrefs(context).getString(KEY_CURSOR_STYLE, "BLOCK") ?: "BLOCK"
}
fun setCursorStyle(context: Context, style: String) {
val s = if (style.equals("UNDERLINE", ignoreCase = true)) "UNDERLINE" else "BLOCK"
getPrefs(context).edit().putString(KEY_CURSOR_STYLE, s).apply()
}
// ========== Block Select Mode ==========
fun isBlockSelectModeEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_BLOCK_SELECT_MODE, false)
}
fun setBlockSelectModeEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_BLOCK_SELECT_MODE, enabled).apply()
}
// ========== Block Paste Mode ==========
fun isBlockPasteEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_BLOCK_PASTE, true)
}
fun setBlockPasteEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_BLOCK_PASTE, enabled).apply()
}
// ========== Startup Behavior ==========
fun getStartupBehavior(context: Context): StartupBehavior {
val s = getPrefs(context).getString(KEY_STARTUP_BEHAVIOR, StartupBehavior.SHOW_CONNECT.name)
return try {
StartupBehavior.valueOf(s ?: StartupBehavior.SHOW_CONNECT.name)
} catch (_: IllegalArgumentException) {
StartupBehavior.SHOW_CONNECT
}
}
fun setStartupBehavior(context: Context, behavior: StartupBehavior) {
getPrefs(context).edit().putString(KEY_STARTUP_BEHAVIOR, behavior.name).apply()
}
// ========== Dynamic Screen Dimensions ==========
fun getDynamicRows(context: Context): Int {
return getPrefs(context).getInt(KEY_DYNAMIC_ROWS, 62).coerceIn(24, 255)
}
fun setDynamicRows(context: Context, rows: Int) {
getPrefs(context).edit().putInt(KEY_DYNAMIC_ROWS, rows.coerceIn(24, 255)).apply()
}
fun getDynamicCols(context: Context): Int {
return getPrefs(context).getInt(KEY_DYNAMIC_COLS, 160).coerceIn(80, 255)
}
fun setDynamicCols(context: Context, cols: Int) {
getPrefs(context).edit().putInt(KEY_DYNAMIC_COLS, cols.coerceIn(80, 255)).apply()
}
// ========== Entry Assist (Document Mode) ==========
fun getEntryAssistDocMode(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_DOC_MODE, false)
}
fun setEntryAssistDocMode(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_DOC_MODE, enabled).apply()
}
fun getEntryAssistWordWrap(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_WORD_WRAP, false)
}
fun setEntryAssistWordWrap(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_WORD_WRAP, enabled).apply()
}
fun getEntryAssistStartCol(context: Context): Int {
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_START_COL, 1).coerceIn(1, 80)
}
fun setEntryAssistStartCol(context: Context, col: Int) {
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_START_COL, col.coerceIn(1, 80)).apply()
}
fun getEntryAssistEndCol(context: Context): Int {
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_END_COL, 80).coerceIn(1, 80)
}
fun setEntryAssistEndCol(context: Context, col: Int) {
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_END_COL, col.coerceIn(1, 80)).apply()
}
fun getEntryAssistBell(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_ENTRY_ASSIST_BELL, false)
}
fun setEntryAssistBell(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_ENTRY_ASSIST_BELL, enabled).apply()
}
fun getEntryAssistBellCol(context: Context): Int {
return getPrefs(context).getInt(KEY_ENTRY_ASSIST_BELL_COL, 75).coerceIn(1, 80)
}
fun setEntryAssistBellCol(context: Context, col: Int) {
getPrefs(context).edit().putInt(KEY_ENTRY_ASSIST_BELL_COL, col.coerceIn(1, 80)).apply()
}
fun getEntryAssistTabStops(context: Context): String {
return getPrefs(context).getString(KEY_ENTRY_ASSIST_TAB_STOPS, "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75")
?: "5,10,15,20,25,30,35,40,45,50,55,60,65,70,75"
}
fun setEntryAssistTabStops(context: Context, stops: String) {
getPrefs(context).edit().putString(KEY_ENTRY_ASSIST_TAB_STOPS, stops).apply()
}
fun getEntryAssistTabStopsArray(context: Context): IntArray {
val s = getEntryAssistTabStops(context)
if (s.isBlank()) return intArrayOf()
val parts = s.split(",")
val list = mutableListOf<Int>()
for (p in parts) {
val v = p.trim().toIntOrNull()
if (v != null && v > 0) {
list.add(v - 1)
}
}
return list.toIntArray()
}
// ========== Terminal Operational Modes ==========
fun getInsertOffOnAid(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_INSERT_OFF_ON_AID, true)
}
fun setInsertOffOnAid(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_INSERT_OFF_ON_AID, enabled).apply()
}
fun getFourColorOverride(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_FOUR_COLOR_OVERRIDE, false)
}
fun setFourColorOverride(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_FOUR_COLOR_OVERRIDE, enabled).apply()
}
fun getNumericFieldLock(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_NUMERIC_FIELD_LOCK, true)
}
fun setNumericFieldLock(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_NUMERIC_FIELD_LOCK, enabled).apply()
}
fun getAutoSkipEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_AUTO_SKIP_ENABLED, true)
}
fun setAutoSkipEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_AUTO_SKIP_ENABLED, enabled).apply()
}
// ========== Hotspots (Default DISABLED for touch screens) ==========
fun isHotspotsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_ENABLED, false)
}
fun setHotspotsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_ENABLED, enabled).apply()
}
fun isHotspotsUrlEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_URL_ENABLED, true)
}
fun setHotspotsUrlEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_URL_ENABLED, enabled).apply()
}
fun isHotspotsPfEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_PF_ENABLED, true)
}
fun setHotspotsPfEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_PF_ENABLED, enabled).apply()
}
fun isHotspotsMenuChoicesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, true)
}
fun setHotspotsMenuChoicesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HOTSPOTS_MENU_CHOICES_ENABLED, enabled).apply()
}
// ========== Selection Handles & Trim ==========
fun isTrimRectSizingHandlesEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_SIZING_HANDLES, false)
}
fun setTrimRectSizingHandlesEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_SIZING_HANDLES, enabled).apply()
}
fun isTrimRectRemainAfterEditEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, false)
}
fun setTrimRectRemainAfterEditEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_TRIM_RECT_REMAIN_AFTER_EDIT, enabled).apply()
}
fun isPasteWordBreakEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_PASTE_WORD_BREAK, true)
}
fun setPasteWordBreakEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_PASTE_WORD_BREAK, enabled).apply()
}
// ========== INI Config Export / Import ==========
fun exportToIni(context: Context, writer: Writer) {
val p = PrintWriter(writer)
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
p.println("; a3270 / j3270 Configuration File")
p.println("; Exported on $timeStamp")
p.println()
p.println("[appearance]")
p.println("javaUiTheme = ${getUiTheme(context)}")
p.println("crosshairRuler = ${isCrosshairRulerEnabled(context)}")
p.println("cursorStyle = ${getCursorStyle(context)}")
p.println()
p.println("[behavior]")
p.println("startupBehavior = ${getStartupBehavior(context).name}")
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null) {
p.println("autoConnectHost = ${autoHost.host}")
p.println("autoConnectPort = ${autoHost.port}")
p.println("autoConnectTls = ${autoHost.useTls}")
p.println("autoConnectVerifyCert = ${autoHost.tlsVerifyCert}")
p.println("autoConnectTn3270e = ${autoHost.tn3270e}")
}
p.println("codePage = ${getDefaultCodePage(context)}")
p.println("hostType = ${getDefaultHostType(context)}")
p.println("dynamicRows = ${getDynamicRows(context)}")
p.println("dynamicCols = ${getDynamicCols(context)}")
p.println("blockSelectMode = ${isBlockSelectModeEnabled(context)}")
p.println("blockPaste = ${isBlockPasteEnabled(context)}")
p.println("trimRectSizingHandles = ${isTrimRectSizingHandlesEnabled(context)}")
p.println("trimRectRemainAfterEdit = ${isTrimRectRemainAfterEditEnabled(context)}")
p.println("pasteWordBreak = ${isPasteWordBreakEnabled(context)}")
p.println()
p.println("[hotspots]")
p.println("hotspotsEnabled = ${isHotspotsEnabled(context)}")
p.println("hotspotsUrlEnabled = ${isHotspotsUrlEnabled(context)}")
p.println("hotspotsPfEnabled = ${isHotspotsPfEnabled(context)}")
p.println("hotspotsMenuChoicesEnabled = ${isHotspotsMenuChoicesEnabled(context)}")
p.println()
p.println("[entryassist]")
p.println("docMode = ${getEntryAssistDocMode(context)}")
p.println("wordWrap = ${getEntryAssistWordWrap(context)}")
p.println("startCol = ${getEntryAssistStartCol(context)}")
p.println("endCol = ${getEntryAssistEndCol(context)}")
p.println("bell = ${getEntryAssistBell(context)}")
p.println("bellCol = ${getEntryAssistBellCol(context)}")
p.println("tabStops = ${getEntryAssistTabStops(context)}")
p.println()
p.println("[modes]")
p.println("insertOffOnAid = ${getInsertOffOnAid(context)}")
p.println("fourColorOverride = ${getFourColorOverride(context)}")
p.println("numericFieldLock = ${getNumericFieldLock(context)}")
p.println("autoSkip = ${getAutoSkipEnabled(context)}")
p.println()
p.println("[graphics]")
p.println("graphicsMode = ${getDefaultGraphicsMode(context)}")
p.println()
p.flush()
}
fun shareSettingsIni(context: Context) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_settings.ini")
file.bufferedWriter().use { writer ->
exportToIni(context, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Terminal Settings")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Terminal Settings"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
fun parseIni(reader: Reader, callback: (section: String, key: String, value: String) -> Unit) {
val bufferedReader = BufferedReader(reader)
var currentSection = ""
bufferedReader.forEachLine { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
if (line.startsWith("[") && line.endsWith("]")) {
currentSection = line.substring(1, line.length - 1).lowercase(Locale.US).trim()
} else {
val eq = line.indexOf('=')
if (eq >= 0) {
val key = line.substring(0, eq).trim()
val value = line.substring(eq + 1).trim()
callback(currentSection, key, value)
}
}
}
}
}
fun loadFromIni(context: Context, reader: Reader) {
parseIni(reader) { section, key, value ->
applyConfigEntry(context, section, key, value)
}
}
private fun applyConfigEntry(context: Context, section: String, key: String, value: String) {
when (section) {
"appearance" -> {
when (key.lowercase(Locale.US)) {
"javauitheme", "theme", "uitheme" -> setUiTheme(context, value)
"crosshairruler", "ruler" -> setCrosshairRulerEnabled(context, value.toBoolean())
"cursorstyle" -> setCursorStyle(context, value)
}
}
"behavior", "connection" -> {
when (key.lowercase(Locale.US)) {
"startupbehavior" -> {
try {
setStartupBehavior(context, StartupBehavior.valueOf(value.uppercase(Locale.US)))
} catch (_: Exception) {}
}
"codepage", "charset" -> setDefaultCodePage(context, value)
"hosttype", "host_type" -> setDefaultHostType(context, value)
"dynamicrows", "dynamic_rows" -> value.toIntOrNull()?.let { setDynamicRows(context, it) }
"dynamiccols", "dynamic_cols" -> value.toIntOrNull()?.let { setDynamicCols(context, it) }
"blockselectmode" -> setBlockSelectModeEnabled(context, value.toBoolean())
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
"hotspotsenabled" -> setHotspotsEnabled(context, value.toBoolean())
}
}
"clipboard" -> {
when (key.lowercase(Locale.US)) {
"blockpaste", "blockpastemode" -> setBlockPasteEnabled(context, value.toBoolean())
"trimrectsizinghandles", "sizinghandles" -> setTrimRectSizingHandlesEnabled(context, value.toBoolean())
"trimrectremainafteredit", "remainafteredit" -> setTrimRectRemainAfterEditEnabled(context, value.toBoolean())
"pastewordbreak", "wordbreak" -> setPasteWordBreakEnabled(context, value.toBoolean())
}
}
"hotspots" -> {
when (key.lowercase(Locale.US)) {
"hotspotsenabled", "enabled" -> setHotspotsEnabled(context, value.toBoolean())
"hotspotsurlenabled", "urlenabled", "url" -> setHotspotsUrlEnabled(context, value.toBoolean())
"hotspotspfenabled", "pfenabled", "pf" -> setHotspotsPfEnabled(context, value.toBoolean())
"hotspotsmenuchoicesenabled", "menuchoicesenabled", "menuchoices", "menu" -> setHotspotsMenuChoicesEnabled(context, value.toBoolean())
}
}
"entryassist" -> {
when (key.lowercase(Locale.US)) {
"docmode", "entryassistdocmode" -> setEntryAssistDocMode(context, value.toBoolean())
"wordwrap", "entryassistwordwrap" -> setEntryAssistWordWrap(context, value.toBoolean())
"startcol", "entryassiststartcol" -> value.toIntOrNull()?.let { setEntryAssistStartCol(context, it) }
"endcol", "entryassistendcol" -> value.toIntOrNull()?.let { setEntryAssistEndCol(context, it) }
"bell", "entryassistbell" -> setEntryAssistBell(context, value.toBoolean())
"bellcol", "entryassistbellcol" -> value.toIntOrNull()?.let { setEntryAssistBellCol(context, it) }
"tabstops", "entryassisttabstops" -> setEntryAssistTabStops(context, value)
}
}
"modes" -> {
when (key.lowercase(Locale.US)) {
"insertoffonaid" -> setInsertOffOnAid(context, value.toBoolean())
"fourcoloroverride" -> setFourColorOverride(context, value.toBoolean())
"numericfieldlock" -> setNumericFieldLock(context, value.toBoolean())
"autoskip", "autoskipenabled" -> setAutoSkipEnabled(context, value.toBoolean())
}
}
"graphics" -> {
if (key.equals("graphicsmode", ignoreCase = true) || key.equals("mode", ignoreCase = true)) {
setDefaultGraphicsMode(context, value.uppercase(Locale.US))
}
}
}
}
}
@@ -0,0 +1,404 @@
package haus.nightmare.a3270.storage
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.File
import java.io.PrintWriter
import java.io.Reader
import java.io.Writer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
data class SavedHost(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int = 23,
val model: Int = 2,
val dynamicRows: Int = 62,
val dynamicCols: Int = 160,
val luName: String = "",
val autoConnect: Boolean = false,
val hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH",
val codePage: String = "037",
val proxyType: String = "NONE",
val proxyHost: String = "",
val proxyPort: Int = 0,
val proxyUsername: String = "",
val proxyPassword: String = ""
) {
fun toConnectionConfig(): ConnectionConfig {
val tm = when (model) {
3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
0 -> TerminalModel.IBM_DYNAMIC
else -> TerminalModel.IBM_3279_2
}
val config = ConnectionConfig(host, port, tm, useTls)
config.isSavedConnection = true
config.savedProfileId = this.id
config.savedProfileName = this.name
config.hostType = if (this.hostType.isNotBlank()) this.hostType else "TSO"
if (model == 0 || tm.isDynamic) {
config.setDynamicDimensions(dynamicRows, dynamicCols)
}
if (luName.isNotBlank()) {
config.luName = luName.trim()
}
config.isTlsVerifyCert = tlsVerifyCert
config.isTn3270eEnabled = tn3270e
config.graphicsMode = GraphicsMode.fromString(graphicsMode)
if (codePage.isNotBlank()) {
config.codePage = codePage
}
if (!proxyType.equals("NONE", ignoreCase = true) && proxyHost.isNotBlank()) {
try {
val pt = ConnectionConfig.ProxyType.valueOf(proxyType.uppercase())
config.setProxy(
pt,
proxyHost,
if (proxyPort > 0) proxyPort else (if (pt == ConnectionConfig.ProxyType.HTTP) 8080 else 1080),
proxyUsername.ifBlank { null },
proxyPassword.ifBlank { null }
)
} catch (_: Exception) {}
}
return config
}
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("name", name)
put("host", host)
put("port", port)
put("model", model)
put("dynamicRows", dynamicRows)
put("dynamicCols", dynamicCols)
put("luName", luName)
put("autoConnect", autoConnect)
put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
put("codePage", codePage)
put("proxyType", proxyType)
put("proxyHost", proxyHost)
put("proxyPort", proxyPort)
put("proxyUsername", proxyUsername)
put("proxyPassword", proxyPassword)
}
}
companion object {
fun fromJson(json: JSONObject): SavedHost {
return SavedHost(
id = json.optString("id", UUID.randomUUID().toString()),
name = json.optString("name", "Mainframe"),
host = json.optString("host", "127.0.0.1"),
port = json.optInt("port", 23),
model = json.optInt("model", 2),
dynamicRows = json.optInt("dynamicRows", 62),
dynamicCols = json.optInt("dynamicCols", 160),
luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH"),
codePage = json.optString("codePage", "037"),
proxyType = json.optString("proxyType", "NONE"),
proxyHost = json.optString("proxyHost", ""),
proxyPort = json.optInt("proxyPort", 0),
proxyUsername = json.optString("proxyUsername", ""),
proxyPassword = json.optString("proxyPassword", "")
)
}
}
}
object HostStorage {
private const val PREFS_NAME = "a3270_host_prefs"
private const val KEY_SAVED_HOSTS = "saved_hosts"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getSavedHosts(context: Context): List<SavedHost> {
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
val list = mutableListOf<SavedHost>()
try {
val jsonArray = JSONArray(jsonStr)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val saved = SavedHost.fromJson(obj)
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
saved.copy(graphicsMode = "BOTH")
} else {
saved
}
list.add(effective)
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun saveHost(context: Context, host: SavedHost) {
val currentHosts = getSavedHosts(context).toMutableList()
val index = currentHosts.indexOfFirst { it.id == host.id }
// If this host is set to autoConnect, unset autoConnect on all others
val updatedHost = if (host.autoConnect) {
currentHosts.indices.forEach { i ->
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
}
host
} else {
host
}
if (index >= 0) {
currentHosts[index] = updatedHost
} else {
currentHosts.add(updatedHost)
}
saveAll(context, currentHosts)
}
fun deleteHost(context: Context, hostId: String) {
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
saveAll(context, currentHosts)
}
val AVAILABLE_CODE_PAGES = listOf(
"037" to "037 - US / Canada / Brazil",
"1047" to "1047 - IBM Open Systems / z/OS Unix",
"500" to "500 - International Latin-1",
"273" to "273 - Germany / Austria",
"277" to "277 - Denmark / Norway",
"278" to "278 - Sweden / Finland",
"280" to "280 - Italy",
"284" to "284 - Spain / Latin America",
"285" to "285 - United Kingdom",
"297" to "297 - France",
"420" to "420 - Arabic Bilingual",
"424" to "424 - Hebrew (with Latin)",
"803" to "803 - Hebrew Character Set",
"838" to "838 - Thai Extended",
"1160" to "1160 - Thai Euro",
"870" to "870 - Eastern Europe / Latin-2",
"871" to "871 - Iceland",
"875" to "875 - Greece (Greek)",
"880" to "880 - Cyrillic (Russian)",
"1025" to "1025 - Cyrillic Multilingual",
"1123" to "1123 - Cyrillic Ukraine",
"1154" to "1154 - Cyrillic Euro",
"905" to "905 - Turkey (Latin-5)",
"1026" to "1026 - Turkey (Turkish)",
"1155" to "1155 - Turkey Euro",
"1140" to "1140 - US / Canada (Euro €)",
"1141" to "1141 - Germany / Austria (Euro €)",
"1142" to "1142 - Denmark / Norway (Euro €)",
"1143" to "1143 - Sweden / Finland (Euro €)",
"1144" to "1144 - Italy (Euro €)",
"1145" to "1145 - Spain / Latin America (Euro €)",
"1146" to "1146 - United Kingdom (Euro €)",
"1147" to "1147 - France (Euro €)",
"1148" to "1148 - International (Euro €)",
"1149" to "1149 - Iceland (Euro €)",
"930" to "930 - Japanese Katakana Mixed DBCS",
"939" to "939 - Japanese Latin Mixed DBCS",
"290" to "290 - Japanese Katakana SBCS",
"1027" to "1027 - Japanese Latin Extended SBCS",
"1390" to "1390 - Japanese Katakana DBCS Euro",
"1399" to "1399 - Japanese Latin DBCS Euro",
"935" to "935 - Simplified Chinese Mixed DBCS",
"937" to "937 - Traditional Chinese Mixed DBCS",
"1388" to "1388 - Simplified Chinese Extended DBCS",
"1371" to "1371 - Traditional Chinese Extended DBCS",
"933" to "933 - Korean Mixed DBCS"
)
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
private fun saveAll(context: Context, hosts: List<SavedHost>) {
val array = JSONArray()
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
// ================= INI Export / Import =================
fun exportToIni(hosts: List<SavedHost>, writer: Writer) {
val p = PrintWriter(writer)
val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
p.println("; a3270 Saved Connections Configuration File")
p.println("; Exported on $timeStamp")
p.println("; Connections count: ${hosts.size}")
p.println()
for (h in hosts) {
val section = h.name.trim().ifBlank { h.host.trim().ifBlank { "Connection" } }
p.println("[$section]")
if (h.id.isNotBlank()) {
p.println("id = ${h.id}")
}
p.println("name = ${h.name}")
p.println("host = ${h.host}")
p.println("port = ${h.port}")
p.println("model = ${h.model}")
p.println("dynamicRows = ${h.dynamicRows}")
p.println("dynamicCols = ${h.dynamicCols}")
p.println("luName = ${h.luName}")
p.println("autoConnect = ${h.autoConnect}")
p.println("hostType = ${h.hostType.ifBlank { "TSO" }}")
p.println("useTls = ${h.useTls}")
p.println("tlsVerifyCert = ${h.tlsVerifyCert}")
p.println("tn3270e = ${h.tn3270e}")
p.println("graphicsMode = ${h.graphicsMode.ifBlank { "BOTH" }}")
p.println("codePage = ${h.codePage.ifBlank { "037" }}")
p.println("proxyType = ${h.proxyType.ifBlank { "NONE" }}")
p.println("proxyHost = ${h.proxyHost}")
p.println("proxyPort = ${h.proxyPort}")
p.println("proxyUsername = ${h.proxyUsername}")
p.println("proxyPassword = ${h.proxyPassword}")
p.println()
}
p.flush()
}
fun loadFromIni(reader: Reader): List<SavedHost> {
val bufferedReader = BufferedReader(reader)
val hosts = mutableListOf<SavedHost>()
var current: SavedHost? = null
bufferedReader.forEachLine { rawLine ->
val line = rawLine.trim()
if (line.isNotEmpty() && !line.startsWith(";") && !line.startsWith("#")) {
if (line.startsWith("[") && line.endsWith("]")) {
current?.let { hosts.add(it) }
var section = line.substring(1, line.length - 1).trim()
if (section.startsWith("connection:", ignoreCase = true)) {
section = section.substring("connection:".length).trim()
} else if (section.startsWith("host:", ignoreCase = true)) {
section = section.substring("host:".length).trim()
}
current = SavedHost(id = UUID.randomUUID().toString(), name = section, host = "")
} else {
val eq = line.indexOf('=')
if (eq >= 0) {
val key = line.substring(0, eq).trim().lowercase(Locale.US)
val value = line.substring(eq + 1).trim()
if (current == null) {
current = SavedHost(id = UUID.randomUUID().toString(), name = "Default", host = "")
}
current = applyIniProperty(current!!, key, value)
}
}
}
}
current?.let { hosts.add(it) }
return hosts
}
private fun applyIniProperty(h: SavedHost, key: String, value: String): SavedHost {
return when (key) {
"id" -> if (value.isNotBlank()) h.copy(id = value) else h
"name", "profilename", "profile" -> if (value.isNotBlank()) h.copy(name = value) else h
"host", "hostname", "address" -> h.copy(host = value)
"port" -> h.copy(port = value.toIntOrNull() ?: 23)
"model", "terminalmodel" -> h.copy(model = value.toIntOrNull() ?: 2)
"dynamicrows", "dynamic_rows", "rows" -> h.copy(dynamicRows = value.toIntOrNull() ?: 62)
"dynamiccols", "dynamic_cols", "cols" -> h.copy(dynamicCols = value.toIntOrNull() ?: 160)
"luname", "lu_name", "lu" -> h.copy(luName = value)
"autoconnect", "auto_connect" -> h.copy(autoConnect = value.toBoolean())
"hosttype", "host_type", "type" -> h.copy(hostType = value.uppercase(Locale.US))
"usetls", "use_tls", "tls", "ssl" -> h.copy(useTls = value.toBoolean())
"tlsverifycert", "verifycert", "tls_verify_cert" -> h.copy(tlsVerifyCert = value.toBoolean())
"tn3270e", "enabletn3270e" -> h.copy(tn3270e = value.toBoolean())
"graphicsmode", "graphics_mode", "graphics" -> h.copy(graphicsMode = value.uppercase(Locale.US))
"codepage", "code_page", "charset" -> h.copy(codePage = value)
"proxytype", "proxy_type" -> h.copy(proxyType = value.uppercase(Locale.US))
"proxyhost", "proxy_host" -> h.copy(proxyHost = value)
"proxyport", "proxy_port" -> h.copy(proxyPort = value.toIntOrNull() ?: 0)
"proxyusername", "proxy_username", "proxyuser" -> h.copy(proxyUsername = value)
"proxypassword", "proxy_password", "proxypass" -> h.copy(proxyPassword = value)
else -> h
}
}
fun importFromIni(context: Context, reader: Reader): List<SavedHost> {
val imported = loadFromIni(reader)
if (imported.isEmpty()) return getSavedHosts(context)
val current = getSavedHosts(context).toMutableList()
for (host in imported) {
val index = current.indexOfFirst {
(it.id.isNotBlank() && it.id == host.id) ||
(it.name.isNotBlank() && it.name.equals(host.name, ignoreCase = true))
}
if (host.autoConnect) {
current.indices.forEach { i ->
current[i] = current[i].copy(autoConnect = false)
}
}
if (index >= 0) {
val existingId = current[index].id
val toSet = if (host.id.isBlank()) host.copy(id = existingId) else host
current[index] = toSet
} else {
current.add(host)
}
}
saveAll(context, current)
return current
}
fun shareConnectionsIni(context: Context) {
try {
val hosts = getSavedHosts(context)
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(cacheDir, "a3270_connections.ini")
file.bufferedWriter().use { writer ->
exportToIni(hosts, writer)
}
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "a3270 Saved Connections")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Saved Connections"))
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Export failed: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,109 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
enum class ConnectChoice {
NEW_WINDOW,
ABORT_CURRENT,
CANCEL
}
@Composable
fun ConnectChoiceDialog(
currentTarget: String,
onChoice: (ConnectChoice) -> Unit,
onDismiss: () -> Unit
) {
val targetText = currentTarget.ifBlank { "the host" }
Dialog(
onDismissRequest = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
},
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color(0xFF1E1E1E),
tonalElevation = 6.dp,
modifier = Modifier
.fillMaxWidth(0.95f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Connection Already Active",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = Color.White
)
Text(
text = "a3270 is currently connected to $targetText.",
fontSize = 13.sp,
color = Color.LightGray
)
Text(
text = "Would you like to open the connection in a new window or abort the current connection?",
fontSize = 13.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = {
onChoice(ConnectChoice.CANCEL)
onDismiss()
}
) {
Text("Cancel", color = Color.Gray)
}
Button(
onClick = {
onChoice(ConnectChoice.ABORT_CURRENT)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Abort Current Connection", fontSize = 11.sp)
}
Button(
onClick = {
onChoice(ConnectChoice.NEW_WINDOW)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("New Window", fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -17,13 +18,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.storage.SavedHost
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
@Composable
fun ConnectDialog(
@@ -32,29 +42,50 @@ fun ConnectDialog(
host: String,
port: Int,
model: Int,
dynamicRows: Int,
dynamicCols: Int,
luName: String,
hostType: String,
useTls: Boolean,
tlsVerifyCert: Boolean,
tn3270e: Boolean,
graphicsMode: String
graphicsMode: String,
codePage: String,
proxyType: String,
proxyHost: String,
proxyPort: Int,
proxyUser: String,
proxyPass: String,
savedConnection: Boolean,
savedProfileId: String?,
savedProfileName: String?
) -> Unit
) {
val context = LocalContext.current
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
var selectedHostId by remember { mutableStateOf<String?>(null) }
var showImportConnectionsDialog by remember { mutableStateOf(false) }
var profileName by remember { mutableStateOf("") }
var host by remember { mutableStateOf("127.0.0.1") }
var portStr by remember { mutableStateOf("23") }
var host by remember { mutableStateOf("") }
var portStr by remember { mutableStateOf("") }
var modelNum by remember { mutableIntStateOf(2) }
var dynamicRowsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()) }
var dynamicColsStr by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()) }
var luName by remember { mutableStateOf("") }
var autoConnect by remember { mutableStateOf(false) }
var hostType by remember { mutableStateOf("TSO") }
var hostType by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)) }
var useTls by remember { mutableStateOf(false) }
var tlsVerifyCert by remember { mutableStateOf(true) }
var tn3270e by remember { mutableStateOf(true) }
var graphicsMode by remember { mutableStateOf("BOTH") }
var codePage by remember { mutableStateOf(haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)) }
var proxyType by remember { mutableStateOf("NONE") }
var proxyHost by remember { mutableStateOf("") }
var proxyPortStr by remember { mutableStateOf("") }
var proxyUsername by remember { mutableStateOf("") }
var proxyPassword by remember { mutableStateOf("") }
var showProxySettings by remember { mutableStateOf(false) }
fun loadProfile(saved: SavedHost) {
selectedHostId = saved.id
@@ -62,6 +93,8 @@ fun ConnectDialog(
host = saved.host
portStr = saved.port.toString()
modelNum = saved.model
dynamicRowsStr = saved.dynamicRows.toString()
dynamicColsStr = saved.dynamicCols.toString()
luName = saved.luName
autoConnect = saved.autoConnect
hostType = saved.hostType
@@ -69,40 +102,70 @@ fun ConnectDialog(
tlsVerifyCert = saved.tlsVerifyCert
tn3270e = saved.tn3270e
graphicsMode = saved.graphicsMode
codePage = saved.codePage
proxyType = saved.proxyType
proxyHost = saved.proxyHost
proxyPortStr = if (saved.proxyPort > 0) saved.proxyPort.toString() else ""
proxyUsername = saved.proxyUsername
proxyPassword = saved.proxyPassword
showProxySettings = saved.proxyType != "NONE"
}
fun clearFields() {
selectedHostId = null
profileName = ""
host = "127.0.0.1"
portStr = "23"
host = ""
portStr = ""
modelNum = 2
dynamicRowsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicRows(context).toString()
dynamicColsStr = haus.nightmare.a3270.storage.AppSettings.getDynamicCols(context).toString()
luName = ""
autoConnect = false
hostType = "TSO"
hostType = haus.nightmare.a3270.storage.AppSettings.getDefaultHostType(context)
useTls = false
tlsVerifyCert = true
tn3270e = true
graphicsMode = "BOTH"
codePage = haus.nightmare.a3270.storage.AppSettings.getDefaultCodePage(context)
proxyType = "NONE"
proxyHost = ""
proxyPortStr = ""
proxyUsername = ""
proxyPassword = ""
showProxySettings = false
}
fun saveCurrentProfile(): SavedHost? {
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
if (host.isBlank()) return null
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val trimmedHost = host.trim()
if (trimmedHost.isBlank()) {
android.widget.Toast.makeText(context, "Please enter a Host / IP Address", android.widget.Toast.LENGTH_SHORT).show()
return null
}
val nameToSave = profileName.trim().ifBlank { "$trimmedHost:$port" }
val dynRows = dynamicRowsStr.trim().toIntOrNull()?.coerceIn(24, 255) ?: 62
val dynCols = dynamicColsStr.trim().toIntOrNull()?.coerceIn(80, 255) ?: 160
val hostToSave = SavedHost(
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
name = nameToSave,
host = host.trim(),
host = trimmedHost,
port = port,
model = modelNum,
dynamicRows = dynRows,
dynamicCols = dynCols,
luName = luName.trim(),
autoConnect = autoConnect,
hostType = hostType,
useTls = useTls,
tlsVerifyCert = tlsVerifyCert,
tn3270e = tn3270e,
graphicsMode = graphicsMode
graphicsMode = graphicsMode,
codePage = codePage,
proxyType = proxyType,
proxyHost = proxyHost.trim(),
proxyPort = proxyPortStr.trim().toIntOrNull() ?: 0,
proxyUsername = proxyUsername.trim(),
proxyPassword = proxyPassword.trim()
)
HostStorage.saveHost(context, hostToSave)
savedHosts = HostStorage.getSavedHosts(context)
@@ -172,13 +235,36 @@ fun ConnectDialog(
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Saved Profiles List Section
if (savedHosts.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Saved Host Profiles:",
if (savedHosts.isNotEmpty()) "Saved Host Profiles (${savedHosts.size}):" else "Saved Host Profiles:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.Gray
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (savedHosts.isNotEmpty()) {
TextButton(
onClick = { HostStorage.shareConnectionsIni(context) },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Export (Share)", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
TextButton(
onClick = { showImportConnectionsDialog = true },
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
) {
Text("Import", fontSize = 11.sp, color = Color(0xFF339AF0))
}
}
}
if (savedHosts.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
@@ -214,7 +300,7 @@ fun ConnectDialog(
color = Color.White
)
Text(
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
text = "${saved.host}:${saved.port} (${if (saved.model == 0) "Dyn ${saved.dynamicRows}x${saved.dynamicCols}" else "M" + saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
if (saved.autoConnect) " [Auto]" else "",
fontSize = 10.sp,
color = Color.LightGray
@@ -229,12 +315,23 @@ fun ConnectDialog(
hostToConn.host,
hostToConn.port,
hostToConn.model,
hostToConn.dynamicRows,
hostToConn.dynamicCols,
hostToConn.luName,
hostToConn.hostType,
hostToConn.useTls,
hostToConn.tlsVerifyCert,
hostToConn.tn3270e,
hostToConn.graphicsMode
hostToConn.graphicsMode,
hostToConn.codePage,
hostToConn.proxyType,
hostToConn.proxyHost,
hostToConn.proxyPort,
hostToConn.proxyUsername,
hostToConn.proxyPassword,
true,
hostToConn.id,
hostToConn.name
)
},
modifier = Modifier.size(32.dp)
@@ -273,6 +370,7 @@ fun ConnectDialog(
onValueChange = { profileName = it },
label = { Text("Profile Name (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
@@ -282,18 +380,22 @@ fun ConnectDialog(
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
onValueChange = { host = it.filterNot { c -> c.isWhitespace() } },
label = { Text("Host / IP Address") },
placeholder = { Text("127.0.0.1", color = Color.Gray) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier
.weight(0.68f)
.focusRequester(focusRequester)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
onValueChange = { portStr = it.filter { c -> c.isDigit() } },
label = { Text("Port") },
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(0.32f)
)
}
@@ -310,7 +412,8 @@ fun ConnectDialog(
2 to "M2",
3 to "M3",
4 to "M4",
5 to "M5"
5 to "M5",
0 to "DYN"
).forEach { (m, label) ->
val isSelected = (modelNum == m)
Surface(
@@ -336,6 +439,32 @@ fun ConnectDialog(
}
}
}
// If Dynamic Model selected, allow specifying Rows and Columns
if (modelNum == 0) {
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = dynamicRowsStr,
onValueChange = { dynamicRowsStr = it },
label = { Text("Dyn Rows (24-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = dynamicColsStr,
onValueChange = { dynamicColsStr = it },
label = { Text("Dyn Cols (80-255)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
}
// Host System Type (TSO, CMS, CICS)
@@ -412,11 +541,73 @@ fun ConnectDialog(
}
}
// Code Page Selector
var codePageMenuExpanded by remember { mutableStateOf(false) }
val codePagesList = HostStorage.AVAILABLE_CODE_PAGES
val selectedCpLabel = codePagesList.firstOrNull { it.first == codePage }?.second ?: "Code Page $codePage"
Column(modifier = Modifier.fillMaxWidth()) {
Text("EBCDIC Code Page:", fontSize = 11.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Box(modifier = Modifier.fillMaxWidth()) {
Surface(
shape = RoundedCornerShape(6.dp),
color = Color(0xFF2C2D30),
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF495057)),
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.clickable { codePageMenuExpanded = true }
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedCpLabel,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
Text("▼", color = Color.Gray, fontSize = 10.sp)
}
}
DropdownMenu(
expanded = codePageMenuExpanded,
onDismissRequest = { codePageMenuExpanded = false },
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 280.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontSize = 12.sp,
color = if (codePage == cpId) Color(0xFF339AF0) else Color.White,
fontWeight = if (codePage == cpId) FontWeight.Bold else FontWeight.Normal
)
},
onClick = {
codePage = cpId
codePageMenuExpanded = false
}
)
}
}
}
}
OutlinedTextField(
value = luName,
onValueChange = { luName = it },
label = { Text("LU Name / Device Pool (Optional)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
@@ -506,6 +697,102 @@ fun ConnectDialog(
Spacer(modifier = Modifier.width(4.dp))
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
}
// Proxy Settings Expandable Section
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showProxySettings = !showProxySettings }
) {
Checkbox(
checked = showProxySettings || proxyType != "NONE",
onCheckedChange = { checked ->
showProxySettings = checked
if (!checked) proxyType = "NONE"
}
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (proxyType != "NONE") "Proxy Active ($proxyType)" else "Configure Network Proxy",
fontSize = 12.sp,
color = if (proxyType != "NONE") Color(0xFF339AF0) else Color.White
)
}
if (showProxySettings || proxyType != "NONE") {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 4.dp, top = 4.dp)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("Proxy Protocol:", fontSize = 11.sp, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf("NONE", "HTTP", "SOCKS4", "SOCKS5").forEach { pType ->
val isSel = proxyType == pType
FilterChip(
selected = isSel,
onClick = { proxyType = pType },
label = { Text(pType, fontSize = 10.sp) }
)
}
}
if (proxyType != "NONE") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyHost,
onValueChange = { proxyHost = it },
label = { Text("Proxy Host") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.7f)
)
OutlinedTextField(
value = proxyPortStr,
onValueChange = { proxyPortStr = it },
label = { Text("Port") },
placeholder = { Text(if (proxyType == "HTTP") "8080" else "1080") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(0.3f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = proxyUsername,
onValueChange = { proxyUsername = it },
label = { Text("User (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
OutlinedTextField(
value = proxyPassword,
onValueChange = { proxyPassword = it },
label = { Text("Pass (Opt)") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions,
modifier = Modifier.weight(0.5f)
)
}
}
}
}
}
}
}
}
@@ -534,12 +821,23 @@ fun ConnectDialog(
saved.host,
saved.port,
saved.model,
saved.dynamicRows,
saved.dynamicCols,
saved.luName,
saved.hostType,
saved.useTls,
saved.tlsVerifyCert,
saved.tn3270e,
saved.graphicsMode
saved.graphicsMode,
saved.codePage,
saved.proxyType,
saved.proxyHost,
saved.proxyPort,
saved.proxyUsername,
saved.proxyPassword,
true,
saved.id,
saved.name
)
}
},
@@ -551,4 +849,64 @@ fun ConnectDialog(
}
}
}
if (showImportConnectionsDialog) {
var importText by remember { mutableStateOf("") }
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
AlertDialog(
onDismissRequest = { showImportConnectionsDialog = false },
title = { Text("Import Host Connections") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Paste j3270 / a3270 INI connections below:", fontSize = 12.sp)
OutlinedTextField(
value = importText,
onValueChange = { importText = it },
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = {
val clip = clipboardManager.getText()?.text ?: ""
if (clip.isNotBlank()) {
importText = clip
}
}) {
Text("Paste Clipboard", fontSize = 11.sp)
}
}
}
},
confirmButton = {
TextButton(onClick = {
showImportConnectionsDialog = false
if (importText.isNotBlank()) {
try {
val countBefore = savedHosts.size
val merged = HostStorage.importFromIni(context, java.io.StringReader(importText))
savedHosts = merged
val countAfter = merged.size
val diff = countAfter - countBefore
val msg = if (diff > 0) "Imported $diff new connection(s)" else "Updated ${merged.size} connection(s)"
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.widget.Toast.makeText(context, "Error importing: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
}
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = { showImportConnectionsDialog = false }) {
Text("Cancel")
}
}
)
}
}
@@ -0,0 +1,214 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ecl.ECLField
@Composable
fun FieldInspectorDialog(
client: Telnet3270Client?,
screenVersion: Long,
onDismiss: () -> Unit,
onJumpToField: (Int) -> Unit
) {
var fields by remember { mutableStateOf<List<ECLField>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var cols by remember { mutableIntStateOf(80) }
var rows by remember { mutableIntStateOf(24) }
fun refresh() {
if (client != null && client.connectionState.isFullSession) {
val fieldList = client.fieldList
fields = fieldList?.fields ?: emptyList()
cols = client.screenBuffer?.cols ?: 80
rows = client.screenBuffer?.rows ?: 24
} else {
fields = emptyList()
}
}
LaunchedEffect(screenVersion, client) {
refresh()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Presentation Space Field Inspector",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Text(
"${fields.size} fields detected (${rows}x$cols)",
fontSize = 11.sp,
color = Color.Gray
)
}
TextButton(onClick = { refresh() }) {
Text("Refresh", fontSize = 12.sp, color = Color(0xFF339AF0))
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
if (fields.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
"No fields detected or terminal not in 3270 session.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("#", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("Pos", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text("Start", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("End", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("Len", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Prot", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("MDT", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Num", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Hi-Int", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Hidden", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text("Pen", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Wrap", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Content Text", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(220.dp), textAlign = TextAlign.Start)
}
// Data Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(fields) { idx, field ->
val isSelected = (idx == selectedIndex)
val startRow = field.startRow + 1
val startCol = field.startCol + 1
val endRow = field.endRow + 1
val endCol = field.endCol + 1
val displayText = if (field.isHidden) "••••••••" else field.text
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedIndex = idx
onJumpToField(field.dataStart)
}
) {
Row(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("${idx + 1}", color = Color.White, fontSize = 11.sp, modifier = Modifier.width(30.dp), textAlign = TextAlign.Center)
Text("${field.start}", color = Color.Cyan, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(42.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", startRow, startCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text(String.format("%02d/%02d", endRow, endCol), color = Color.LightGray, fontSize = 10.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(52.dp), textAlign = TextAlign.Center)
Text("${field.length}", color = Color.White, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isProtected) "Y" else "-", color = if (field.isProtected) Color.Gray else Color(0xFF51CF66), fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isModified) "Y" else "-", color = if (field.isModified) Color(0xFFFFB450) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isNumeric) "Y" else "-", color = Color.LightGray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isHighIntensity) "Y" else "-", color = if (field.isHighIntensity) Color.White else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text(if (field.isHidden) "Y" else "-", color = if (field.isHidden) Color(0xFFFF6B6B) else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(46.dp), textAlign = TextAlign.Center)
Text(if (field.isPenSelectable) "Y" else "-", color = if (field.isPenSelectable) Color.Yellow else Color.Gray, fontSize = 10.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (field.isWrapped) "↩" else "-", color = if (field.isWrapped) Color(0xFF339AF0) else Color.Gray, fontSize = 10.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(displayText, color = if (field.isProtected) Color(0xFF90CAF9) else Color(0xFFA5D6A7), fontSize = 11.sp, fontFamily = FontFamily.Monospace, maxLines = 1, modifier = Modifier.width(220.dp))
}
}
}
}
}
}
// Bottom Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (selectedIndex in fields.indices) {
Button(
onClick = {
onJumpToField(fields[selectedIndex].dataStart)
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Jump to Field")
}
Spacer(modifier = Modifier.width(8.dp))
}
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
}
}
}
}
}
@@ -0,0 +1,699 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.a3270.FTProgressState
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val TerminalKeyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
)
data class LastTransferState(
var hostFile: String = "",
var localFile: String = "",
var isReceive: Boolean = true,
var isAscii: Boolean = true,
var hostType: FTConfig.HostType = FTConfig.HostType.TSO,
var recfm: FTConfig.RecordFormat = FTConfig.RecordFormat.DEFAULT,
var lreclStr: String = "",
var blksizeStr: String = "",
var units: FTConfig.AllocationUnit = FTConfig.AllocationUnit.TRACKS,
var spaceStr: String = "",
var dirBlocks: String = "",
var cmsOptions: String = ""
)
private var lastTransferState: LastTransferState? = null
fun getLastTransferState(): LastTransferState? = lastTransferState
fun resetSessionState() { lastTransferState = null }
fun formatHostFilename(localName: String, hostType: FTConfig.HostType): String {
if (hostType == FTConfig.HostType.CMS) {
val lastDot = localName.lastIndexOf('.')
return if (lastDot > 0) {
val fn = localName.substring(0, lastDot).replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
val ft = localName.substring(lastDot + 1).replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
"${fn.ifBlank { "FILE" }} ${ft.ifBlank { "TEXT" }} A"
} else {
val clean = localName.replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
"${clean.ifBlank { "FILE" }} FILE A"
}
} else if (hostType == FTConfig.HostType.TSO) {
val cleanBase = localName.substringBeforeLast('.').replace(Regex("[^a-zA-Z0-9]"), "").uppercase()
return "'${cleanBase.ifBlank { "DATA" }}'"
}
return localName
}
fun formatLocalFilenameFromHost(hostFile: String, hostType: FTConfig.HostType): String {
val cleanName = hostFile.trim('\'', '"')
if (hostType == FTConfig.HostType.CMS) {
val tokens = cleanName.trim().split(Regex("\\s+"))
if (tokens.size >= 2) {
return "${tokens[0].lowercase()}.${tokens[1].lowercase()}"
} else if (tokens.size == 1 && tokens[0].isNotBlank()) {
return "${tokens[0].lowercase()}.txt"
}
} else {
val lastDot = cleanName.lastIndexOf('.')
if (lastDot > 0) {
return "${cleanName.substring(lastDot + 1).lowercase()}.txt"
}
}
return if (cleanName.isNotBlank()) "${cleanName.lowercase()}.txt" else "download.txt"
}
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
client: haus.nightmare.lib3270j.Telnet3270Client? = null,
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val context = LocalContext.current
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
val clientConfig = client?.config
val configuredFtType = clientConfig?.ftHostType
val effectiveHostType = if (configuredFtType != null && (clientConfig.isSavedConnection || lastTransferState == null)) {
configuredFtType
} else {
lastTransferState?.hostType ?: initialTypeEnum
}
var hostFile by remember { mutableStateOf(lastTransferState?.hostFile ?: "") }
var localFile by remember { mutableStateOf(lastTransferState?.localFile ?: "") }
var localDisplayName by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(lastTransferState?.isReceive ?: true) }
var isAscii by remember { mutableStateOf(lastTransferState?.isAscii ?: true) }
var hostType by remember { mutableStateOf(effectiveHostType) }
var recfm by remember { mutableStateOf(lastTransferState?.recfm ?: FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf(lastTransferState?.lreclStr ?: "") }
var blksizeStr by remember { mutableStateOf(lastTransferState?.blksizeStr ?: "") }
var units by remember { mutableStateOf(lastTransferState?.units ?: FTConfig.AllocationUnit.TRACKS) }
var spaceStr by remember { mutableStateOf(lastTransferState?.spaceStr ?: "") }
var dirBlocks by remember { mutableStateOf(lastTransferState?.dirBlocks ?: "") }
var cmsOptions by remember { mutableStateOf(lastTransferState?.cmsOptions ?: "") }
var overwrite by remember { mutableStateOf(true) }
var showBrowseDialog by remember { mutableStateOf(false) }
var targetSaveUri by remember { mutableStateOf<Uri?>(null) }
var lastSavedFile by remember { mutableStateOf<File?>(null) }
// SAF Launcher for uploading (Send / PUT)
val openDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
try {
val displayName = getFileNameFromUri(context, uri).ifBlank { "upload.bin" }
val tempDir = File(context.cacheDir, "ft_upload").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
localFile = tempFile.absolutePath
localDisplayName = displayName
if (hostFile.isBlank()) {
hostFile = formatHostFilename(displayName, hostType)
}
Toast.makeText(context, "Selected: $displayName", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed to read file: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
// SAF Launcher for choosing download destination (Receive / GET)
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("*/*")
) { uri: Uri? ->
if (uri != null) {
targetSaveUri = uri
val displayName = getFileNameFromUri(context, uri).ifBlank { "download.bin" }
localDisplayName = displayName
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, displayName)
localFile = tempFile.absolutePath
Toast.makeText(context, "Destination: $displayName", Toast.LENGTH_SHORT).show()
}
}
// Watch for completed transfers to export to destination URI if chosen
LaunchedEffect(ftProgressState.isActive, ftProgressState.isError) {
if (!ftProgressState.isActive && !ftProgressState.isError && ftProgressState.bytesTransferred > 0 && isReceive && localFile.isNotBlank()) {
val src = File(localFile)
if (src.exists()) {
lastSavedFile = src
val uri = targetSaveUri
if (uri != null) {
try {
context.contentResolver.openOutputStream(uri)?.use { out ->
src.inputStream().use { input ->
input.copyTo(out)
}
}
Toast.makeText(context, "Saved download to destination file", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Failed writing to destination: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
if (showBrowseDialog) {
val initialQuery = resolveDefaultQuery(hostType, hostFile)
HostDirectoryDialog(
client = client,
initialHostType = hostType,
initialQuery = initialQuery,
onDismiss = { showBrowseDialog = false },
onSelectEntry = { selectedName, selectedEntry ->
hostFile = selectedName
if (selectedEntry != null) {
val r = selectedEntry.recfm
if (!r.isNullOrBlank()) {
val mappedRecfm = when (r.trim().uppercase(Locale.US)) {
"F", "FB", "FA", "FBA", "FBM" -> FTConfig.RecordFormat.FIXED
"V", "VB", "VA", "VBA", "VBM" -> FTConfig.RecordFormat.VARIABLE
else -> FTConfig.RecordFormat.DEFAULT
}
recfm = mappedRecfm
}
if (selectedEntry.lrecl > 0) {
lreclStr = selectedEntry.lrecl.toString()
}
if (selectedEntry.blksize > 0) {
blksizeStr = selectedEntry.blksize.toString()
}
}
val shouldAutofill = localFile.isBlank() || (lastTransferState != null && localFile == lastTransferState?.localFile)
if (shouldAutofill) {
val safeName = formatLocalFilenameFromHost(selectedName, hostType)
val tempDir = File(context.cacheDir, "ft_download").apply { if (!exists()) mkdirs() }
val tempFile = File(tempDir, safeName)
localFile = tempFile.absolutePath
localDisplayName = safeName
}
showBrowseDialog = false
}
)
}
fun exportToDownloads() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val targetName = (if (localDisplayName.isNotBlank()) localDisplayName else src.name).let {
val base = it.substringBeforeLast('.')
val ext = if (it.contains('.')) "." + it.substringAfterLast('.') else ""
"${base}_$timeStamp$ext"
}
val dst = File(downloadsDir, targetName)
src.copyTo(dst, overwrite = true)
Toast.makeText(context, "Exported to Downloads/${dst.name}", Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareDownloadedFile() {
val src = lastSavedFile ?: if (localFile.isNotBlank()) File(localFile) else null
if (src == null || !src.exists()) {
Toast.makeText(context, "Downloaded file not found", Toast.LENGTH_SHORT).show()
return
}
try {
val text = src.readText()
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, localDisplayName.ifBlank { src.name })
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share Transferred File"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
if (!ftProgressState.isError && isReceive && lastSavedFile?.exists() == true) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedButton(
onClick = { exportToDownloads() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Save to Downloads 📥", fontSize = 11.sp)
}
OutlinedButton(
onClick = { shareDownloadedFile() },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
modifier = Modifier.weight(1f)
) {
Text("Share 📤", fontSize = 11.sp)
}
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
}
// Transfer Direction
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isReceive,
onClick = {
isReceive = true
targetSaveUri = null
},
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = {
isReceive = false
targetSaveUri = null
},
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Host File Name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
if (client != null && client.connectionState.isFullSession) {
OutlinedButton(
onClick = { showBrowseDialog = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text("Browse...", fontSize = 11.sp)
}
}
}
// Local File Selection (SAF Document Picker integration)
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = if (localDisplayName.isNotBlank()) localDisplayName else localFile,
onValueChange = {
localDisplayName = it
localFile = it
},
label = { Text(if (isReceive) "Local Destination" else "Local File to Upload") },
placeholder = { Text(if (isReceive) "download.txt" else "sample.txt") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(
imeAction = if (!isReceive && hostType == FTConfig.HostType.TSO) ImeAction.Next else ImeAction.Done
),
modifier = Modifier.weight(1f)
)
Button(
onClick = {
if (isReceive) {
val defName = if (hostFile.isNotBlank()) {
formatLocalFilenameFromHost(hostFile, hostType)
} else "download.txt"
createDocumentLauncher.launch(defName)
} else {
openDocumentLauncher.launch(arrayOf("*/*"))
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.padding(top = 6.dp)
) {
Text(if (isReceive) "Save As..." else "Pick File...", fontSize = 11.sp)
}
}
Text(
text = if (isReceive) "Pick a destination or enter a file name to save in app storage" else "Use 'Pick File' to select any file from Downloads / Storage",
fontSize = 10.sp,
color = Color.LightGray,
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
)
}
// Transfer Mode
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII (Text)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary", fontSize = 11.sp) }
)
}
}
// Host Type (TSO, CMS, CICS)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
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)
)
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))
}
}
}
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)
)
}
}
// CMS Options
if (hostType == FTConfig.HostType.CMS) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = cmsOptions,
onValueChange = { cmsOptions = it },
label = { Text("CMS Options", fontSize = 11.sp) },
placeholder = { Text("e.g. ASCII CRLF or RECFM F LRECL 80") },
singleLine = true,
keyboardOptions = TerminalKeyboardOptions.copy(imeAction = ImeAction.Done),
modifier = Modifier.fillMaxWidth()
)
}
}
},
confirmButton = {
Button(
onClick = {
val resolvedLocal = if (localFile.isNotBlank()) {
localFile.trim()
} else if (localDisplayName.isNotBlank()) {
val tempDir = File(context.cacheDir, if (isReceive) "ft_download" else "ft_upload").apply { if (!exists()) mkdirs() }
File(tempDir, localDisplayName.trim()).absolutePath
} else ""
if (hostFile.isNotBlank() && resolvedLocal.isNotBlank()) {
localFile = resolvedLocal
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(resolvedLocal)
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
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 (hostType == FTConfig.HostType.CMS && cmsOptions.isNotBlank()) {
setOptions(cmsOptions.trim())
}
}
lastTransferState = LastTransferState(
hostFile = hostFile.trim(),
localFile = resolvedLocal,
isReceive = isReceive,
isAscii = isAscii,
hostType = hostType,
recfm = recfm,
lreclStr = lreclStr,
blksizeStr = blksizeStr,
units = units,
spaceStr = spaceStr,
dirBlocks = dirBlocks,
cmsOptions = cmsOptions
)
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && (localFile.isNotBlank() || localDisplayName.isNotBlank()) && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
private fun getFileNameFromUri(context: Context, uri: Uri): String {
var name = ""
try {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
name = it.getString(nameIndex) ?: ""
}
}
}
} catch (_: Exception) {}
if (name.isBlank()) {
name = uri.lastPathSegment ?: "file"
}
return name
}
@@ -0,0 +1,183 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun FindDialog(
cols: Int = 80,
onDismiss: () -> Unit,
onFind: (query: String, matchCase: Boolean, forward: Boolean) -> Int,
onClearHighlight: () -> Unit
) {
var query by remember { mutableStateOf("") }
var matchCase by remember { mutableStateOf(false) }
var forward by remember { mutableStateOf(true) }
var statusMessage by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100)
focusRequester.requestFocus()
}
fun doFind(searchForward: Boolean) {
val trimmed = query.trim()
if (trimmed.isEmpty()) {
statusMessage = "Please enter search text."
isError = true
return
}
val pos = onFind(trimmed, matchCase, searchForward)
if (pos >= 0) {
val effectiveCols = if (cols > 0) cols else 80
val row = (pos / effectiveCols) + 1
val col = (pos % effectiveCols) + 1
statusMessage = "Found at position $pos ($row/$col)"
isError = false
} else {
statusMessage = "Text not found: \"$trimmed\""
isError = true
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.92f)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Find on Screen",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
OutlinedTextField(
value = query,
onValueChange = {
query = it
if (statusMessage.isNotBlank()) statusMessage = ""
},
label = { Text("Find What") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { doFind(forward) }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// Options Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { matchCase = !matchCase }
) {
Checkbox(
checked = matchCase,
onCheckedChange = { matchCase = it }
)
Spacer(modifier = Modifier.width(4.dp))
Text("Match Case", fontSize = 12.sp, color = Color.White)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
FilterChip(
selected = forward,
onClick = { forward = true },
label = { Text("Down ↓", fontSize = 11.sp) }
)
FilterChip(
selected = !forward,
onClick = { forward = false },
label = { Text("Up ↑", fontSize = 11.sp) }
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = if (isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = {
onClearHighlight()
onDismiss()
}) {
Text("Close", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
OutlinedButton(onClick = {
onClearHighlight()
statusMessage = ""
}) {
Text("Clear")
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = { doFind(forward) },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Find Next")
}
}
}
}
}
}
@@ -0,0 +1,458 @@
package haus.nightmare.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.compose.BackHandler
import androidx.compose.ui.window.PopupProperties
import haus.nightmare.lib3270j.ConnectionState
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
@Composable
fun TwoRowKeyBar(
connectionState: ConnectionState,
isShiftPressed: Boolean = false,
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
isDocMode: Boolean = false,
isWordWrap: Boolean = false,
isAplMode: Boolean = false,
fourColorOverride: Boolean = false,
hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {},
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onFtClick: () -> Unit,
onFieldInspectorClick: () -> Unit = {},
onFindClick: () -> Unit = {},
onScreenExportClick: () -> Unit = {},
onScreenHistoryClick: () -> Unit = {},
onScriptClick: () -> Unit = {},
onMacroRuntimeClick: () -> Unit = {},
onPrinterSessionClick: () -> Unit = {},
onToggleLightPen: () -> Unit = {},
onToggleInsert: () -> Unit = {},
onToggleDocMode: () -> Unit = {},
onToggleWordWrap: () -> Unit = {},
onToggleAplMode: () -> Unit = {},
onToggleFourColorOverride: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
onBackTab: () -> Unit,
onEraseInput: () -> Unit = {},
onEraseEof: () -> Unit = {},
onAttn: () -> Unit = {},
onSysReq: () -> Unit = {},
onCursorSelect: () -> Unit = {},
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
onCursorRight: () -> Unit = {},
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
BackHandler(enabled = menuExpanded) {
menuExpanded = false
}
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF161719))
) {
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Diagnostics/Tools/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "⋮",
color = Color(0xFF343A40),
modifier = Modifier.fillMaxWidth(),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (System.currentTimeMillis() - lastDismissTime > 200L) {
menuExpanded = !menuExpanded
}
}
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = {
menuExpanded = false
lastDismissTime = System.currentTimeMillis()
},
properties = PopupProperties(
focusable = false,
dismissOnClickOutside = true
),
modifier = Modifier
.background(Color(0xFF2C2D30))
.heightIn(max = 420.dp)
) {
if (!connectionState.isConnected()) {
DropdownMenuItem(
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onConnectClick()
}
)
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
} else {
DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onDisconnectClick()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("Find on Screen", color = Color.White) },
onClick = {
menuExpanded = false
onFindClick()
}
)
DropdownMenuItem(
text = { Text("Field Inspector", color = Color.White) },
onClick = {
menuExpanded = false
onFieldInspectorClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (IND\$FILE)", color = Color.White) },
onClick = {
menuExpanded = false
onFtClick()
}
)
DropdownMenuItem(
text = { Text("Save / Share Screen", color = Color.White) },
onClick = {
menuExpanded = false
onScreenExportClick()
}
)
DropdownMenuItem(
text = { Text("Screen History", color = Color.White) },
onClick = {
menuExpanded = false
onScreenHistoryClick()
}
)
DropdownMenuItem(
text = { Text("Run Keystroke Script", color = Color.White) },
onClick = {
menuExpanded = false
onScriptClick()
}
)
DropdownMenuItem(
text = { Text("Macro Runtime (<HASCRIPT>)", color = Color.White) },
onClick = {
menuExpanded = false
onMacroRuntimeClick()
}
)
DropdownMenuItem(
text = { Text("3287 Printer Session", color = Color.White) },
onClick = {
menuExpanded = false
onPrinterSessionClick()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("Attention (Attn)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onAttn()
}
)
DropdownMenuItem(
text = { Text("System Request (SysReq)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onSysReq()
}
)
DropdownMenuItem(
text = { Text("Erase Input (ErInp)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onEraseInput()
}
)
DropdownMenuItem(
text = { Text("Cursor Select (CurSel)", color = Color.LightGray) },
onClick = {
menuExpanded = false
onCursorSelect()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
DropdownMenuItem(
text = { Text("Insert Mode: ${if (isInsertMode) "ON" else "OFF"}", color = if (isInsertMode) Color(0xFF7890F0) else Color.White) },
onClick = {
menuExpanded = false
onToggleInsert()
}
)
DropdownMenuItem(
text = { Text("Document Mode (DOC): ${if (isDocMode) "ON" else "OFF"}", color = if (isDocMode) Color(0xFF38D9A9) else Color.White) },
onClick = {
menuExpanded = false
onToggleDocMode()
}
)
DropdownMenuItem(
text = { Text("Word Wrap: ${if (isWordWrap) "ON" else "OFF"}", color = if (isWordWrap) Color(0xFF38D9A9) else Color.White) },
onClick = {
menuExpanded = false
onToggleWordWrap()
}
)
DropdownMenuItem(
text = { Text("APL Keyboard Mode: ${if (isAplMode) "ON" else "OFF"}", color = if (isAplMode) Color(0xFFFFA94D) else Color.White) },
onClick = {
menuExpanded = false
onToggleAplMode()
}
)
DropdownMenuItem(
text = { Text("Base 4-Color Override: ${if (fourColorOverride) "ON" else "OFF"}", color = if (fourColorOverride) Color(0xFFFFD43B) else Color.White) },
onClick = {
menuExpanded = false
onToggleFourColorOverride()
}
)
HorizontalDivider(color = Color(0xFF3E4044))
}
DropdownMenuItem(
text = {
Text(
text = if (isLightPenMode) "Light Pen: ON" else "Light Pen: OFF",
color = if (isLightPenMode) Color(0xFFFFFF50) else Color.White,
fontWeight = FontWeight.SemiBold
)
},
onClick = {
menuExpanded = false
onToggleLightPen()
}
)
DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onSettingsClick()
}
)
}
}
// 2. TAB / BTAB depending on Shift state
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
KeyButton(
label = tabLabel,
color = Color(0xFF1C7ED6),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onBackTab()
onClearShift()
} else {
onTab()
}
}
)
// 3. RESET / ERASE (Erase Input) depending on Shift state
val resetLabel = if (isShiftPressed) "ERASE" else "RESET"
KeyButton(
label = resetLabel,
color = Color(0xFFE67700),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseInput()
onClearShift()
} else {
onReset()
}
}
)
// 4. ENTER / EOF (Erase EOF) depending on Shift state
val enterLabel = if (isShiftPressed) "EOF" else "ENTER"
KeyButton(
label = enterLabel,
color = Color(0xFF2B8A3E),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onEraseEof()
onClearShift()
} else {
onSendAid(AID_ENTER)
}
}
)
// 5. CLEAR / ATTN (Attention) depending on Shift state
val clearLabel = if (isShiftPressed) "ATTN" else "CLEAR"
KeyButton(
label = clearLabel,
color = Color(0xFFC92A2A),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onAttn()
onClearShift()
} else {
onSendAid(AID_CLEAR)
}
}
)
// 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
// 7. PA2
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
// 8. PA3
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
// 9. Left Navigation
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
// 10. Up Navigation
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
// 11. Down Navigation
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
// 12. Right Navigation
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
}
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
val startPf = if (isShiftPressed) 13 else 1
val endPf = if (isShiftPressed) 24 else 12
for (i in startPf..endPf) {
val aid = pfAids[i - 1]
KeyButton(
label = "F$i",
color = Color(0xFF364FC7),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
onSendAid(aid)
if (isShiftPressed) {
onClearShift()
}
}
)
}
}
}
}
@Composable
private fun KeyButton(
label: String,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hapticFeedbackEnabled: Boolean = true,
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
) {
val view = LocalView.current
Surface(
onClick = {
if (hapticFeedbackEnabled) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
onClick()
},
shape = RoundedCornerShape(4.dp),
color = color,
shadowElevation = 1.dp,
modifier = modifier
.padding(horizontal = 1.dp, vertical = 1.dp)
.height(31.dp)
.focusProperties { canFocus = false }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
) {
Text(
text = label,
color = Color.White,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
@@ -0,0 +1,910 @@
package haus.nightmare.a3270.ui
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryParser
import haus.nightmare.lib3270j.ft.dir.HostDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
fun resolveDefaultQuery(hostType: FTConfig.HostType, initialQuery: String?): String {
val defaultQuery = initialQuery?.trim() ?: ""
if (hostType == FTConfig.HostType.CMS && (defaultQuery.isEmpty() || defaultQuery == "* * *" || defaultQuery == "*")) {
return "* * A"
}
return defaultQuery
}
fun formatCMSFilter(pattern: String?): String {
if (pattern.isNullOrBlank()) {
return "* * A"
}
val clean = pattern.replace("'", "").replace("\"", "").trim()
if (clean == "* * *" || clean == "*") {
return "* * A"
} else if (clean.contains('.')) {
val dot = clean.indexOf('.')
return clean.substring(0, dot).trim() + " " + clean.substring(dot + 1).trim() + " A"
} else {
val parts = clean.split(Regex("\\s+"))
return when (parts.size) {
1 -> "${parts[0]} * A"
2 -> "${parts[0]} ${parts[1]} A"
else -> "${parts[0]} ${parts[1]} ${parts[2]}"
}
}
}
fun matchesFilter(entry: HostDirectoryEntry, filter: String?): Boolean {
if (filter.isNullOrBlank()) return true
val cleanFilter = filter.replace("'", "").replace("\"", "").trim()
if (cleanFilter.isEmpty() || cleanFilter == "*" || cleanFilter == "* * *" || cleanFilter == "*.*") {
return true
}
return when (entry) {
is CMSDirectoryEntry -> matchesCmsFilter(entry, cleanFilter)
is TSODirectoryEntry -> matchesTsoFilter(entry, cleanFilter)
else -> matchesWildcard(entry.name, cleanFilter)
}
}
fun matchesCmsFilter(cms: CMSDirectoryEntry, filter: String): Boolean {
val fn = cms.filename?.trim() ?: ""
val ft = cms.filetype?.trim() ?: ""
val fm = cms.filemode?.trim() ?: ""
val tokens = filter.split(Regex("\\s+"))
return if (tokens.size >= 3) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1]) && matchesCmsFilemode(fm, tokens[2])
} else if (tokens.size == 2) {
matchesWildcard(fn, tokens[0]) && matchesWildcard(ft, tokens[1])
} else {
val tok = tokens[0]
if (tok.contains('.')) {
val dot = tok.indexOf('.')
matchesWildcard(fn, tok.substring(0, dot)) && matchesWildcard(ft, tok.substring(dot + 1))
} else if (matchesWildcard(fn, tok) || matchesWildcard(ft, tok) || matchesCmsFilemode(fm, tok)) {
true
} else {
matchesWildcard(cms.name, tok)
}
}
}
fun matchesCmsFilemode(actualFm: String?, fmPattern: String?): Boolean {
if (fmPattern.isNullOrEmpty() || fmPattern == "*") return true
if (actualFm.isNullOrEmpty()) return false
if (fmPattern.length == 1 && fmPattern[0].isLetter()) {
return actualFm.startsWith(fmPattern, ignoreCase = true)
}
return matchesWildcard(actualFm, fmPattern)
}
fun matchesTsoFilter(tso: TSODirectoryEntry, filter: String): Boolean {
val dsn = tso.datasetName?.trim() ?: ""
if (filter == "*" || filter == "* * *" || filter == "*.*") return true
if (filter.contains('*') || filter.contains('?')) {
return matchesWildcard(dsn, filter)
}
return dsn.contains(filter, ignoreCase = true)
}
fun matchesWildcard(text: String?, pattern: String?): Boolean {
val t = text ?: ""
if (pattern.isNullOrEmpty() || pattern == "*") return true
val regex = buildString {
append("^")
for (c in pattern) {
when (c) {
'*' -> append(".*")
'?' -> append(".")
in "\\.[]{}()+^$|" -> append("\\").append(c)
else -> append(Regex.escape(c.uppercaseChar().toString()))
}
}
append("$")
}
return t.uppercase(Locale.US).matches(Regex(regex))
}
private fun waitForKeyboardUnlock(client: Telnet3270Client?, timeoutMs: Long): Boolean {
if (client == null) return false
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
var locked = false
if (client.inputProcessor != null && client.inputProcessor.isKeyboardLocked) {
locked = true
}
if (client.oia != null && (client.oia.isKeyLocked || client.oia.isXSystem)) {
locked = true
}
if (!locked) {
return true
}
try {
Thread.sleep(40)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return false
}
}
return false
}
@Composable
fun HostDirectoryDialog(
client: Telnet3270Client?,
initialHostType: FTConfig.HostType = FTConfig.HostType.TSO,
initialQuery: String = "",
onDismiss: () -> Unit,
onSelectDataset: (String) -> Unit = {},
onSelectEntry: ((String, HostDirectoryEntry?) -> Unit)? = null
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var hostType by remember { mutableStateOf(initialHostType) }
var query by remember { mutableStateOf(resolveDefaultQuery(initialHostType, initialQuery)) }
var allEntries by remember { mutableStateOf<List<HostDirectoryEntry>>(emptyList()) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var statusMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var inspectionEntry by remember { mutableStateOf<TSODirectoryEntry?>(null) }
var showInspectionDialog by remember { mutableStateOf(false) }
// Client-side filtering
val currentEntries by remember(allEntries, query, hostType) {
derivedStateOf {
val filter = query.trim()
if (filter.isEmpty()) {
allEntries
} else {
allEntries.filter { matchesFilter(it, filter) }
}
}
}
fun parseTextData(text: String, source: String) {
allEntries = if (hostType == FTConfig.HostType.CMS) {
CMSDirectoryParser.parse(text)
} else {
TSODirectoryParser.parse(text)
}
selectedIndex = -1
statusMessage = if (allEntries.isEmpty()) {
"0 entries found from $source. Use 'Query Host' or 'Paste Clipboard'."
} else {
"Parsed ${allEntries.size} entries from $source."
}
}
fun parseScreen() {
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to parse."
return
}
val rows = c.ps.rows
val cols = c.ps.cols
val screenText = c.ps.copyString(0, 0, rows - 1, cols - 1)
parseTextData(screenText, "presentation space")
}
fun parseClipboard() {
try {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (cm != null && cm.hasPrimaryClip()) {
val clip = cm.primaryClip
if (clip != null && clip.itemCount > 0) {
val clipText = clip.getItemAt(0).coerceToText(context)?.toString()
if (!clipText.isNullOrBlank()) {
parseTextData(clipText, "clipboard")
return
}
}
}
statusMessage = "Clipboard contains no text."
} catch (ex: Exception) {
statusMessage = "Error reading clipboard: ${ex.message}"
}
}
fun queryHost() {
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query."
return
}
val pattern = query.trim()
val curHostType = hostType
statusMessage = "Querying host ($curHostType)..."
isLoading = true
coroutineScope.launch(Dispatchers.IO) {
try {
val allPages = StringBuilder()
val maxPages = 500
if (curHostType == FTConfig.HostType.CMS) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
val cmsFilter = formatCMSFilter(pattern)
c.ps.SendKeys("LISTFILE $cmsFilter ( nohead alloc date[enter]")
Thread.sleep(100)
for (page in 0 until maxPages) {
val startWait = System.currentTimeMillis()
val timeoutMs = 10000L
var screen = ""
var isMore = false
while (System.currentTimeMillis() - startWait < timeoutMs) {
waitForKeyboardUnlock(c, 500)
val rows = c.ps.rows
val cols = c.ps.cols
screen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val upper = screen.uppercase(Locale.US)
if (upper.contains("MORE...") || upper.contains("HOLDING")) {
isMore = true
break
}
if (upper.contains("READY;") || upper.contains("READY(")) {
break
}
Thread.sleep(150)
}
allPages.append(screen).append("\n")
if (isMore) {
c.ps.SendKeys("[clear]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 3000)
} else {
break
}
}
if (c.ps != null) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
}
} else {
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
var tsoCmd = "TSO LISTCAT ALL"
if (pattern.isNotEmpty()) {
var dsnPattern = pattern.trim()
if (dsnPattern.startsWith("'") && dsnPattern.endsWith("'") && dsnPattern.length > 2) {
dsnPattern = dsnPattern.substring(1, dsnPattern.length - 1)
}
val paren = dsnPattern.indexOf('(')
if (paren > 0) {
dsnPattern = dsnPattern.substring(0, paren)
}
if (dsnPattern == "*" || dsnPattern == "* * *" || dsnPattern == "*.*") {
tsoCmd = "TSO LISTCAT ALL"
} else {
var level = dsnPattern
if (level.endsWith(".*")) {
level = level.substring(0, level.length - 2)
} else if (level.endsWith("*")) {
level = level.substring(0, level.length - 1)
}
tsoCmd = if (level.isEmpty()) "TSO LISTCAT ALL" else "TSO LISTCAT LEVEL($level) ALL"
}
}
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("NONVSAM") || u.contains("CLUSTER") || u.contains("GDG")
|| u.contains("ALIAS") || u.contains("IN-CAT") || u.contains("IDCAMS")
|| u.contains("***") || u.contains("READY") || u.contains("NOT FOUND")
|| u.contains("NO ENTRY") || s.contains(" --- ")
|| u.contains("--RECFM-") || u.contains("--VOLUMES--")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until maxPages) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
allPages.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else if (upper.contains("NONVSAM") || upper.contains("CLUSTER") || upper.contains("GDG")
|| upper.contains("ALIAS") || upper.contains("IDCAMS") || screen.contains(" --- ")
|| upper.contains("--RECFM-") || upper.contains("--VOLUMES--")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 1000)
val r2 = c.ps.rows
val col2 = c.ps.cols
val s2 = c.ps.copyString(0, 0, r2 - 1, col2 - 1)
if (s2.uppercase(Locale.US).contains("***")) {
prevScreen = s2
allPages.append(s2).append("\n")
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
continue
} else {
if (s2 != screen && s2.isNotBlank()) {
allPages.append(s2).append("\n")
}
break
}
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("NONVSAM") || currentScr.uppercase(Locale.US).contains("IDCAMS")
|| currentScr.contains(" --- ") || currentScr.uppercase(Locale.US).contains("--RECFM-")
|| (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
}
val collectedText = allPages.toString()
withContext(Dispatchers.Main) {
parseTextData(collectedText, "host query")
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusMessage = "Host query failed: ${e.message}"
isLoading = false
}
}
}
}
fun inspectSelectedDataset() {
val selected = currentEntries.getOrNull(selectedIndex)
if (selected == null || selected !is TSODirectoryEntry) {
statusMessage = "Please select a TSO dataset entry to inspect."
return
}
val c = client
if (c == null || !c.connectionState.isFullSession || c.ps == null) {
statusMessage = "No active session to query host."
return
}
val dsn = selected.datasetName
if (dsn.isNullOrBlank()) return
statusMessage = "Inspecting $dsn via TSO LISTDS..."
isLoading = true
coroutineScope.launch(Dispatchers.IO) {
try {
val output = StringBuilder()
val rows = c.ps.rows
val cols = c.ps.cols
val initialScreen = c.ps.copyString(0, 0, rows - 1, cols - 1)
val isFormatted = c.screenBuffer != null && c.screenBuffer.isFormatted
val hasCmdArrow = initialScreen.contains("===>")
val isIspf = isFormatted || hasCmdArrow || initialScreen.contains("ISPF")
val tsoCmd = "TSO LISTDS '$dsn' STATUS"
if (isIspf) {
val arrowPos = initialScreen.indexOf("===>")
if (arrowPos >= 0 && c.screenBuffer != null) {
val inputAddr = c.screenBuffer.findNextUnprotected(arrowPos)
if (inputAddr > 0) {
c.screenBuffer.cursorAddress = inputAddr
c.screenBuffer.updateDisplaySnapshot()
} else {
c.ps.SendKeys("[home]")
}
} else {
c.ps.SendKeys("[home]")
}
c.ps.SendKeys("[erase_eof]")
c.ps.SendKeys("$tsoCmd[enter]")
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 3000)
c.ps.SendKeys("$tsoCmd[enter]")
}
Thread.sleep(100)
val startCmd = System.currentTimeMillis()
while (System.currentTimeMillis() - startCmd < 4000) {
waitForKeyboardUnlock(c, 200)
val r = c.ps.rows
val col = c.ps.cols
val s = c.ps.copyString(0, 0, r - 1, col - 1)
val u = s.uppercase(Locale.US)
if (u.contains("--RECFM-") || u.contains("--VOLUMES--") || u.contains("***")
|| u.contains("READY") || u.contains("NOT IN CATALOG") || u.contains("NOT FOUND")) {
break
}
Thread.sleep(80)
}
var prevScreen = ""
for (page in 0 until 5) {
waitForKeyboardUnlock(c, 300)
val r = c.ps.rows
val col = c.ps.cols
val screen = c.ps.copyString(0, 0, r - 1, col - 1)
val upper = screen.uppercase(Locale.US)
if (screen == prevScreen || screen.isBlank()) {
break
}
prevScreen = screen
output.append(screen).append("\n")
if (upper.contains("***")) {
c.ps.SendKeys("[enter]")
Thread.sleep(100)
waitForKeyboardUnlock(c, 2000)
} else {
break
}
}
if (c.ps != null) {
if (isIspf) {
c.ps.SendKeys("[enter]")
Thread.sleep(150)
waitForKeyboardUnlock(c, 1500)
val r = c.ps.rows
val col = c.ps.cols
val currentScr = c.ps.copyString(0, 0, r - 1, col - 1)
if (currentScr.uppercase(Locale.US).contains("--RECFM-") || (c.screenBuffer != null && !c.screenBuffer.isFormatted)) {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
} else {
c.ps.SendKeys("[clear]")
waitForKeyboardUnlock(c, 2000)
}
}
val outText = output.toString()
val parsed = TSODirectoryParser.parse(outText)
withContext(Dispatchers.Main) {
isLoading = false
if (parsed.isNotEmpty()) {
val updated = parsed[0]
if (updated.recfm != null) selected.recfm = updated.recfm
if (updated.lrecl > 0) selected.lrecl = updated.lrecl
if (updated.blksize > 0) selected.blksize = updated.blksize
if (updated.dsorg != null) selected.dsorg = updated.dsorg
if (updated.volume != null && (selected.volume == null || selected.volume == "******" || selected.volume.isEmpty())) {
selected.volume = updated.volume
}
if (updated.tracksAllocated > 0) selected.tracksAllocated = updated.tracksAllocated
if (updated.extents > 0) selected.extents = updated.extents
statusMessage = "Attributes for $dsn: RECFM=${selected.recfm}, LRECL=${selected.lrecl}, BLKSIZE=${selected.blksize}, VOL=${selected.volume}"
inspectionEntry = selected
showInspectionDialog = true
} else {
statusMessage = "No VTOC attributes found for $dsn"
}
}
} catch (ex: Exception) {
withContext(Dispatchers.Main) {
isLoading = false
statusMessage = "Inspection failed: ${ex.message}"
}
}
}
}
LaunchedEffect(Unit) {
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
}
if (showInspectionDialog && inspectionEntry != null) {
val entry = inspectionEntry!!
AlertDialog(
onDismissRequest = { showInspectionDialog = false },
title = { Text("Dataset Attributes (LISTDS)", fontWeight = FontWeight.Bold, fontSize = 16.sp) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Dataset: ${entry.datasetName}", fontWeight = FontWeight.Bold, fontSize = 13.sp, color = Color.White)
HorizontalDivider(color = Color(0xFF373A40))
Text("Volume: ${entry.volume ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Record Format (RECFM): ${entry.recfm ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Logical Record Length (LRECL): ${if (entry.lrecl > 0) entry.lrecl.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Block Size (BLKSIZE): ${if (entry.blksize > 0) entry.blksize.toString() else "N/A"}", fontSize = 12.sp, color = Color.LightGray)
Text("Dataset Organization (DSORG): ${entry.dsorg ?: "N/A"}", fontSize = 12.sp, color = Color.LightGray)
if (entry.tracksAllocated > 0) {
Text("Allocated Tracks: ${entry.tracksAllocated}", fontSize = 12.sp, color = Color.LightGray)
}
if (entry.extents > 0) {
Text("Extents: ${entry.extents}", fontSize = 12.sp, color = Color.LightGray)
}
}
},
confirmButton = {
Button(onClick = { showInspectionDialog = false }) {
Text("OK")
}
}
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header & Query Bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Host Catalog & Directory Browser",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Row {
FilterChip(
selected = (hostType == FTConfig.HostType.TSO),
onClick = {
hostType = FTConfig.HostType.TSO
if (query == "* * A" || query == "* * *") {
query = ""
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("TSO", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (hostType == FTConfig.HostType.CMS),
onClick = {
hostType = FTConfig.HostType.CMS
if (query.isBlank() || query == "* * *" || query == "*") {
query = "* * A"
}
if (client != null && client.connectionState.isFullSession) {
queryHost()
} else {
parseScreen()
}
},
label = { Text("CMS", fontSize = 11.sp) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Filter / Pattern") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "USER.*" else "* * A") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(onSearch = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else if (allEntries.isEmpty() && client != null && client.connectionState.isFullSession) {
queryHost()
}
}),
modifier = Modifier.weight(1f)
)
Button(
onClick = {
if (query.contains("\n") || query.contains("\r") || query.uppercase(Locale.US).contains("NONVSAM")
|| query.uppercase(Locale.US).contains("IN-CAT") || query.uppercase(Locale.US).contains("FILENAME")) {
parseTextData(query, "query text")
} else {
queryHost()
}
},
enabled = !isLoading,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text(if (isLoading) "Querying..." else "Query Host")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (statusMessage.isNotBlank()) statusMessage else "${currentEntries.size} entries shown (${allEntries.size} total).",
fontSize = 11.sp,
color = Color.Gray,
modifier = Modifier.weight(1f)
)
Row {
TextButton(onClick = { parseClipboard() }, enabled = !isLoading) {
Text("Paste Clipboard", fontSize = 11.sp, color = Color(0xFF339AF0))
}
TextButton(onClick = { parseScreen() }, enabled = !isLoading) {
Text("Parse Screen", fontSize = 11.sp, color = Color(0xFF51CF66))
}
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Table View
if (currentEntries.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
if (isLoading) "Loading entries from host..." else "No entries found.",
color = Color.Gray,
fontSize = 12.sp
)
}
} else {
val horizScroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.horizontalScroll(horizScroll)
) {
// Header Row
Row(
modifier = Modifier
.background(Color(0xFF25262B))
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (hostType == FTConfig.HostType.CMS) {
Text("Filename", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("Filetype", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(80.dp))
Text("Fm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Fmt", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Records", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Date / Time", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(140.dp))
} else {
Text("Dataset Name", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(240.dp))
Text("Volume", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(70.dp))
Text("Recfm", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text("Lrecl", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Blksize", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("Tracks", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text("Ext", color = Color.LightGray, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
// Entry Rows
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(currentEntries) { idx, item ->
val isSelected = (idx == selectedIndex)
Surface(
shape = RoundedCornerShape(4.dp),
color = if (isSelected) Color(0xFF1C7ED6).copy(alpha = 0.35f) else if (idx % 2 == 0) Color(0xFF161719) else Color(0xFF1E1F23),
modifier = Modifier
.fillMaxWidth()
.clickable { selectedIndex = idx }
) {
Row(
modifier = Modifier.padding(vertical = 5.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item is CMSDirectoryEntry) {
Text(item.filename ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(item.filetype ?: "", color = Color.Cyan, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(80.dp))
Text(item.filemode ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.numRecords > 0) "${item.numRecords}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text("${item.date ?: ""} ${item.time ?: ""}".trim(), color = Color.Gray, fontSize = 10.sp, modifier = Modifier.width(140.dp))
} else if (item is TSODirectoryEntry) {
Text(item.datasetName ?: "", color = Color.White, fontSize = 11.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.width(240.dp))
Text(item.volume ?: "", color = Color.Cyan, fontSize = 11.sp, modifier = Modifier.width(70.dp))
Text(item.recfm ?: "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(44.dp), textAlign = TextAlign.Center)
Text(if (item.lrecl > 0) "${item.lrecl}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.blksize > 0) "${item.blksize}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(60.dp), textAlign = TextAlign.Center)
Text(if (item.tracksAllocated > 0) "${item.tracksAllocated}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(50.dp), textAlign = TextAlign.Center)
Text(if (item.extents > 0) "${item.extents}" else "", color = Color.LightGray, fontSize = 11.sp, modifier = Modifier.width(36.dp), textAlign = TextAlign.Center)
}
}
}
}
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row {
if (hostType == FTConfig.HostType.TSO) {
OutlinedButton(
onClick = { inspectSelectedDataset() },
enabled = selectedIndex in currentEntries.indices && !isLoading,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)
) {
Text("Inspect (LISTDS)", fontSize = 11.sp)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(6.dp))
Button(
onClick = {
if (selectedIndex in currentEntries.indices) {
val entry = currentEntries[selectedIndex]
val selectedName = entry.name
onSelectEntry?.invoke(selectedName, entry)
onSelectDataset(selectedName)
onDismiss()
}
},
enabled = selectedIndex in currentEntries.indices,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Select Dataset")
}
}
}
}
}
}
}
@@ -0,0 +1,382 @@
package haus.nightmare.a3270.ui
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.util.Collections
import java.util.regex.Pattern
/**
* Real-time hotspot and URL auto-detection engine matching IBM Host On-Demand
* ShowURLOptions and ScreenText parity.
*
* Scans screen text using regular expressions for:
* 1. URLs (http://, https://, ftp://, file://, mailto:, www.)
* 2. Function keys (PF1..PF24, F1..F24, PA1..PA3, [ENTER], ENTER)
* 3. Menu choices ("1.", "2.", "===> 3", "=> 4")
*
* Provides hit-testing for touch/mouse taps and generates keystroke actions.
*/
class HotspotEngine {
enum class HotspotType {
URL,
PF_KEY,
ENTER,
MENU_CHOICE
}
data class Hotspot(
val type: HotspotType,
val startRow: Int,
val startCol: Int,
val endRow: Int,
val endCol: Int,
val displayText: String,
val action: String,
val tooltip: String
) {
val text: String get() = displayText
/**
* Test whether the given row/col coordinate falls within this hotspot.
*/
fun contains(row: Int, col: Int): Boolean {
if (row < startRow || row > endRow) {
return false
}
if (startRow == endRow) {
return col in startCol..endCol
}
if (row == startRow) {
return col >= startCol
}
if (row == endRow) {
return col <= endCol
}
return true // Intermediate rows
}
}
var urlEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var pfKeysEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
var menuChoicesEnabled: Boolean = true
set(value) {
field = value
invalidateCache()
}
companion object {
private val URL_PATTERN = Pattern.compile(
"(?:https?|ftp|file)://[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|www\\.[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+|mailto:[\\w\\-._~:/?#\\[\\]@!$&'*+,;=%]+",
Pattern.CASE_INSENSITIVE
)
private val PF_KEY_PATTERN = Pattern.compile(
"(?:(?<=\\W)|^)(PF(?:[1-9]|1[0-9]|2[0-4])|F(?:[1-9]|1[0-9]|2[0-4])|PA[1-3]|\\[ENTER\\]|ENTER)(?:(?=\\W)|$)",
Pattern.CASE_INSENSITIVE
)
private val MENU_NUMBER_PATTERN = Pattern.compile(
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
)
private val MENU_PROMPT_PATTERN = Pattern.compile(
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
)
private val ISPF_MENU_PATTERN = Pattern.compile(
"(?:^\\s*|\\s{3,})(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)"
)
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
arr[i] = true
}
}
private fun isRangeOccupied(arr: BooleanArray, start: Int, end: Int): Boolean {
val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size)
for (i in s until e) {
if (arr[i]) return true
}
return false
}
}
private var cachedScreenText: String = ""
private var cachedRows: Int = 0
private var cachedCols: Int = 0
private var cachedHotspots: List<Hotspot> = emptyList()
fun invalidateCache() {
synchronized(this) {
cachedScreenText = ""
cachedHotspots = emptyList()
}
}
/**
* Scan the given ScreenBuffer for interactive hotspots.
*/
fun scan(sb: ScreenBuffer?): List<Hotspot> {
if (sb == null) return emptyList()
val rows = sb.displayRows
val cols = sb.displayCols
val fullText = StringBuilder(rows * (cols + 1))
for (r in 0 until rows) {
for (c in 0 until cols) {
val baddr = r * cols + c
val ea = sb.getDisplayCell(baddr)
val ch = ea?.ucs4 ?: ' '
if (ea == null || ea.isFieldAttribute || ch <= ' ' || ch == '\u00FF') {
fullText.append(' ')
} else {
fullText.append(ch)
}
}
if (r < rows - 1) {
fullText.append('\n')
}
}
return scanText(fullText.toString(), rows, cols)
}
/**
* Scan formatted screen text (lines separated by '\n') for hotspots.
*/
fun scanText(screenText: String?, rows: Int, cols: Int, enabled: Boolean = true): List<Hotspot> {
if (!enabled || screenText == null || rows <= 0 || cols <= 0) {
return emptyList()
}
synchronized(this) {
if (screenText == cachedScreenText && rows == cachedRows && cols == cachedCols) {
return cachedHotspots
}
val hotspots = mutableListOf<Hotspot>()
val lines = screenText.split("\n")
val actualRows = minOf(rows, lines.size)
for (r in 0 until actualRows) {
val line = lines[r]
val occupied = BooleanArray(line.length + 1)
// 1. Scan URLs
if (urlEnabled) {
val mUrl = URL_PATTERN.matcher(line)
while (mUrl.find()) {
val startCol = mUrl.start()
var endCol = mUrl.end()
var match = mUrl.group()
while (match.length > 1) {
val last = match[match.length - 1]
if (last == '.' || last == ',' || last == ';' || last == ':' || last == ')' || last == ']' || last == '>') {
match = match.substring(0, match.length - 1)
endCol--
} else {
break
}
}
if (match.isEmpty()) continue
var actionUrl = match
if (match.startsWith("www.", ignoreCase = true)) {
actionUrl = "http://$match"
}
val h = Hotspot(
type = HotspotType.URL,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = match,
action = actionUrl,
tooltip = "Open URL: $actionUrl"
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 2. Scan PF / Function keys
if (pfKeysEnabled) {
val mPf = PF_KEY_PATTERN.matcher(line)
while (mPf.find()) {
val startCol = mPf.start(1)
val endCol = mPf.end(1)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val matchStr = mPf.group(1) ?: continue
val keyText = matchStr.uppercase()
val action: String
val tooltip: String
if (keyText == "ENTER" || keyText == "[ENTER]") {
action = "[enter]"
tooltip = "Send [ENTER]"
} else if (keyText.startsWith("PF")) {
val numStr = keyText.substring(2)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("F")) {
val numStr = keyText.substring(1)
action = "[pf$numStr]"
tooltip = "Send ${action.uppercase()}"
} else if (keyText.startsWith("PA")) {
val numStr = keyText.substring(2)
action = "[pa$numStr]"
tooltip = "Send ${action.uppercase()}"
} else {
continue
}
val h = Hotspot(
type = if (keyText.contains("ENTER")) HotspotType.ENTER else HotspotType.PF_KEY,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = matchStr,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
// 3. Scan Menu choices ("1.", "2.", "===> 3", "=> 4")
if (menuChoicesEnabled) {
val mNum = MENU_NUMBER_PATTERN.matcher(line)
while (mNum.find()) {
val startCol = mNum.start(1)
val endCol = mNum.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mNum.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Select option $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = "$choiceNum.",
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
val mPrompt = MENU_PROMPT_PATTERN.matcher(line)
while (mPrompt.find()) {
val startCol = mPrompt.start()
val endCol = mPrompt.end()
if (isRangeOccupied(occupied, startCol, endCol)) continue
val choiceNum = mPrompt.group(1)
val action = "$choiceNum[enter]"
val tooltip = "Execute choice $choiceNum"
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = mPrompt.group(),
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
// 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
val mIspf = ISPF_MENU_PATTERN.matcher(line)
while (mIspf.find()) {
val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue
val labelStr = mIspf.group(3) ?: continue
val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2)
val endCol = mIspf.end(3)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val action = "$choiceStr[enter]"
val tooltip = "Select option $choiceStr ($labelStr)"
val display = line.substring(startCol, endCol)
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = display,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
}
}
this.cachedScreenText = screenText
this.cachedRows = rows
this.cachedCols = cols
this.cachedHotspots = Collections.unmodifiableList(hotspots)
return cachedHotspots
}
}
/**
* Hit-test for a hotspot at the specified terminal row and column.
*/
fun findHotspot(row: Int, col: Int): Hotspot? {
synchronized(this) {
for (h in cachedHotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
fun getHotspots(): List<Hotspot> {
synchronized(this) {
return cachedHotspots
}
}
fun findHotspotAt(hotspots: List<Hotspot>, row: Int, col: Int): Hotspot? {
for (h in hotspots) {
if (h.contains(row, col)) {
return h
}
}
return null
}
}
@@ -0,0 +1,365 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.Telnet3270Client
import haus.nightmare.lib3270j.macro.Macro
import haus.nightmare.lib3270j.macro.MacroScreen
import haus.nightmare.lib3270j.macro.action.MacroAction
import haus.nightmare.lib3270j.macro.action.MacroActionPrompt
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicReference
private const val DEFAULT_MACRO_XML = """<HASCRIPT name="SampleLogon" timeout="15000" usevars="true">
<vars>
<create name="${'$'}userId" type="string" value="USER01" />
<create name="${'$'}count" type="integer" value="0" />
</vars>
<screen name="LogonScreen" entryscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<input value="${'$'}userId + '[enter]'" movecursor="true" xlatehostkeys="true" />
</actions>
<nextscreens timeout="10000">
<nextscreen name="ReadyScreen" />
</nextscreens>
</screen>
<screen name="ReadyScreen" exitscreen="true">
<description>
<oia status="NOTINHIBITED" />
</description>
<actions>
<message title="Success" value="Logon macro completed successfully!" />
</actions>
</screen>
</HASCRIPT>"""
data class MacroPromptState(
val prompt: MacroActionPrompt,
val deferred: CompletableDeferred<String?>
)
@Composable
fun MacroDialog(
client: Telnet3270Client?,
onDismiss: () -> Unit
) {
var macroXml by remember { mutableStateOf(DEFAULT_MACRO_XML) }
var executionLog by remember { mutableStateOf("") }
var statusText by remember { mutableStateOf("Status: Stopped") }
var isPlaying by remember { mutableStateOf(false) }
var isPaused by remember { mutableStateOf(false) }
var activePrompt by remember { mutableStateOf<MacroPromptState?>(null) }
val coroutineScope = rememberCoroutineScope()
val macroRef = remember { AtomicReference<Macro?>(null) }
fun appendLog(msg: String) {
val line = "[${java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US).format(java.util.Date())}] $msg\n"
executionLog += line
}
DisposableEffect(client) {
val macro = if (client != null) Macro(client) else null
macroRef.set(macro)
if (macro != null) {
val listener = object : Macro.MacroListener {
override fun macroStateChanged(m: Macro, oldState: Int, newState: Int) {
when (newState) {
Macro.STATE_PLAYING -> {
isPlaying = true
isPaused = false
statusText = "Status: Playing"
appendLog("Macro playing...")
}
Macro.STATE_PAUSED -> {
isPaused = true
statusText = "Status: Paused"
appendLog("Macro paused.")
}
Macro.STATE_ERROR -> {
isPlaying = false
isPaused = false
statusText = "Status: Error"
appendLog("Macro error.")
}
Macro.STATE_STOPPED -> {
isPlaying = false
isPaused = false
statusText = "Status: Stopped"
appendLog("Macro stopped.")
}
}
}
override fun macroScreenMatching(screen: MacroScreen) {
appendLog("Matched screen: [${screen.name}]")
}
override fun macroActionExecuting(action: MacroAction) {
appendLog(" -> Executing: ${action.javaClass.simpleName}")
}
override fun macroMessage(title: String, message: String) {
appendLog("MESSAGE [$title]: $message")
}
override fun macroError(error: String) {
isPlaying = false
isPaused = false
statusText = "Status: Error - $error"
appendLog("ERROR: $error")
}
}
macro.addMacroListener(listener)
macro.setPromptHandler { prompt ->
val deferred = CompletableDeferred<String?>()
activePrompt = MacroPromptState(prompt, deferred)
kotlinx.coroutines.runBlocking {
deferred.await() ?: ""
}
}
}
onDispose {
macro?.stop()
macroRef.set(null)
}
}
Dialog(
onDismissRequest = {
if (!isPlaying) onDismiss()
},
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.90f)
.padding(vertical = 10.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"XML Macro Runtime (<HASCRIPT>)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
statusText,
fontSize = 11.sp,
color = if (isPlaying) Color(0xFF51CF66) else Color.LightGray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Macro XML Editor Pane
Text("Macro Definition XML:", fontSize = 12.sp, color = Color.LightGray)
OutlinedTextField(
value = macroXml,
onValueChange = { macroXml = it },
textStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color.White
),
modifier = Modifier
.fillMaxWidth()
.weight(0.55f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF339AF0),
unfocusedBorderColor = Color(0xFF495057),
focusedContainerColor = Color(0xFF121212),
unfocusedContainerColor = Color(0xFF121212)
)
)
// Execution Log Pane
Text("Execution Log:", fontSize = 12.sp, color = Color.LightGray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.45f)
.background(Color(0xFF0F0F0F), RoundedCornerShape(6.dp))
.padding(8.dp)
) {
val logScroll = rememberScrollState()
Text(
text = executionLog.ifBlank { "Ready to execute macro..." },
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = if (executionLog.isBlank()) Color.Gray else Color(0xFF69DB7C),
modifier = Modifier
.fillMaxSize()
.verticalScroll(logScroll)
)
}
// Control Buttons: Play, Pause, Stop, Close
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val screens = haus.nightmare.lib3270j.macro.MacroXmlParser.parse(macroXml)
macro.setScreens(screens)
macro.setClient(client)
macro.play()
} catch (ex: Exception) {
appendLog("Execution exception: ${ex.message}")
}
}
}
},
enabled = !isPlaying && client != null && client.connectionState?.isFullSession == true,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E)),
modifier = Modifier.weight(1f)
) {
Text("▶ Play", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
if (macro != null) {
if (macro.state == Macro.STATE_PLAYING) {
macro.pause()
} else if (macro.state == Macro.STATE_PAUSED) {
macro.resume()
}
}
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67700)),
modifier = Modifier.weight(1f)
) {
Text(if (isPaused) "Resume" else "Pause", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
},
enabled = isPlaying,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.weight(1f)
) {
Text("■ Stop", fontSize = 12.sp)
}
Button(
onClick = {
val macro = macroRef.get()
macro?.stop()
onDismiss()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(75.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
// Modal prompt dialog when macro requests <prompt> parameter input
val promptState = activePrompt
if (promptState != null) {
var inputValue by remember { mutableStateOf(promptState.prompt.defaultValue ?: "") }
AlertDialog(
onDismissRequest = {
promptState.deferred.complete(null)
activePrompt = null
},
title = {
Text(
text = promptState.prompt.title.ifBlank { "Macro Input Prompt" },
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = promptState.prompt.description.ifBlank { "Enter parameter:" },
fontSize = 13.sp,
color = Color.LightGray
)
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
promptState.deferred.complete(inputValue)
activePrompt = null
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(
onClick = {
promptState.deferred.complete(null)
activePrompt = null
}
) {
Text("Cancel")
}
}
)
}
}
@@ -0,0 +1,343 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import haus.nightmare.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
isLightPenMode: Boolean = false,
isInsertMode: Boolean = false,
isDocMode: Boolean = false,
isWordWrap: Boolean = false,
isAplMode: Boolean = false,
onToggleInsert: (() -> Unit)? = null,
onToggleDocMode: (() -> Unit)? = null,
onToggleWordWrap: (() -> Unit)? = null,
onToggleAplMode: (() -> Unit)? = null,
inhibitReason: Int = 0,
luName: String = "",
isNumericField: Boolean = false,
fieldStatus: String = "",
uiTheme: String = "DARK",
modifier: Modifier = Modifier
) {
val isLight = uiTheme.equals("LIGHT", ignoreCase = true)
val barBg = if (isLight) Color(0xFFE9ECEF) else Color(0xFF161719)
val defaultTextClr = if (isLight) Color(0xFF212529) else Color.White
val sysAvailClr = if (isLight) Color(0xFF005AC8) else Color(0xFF7890F0)
val inputInhibitedClr = if (isLight) Color(0xFF141414) else Color(0xFFFFFFFF)
val attentionClr = if (isLight) Color(0xFFB46400) else Color(0xFFFFFF00)
val commCheckClr = if (isLight) Color(0xFFBE1414) else Color(0xFFFF0000)
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("%03d/%03d [%04d]", row, col, cursorAddr)
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val connTypeBadge = when (connectionState) {
ConnectionState.CONNECTED_3270 -> "TN3270"
ConnectionState.CONNECTED_TN3270E -> "TN3270E"
ConnectionState.CONNECTED_SSCP -> "SSCP-LU"
ConnectionState.CONNECTED_NVT, ConnectionState.CONNECTED_NVT_CHAR, ConnectionState.CONNECTED_E_NVT -> "NVT"
ConnectionState.CONNECTED_UNBOUND -> "UNBOUND"
ConnectionState.TCP_PENDING, ConnectionState.TELNET_PENDING -> "CONNECTING"
ConnectionState.NOT_CONNECTED -> "OFFLINE"
else -> connectionState.name
}
val (lockStatusText, lockStatusColor) = if (!connectionState.isConnected()) {
"OFFLINE" to Color(0xFF868E96)
} else if (inhibitReason != 0) {
val txt = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_SYSTEM_LOCK -> "X SYSTEM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> "X COMM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> "X NUM"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_PROTECTED_FIELD -> "X PROT"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> "X >"
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> "X OP"
else -> "X LOCKED"
}
val clr = when (inhibitReason) {
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_COMM_CHECK -> commCheckClr
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OPERATOR_DUE -> attentionClr
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_NUMERIC_ONLY -> attentionClr
haus.nightmare.lib3270j.ecl.ECLConstants.INHIBIT_OVERFLOW -> attentionClr
else -> inputInhibitedClr
}
txt to clr
} else if (isKeyboardLocked) {
"X SYSTEM" to inputInhibitedClr
} else {
"READY" to sysAvailClr
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(barBg)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF7890F0) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = defaultTextClr,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
// TLS Badge
if (connectionState.isConnected() && isTls) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// GFX Mode Badge
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (graphicsMode) {
"VECTOR_GRAPHICS" -> "GOCA"
"PROGRAMMED_SYMBOLS" -> "PS"
else -> "GFX"
},
color = Color(0xFF22B8CF),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Field Status: DBCS vs NUM vs ALPHA
if (connectionState.isConnected() && rows > 0 && cols > 0) {
val effectiveFieldStatus = if (fieldStatus.isNotBlank()) fieldStatus else (if (isNumericField) "NUM" else "ALPHA")
val badgeColor = when (effectiveFieldStatus) {
"DBCS" -> sysAvailClr
"NUM" -> Color(0xFFFFD43B)
else -> Color(0xFF74C0FC)
}
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = badgeColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = effectiveFieldStatus,
color = badgeColor,
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Light Pen Badge
if (isLightPenMode) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFFFFFF50).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LP",
color = Color(0xFFFFFF50),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// Connection Type Badge (TN3270, TN3270E, SSCP, NVT)
if (connectionState.isConnected()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF7950F2).copy(alpha = 0.25f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = connTypeBadge,
color = Color(0xFFB197FC),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// LU Name Badge
if (connectionState.isConnected() && luName.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF40C057).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "LU:$luName",
color = Color(0xFF69DB7C),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
// Operational Mode Badges (^ INS, APL, DOC, V)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 2.dp)
) {
if (connectionState.isConnected() && isInsertMode) {
Surface(
color = Color(0xFF7890F0).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.then(if (onToggleInsert != null) Modifier.clickable { onToggleInsert() } else Modifier)
) {
Text(
text = "^ INS",
color = Color(0xFF7890F0),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
if (connectionState.isConnected() && isAplMode) {
Surface(
color = Color(0xFFFF922B).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.then(if (onToggleAplMode != null) Modifier.clickable { onToggleAplMode() } else Modifier)
) {
Text(
text = "APL",
color = Color(0xFFFFA94D),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
if (connectionState.isConnected() && isDocMode) {
Surface(
color = Color(0xFF20C997).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.then(if (onToggleDocMode != null) Modifier.clickable { onToggleDocMode() } else Modifier)
) {
Text(
text = "DOC",
color = Color(0xFF38D9A9),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
if (connectionState.isConnected() && isWordWrap) {
Surface(
color = Color(0xFF20C997).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.then(if (onToggleWordWrap != null) Modifier.clickable { onToggleWordWrap() } else Modifier)
) {
Text(
text = "V",
color = Color(0xFF38D9A9),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position & Buffer Address
Text(
text = posStr,
color = if (isLight) Color(0xFF0C8599) else Color(0xFF22B8CF),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -0,0 +1,490 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.os.Environment
import android.print.PrintAttributes
import android.print.PrintDocumentAdapter
import android.print.PrintManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.printer.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun PrinterSessionDialog(
initialHost: String = "",
initialPort: Int = 0,
initialCodePage: String = "037",
initialUseTls: Boolean = false,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var host by remember { mutableStateOf(initialHost) }
var portStr by remember { mutableStateOf(if (initialPort > 0) initialPort.toString() else "") }
var printerLu by remember { mutableStateOf("") }
var assocDisplayLu by remember { mutableStateOf("") }
var codePage by remember { mutableStateOf(initialCodePage) }
var useTls by remember { mutableStateOf(initialUseTls) }
var pdtType by remember { mutableStateOf("DEFAULT") }
var isConnected by remember { mutableStateOf(false) }
var sessionStatus by remember { mutableStateOf("Disconnected") }
var spoolText by remember { mutableStateOf("") }
var pageCount by remember { mutableIntStateOf(0) }
var byteCount by remember { mutableIntStateOf(0) }
var printerClient by remember { mutableStateOf<Telnet3270EPClient?>(null) }
val codePagesList = haus.nightmare.a3270.storage.HostStorage.AVAILABLE_CODE_PAGES
fun startSession() {
val targetHost = host.trim().ifBlank { "127.0.0.1" }
val port = portStr.trim().toIntOrNull() ?: if (useTls) 992 else 23
val config = PrinterConfig(targetHost, port).apply {
this.isUseTls = useTls
this.codePage = codePage
if (printerLu.isNotBlank()) this.printerLuName = printerLu.trim()
if (assocDisplayLu.isNotBlank()) this.associatedDisplayLuName = assocDisplayLu.trim()
this.destinationType = PrinterConfig.DestinationType.MEMORY
val pdt = when (pdtType) {
"PCL5" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPcl5PDT()
"EPSON_ESC_P" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createEpsonEscPPDT()
"POSTSCRIPT" -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPostScriptPDT()
else -> haus.nightmare.lib3270j.printer.PrinterDefinitionTable.createPlainTextPDT()
}
if (pdt != null) {
this.printerDefinitionTable = pdt
}
}
val client = Telnet3270EPClient(config)
client.addPrintListener(object : PrintSessionListener {
override fun onPrintJobStarted(event: PrintSessionEvent) {
sessionStatus = "Printing job: ${event.jobName ?: "Job"}"
}
override fun onPrintJobData(event: PrintSessionEvent) {
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
byteCount = event.byteCount.toInt()
}
override fun onPrintJobPageComplete(event: PrintSessionEvent) {
pageCount = event.pageNumber
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
}
override fun onPrintJobComplete(event: PrintSessionEvent) {
pageCount = event.totalPages
byteCount = event.byteCount.toInt()
val captured = client.pd?.capturedText
if (captured != null) spoolText = captured
sessionStatus = "Print Job Complete"
}
override fun onPrinterStatusChanged(event: PrintSessionEvent) {
val status = event.statusCode
isConnected = (status == PrinterConstants.STATUS_CONNECTED || status == PrinterConstants.STATUS_PRINTING)
sessionStatus = when (status) {
PrinterConstants.STATUS_CONNECTED -> "Connected"
PrinterConstants.STATUS_CONNECTING -> "Connecting..."
PrinterConstants.STATUS_PRINTING -> "Printing..."
else -> "Disconnected"
}
}
override fun onPrinterError(event: PrintSessionEvent) {
sessionStatus = "Error: ${event.message ?: "Code ${event.statusCode}"}"
}
})
printerClient = client
sessionStatus = "Connecting..."
val ok = client.connect()
if (!ok) {
sessionStatus = "Failed to initiate printer connection."
}
}
fun stopSession() {
printerClient?.disconnect()
printerClient = null
isConnected = false
sessionStatus = "Disconnected"
}
DisposableEffect(Unit) {
onDispose {
printerClient?.disconnect()
printerClient = null
}
}
fun printViaAndroidService() {
if (spoolText.isBlank()) {
Toast.makeText(context, "No printer spool to print.", Toast.LENGTH_SHORT).show()
return
}
try {
val printManager = context.getSystemService(Context.PRINT_SERVICE) as? PrintManager
if (printManager != null) {
val webView = WebView(context)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
val printAdapter: PrintDocumentAdapter = webView.createPrintDocumentAdapter("3287_Print_Job")
printManager.print("3287 Print Job", printAdapter, PrintAttributes.Builder().build())
}
}
val htmlDoc = "<html><body style='font-family:monospace; font-size:12px; white-space:pre;'>${spoolText.replace("<", "&lt;").replace(">", "&gt;")}</body></html>"
webView.loadDataWithBaseURL(null, htmlDoc, "text/html", "UTF-8", null)
}
} catch (e: Exception) {
Toast.makeText(context, "Print failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun saveSpoolToFile() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(downloadsDir, "3287_spool_$timeStamp.txt")
file.writeText(spoolText)
Toast.makeText(context, "Saved to Downloads/${file.name}", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
fun shareSpool() {
if (spoolText.isBlank()) {
Toast.makeText(context, "Spool is empty.", Toast.LENGTH_SHORT).show()
return
}
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3287 Printer Spool")
putExtra(Intent.EXTRA_TEXT, spoolText)
}
context.startActivity(Intent.createChooser(intent, "Share 3287 Spool"))
} catch (e: Exception) {
Toast.makeText(context, "Share failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 700.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"IBM 3287 Printer Session",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"TN3270E SCS / 3270 DS Print Emulation",
fontSize = 11.sp,
color = Color.Gray
)
}
Surface(
color = if (isConnected) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFF6B6B).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = sessionStatus,
color = if (isConnected) Color(0xFF51CF66) else Color(0xFFFF6B6B),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Config Row 1: Host & Port
if (!isConnected) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
placeholder = { Text("127.0.0.1", color = Color.Gray) },
singleLine = true,
modifier = Modifier.weight(2.5f)
)
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port") },
placeholder = { Text(if (useTls) "992" else "23", color = Color.Gray) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
}
// Config Row 2: Printer LU & Assoc Display LU
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
OutlinedTextField(
value = printerLu,
onValueChange = { printerLu = it },
label = { Text("Printer LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = assocDisplayLu,
onValueChange = { assocDisplayLu = it },
label = { Text("Display LU (Opt)") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
// PDT, Code Page & TLS Row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
var pdtExpanded by remember { mutableStateOf(false) }
val pdtList = listOf(
"DEFAULT" to "Plain Text",
"PCL5" to "HP PCL 5/6",
"EPSON_ESC_P" to "Epson ESC/P",
"POSTSCRIPT" to "PostScript"
)
val selectedPdtLabel = pdtList.firstOrNull { it.first == pdtType }?.second ?: "PDT"
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { pdtExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("PDT: $selectedPdtLabel", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = pdtExpanded,
onDismissRequest = { pdtExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
pdtList.forEach { (type, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
pdtType = type
pdtExpanded = false
}
)
}
}
}
var cpExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
OutlinedButton(
onClick = { cpExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("CP$codePage", fontSize = 10.sp, maxLines = 1)
}
DropdownMenu(
expanded = cpExpanded,
onDismissRequest = { cpExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30)).heightIn(max = 240.dp)
) {
codePagesList.forEach { (cpId, label) ->
DropdownMenuItem(
text = { Text(label, fontSize = 12.sp, color = Color.White) },
onClick = {
codePage = cpId
cpExpanded = false
}
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { useTls = !useTls }
) {
Checkbox(checked = useTls, onCheckedChange = { useTls = it })
Text("TLS", fontSize = 11.sp, color = Color.White)
}
Button(
onClick = { startSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Connect", fontSize = 11.sp)
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Pages: $pageCount | Bytes: $byteCount",
fontSize = 12.sp,
color = Color.LightGray,
fontFamily = FontFamily.Monospace
)
Button(
onClick = { stopSession() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A))
) {
Text("Disconnect Printer")
}
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Spool Display Box
Text("Print Spool Buffer:", fontSize = 12.sp, color = Color.Gray)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (spoolText.isBlank()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No printed output spooled yet.", color = Color.DarkGray, fontSize = 12.sp)
}
} else {
Text(
text = spoolText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
// Spool Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = {
spoolText = ""
pageCount = 0
byteCount = 0
printerClient?.pd?.resetCapture()
}
) {
Text("Clear", fontSize = 11.sp)
}
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(spoolText))
Toast.makeText(context, "Copied spool to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 11.sp)
}
Button(
onClick = { saveSpoolToFile() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save", fontSize = 11.sp)
}
Button(
onClick = { printViaAndroidService() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Print 🖨️", fontSize = 11.sp)
}
}
}
}
}
}
}
@@ -0,0 +1,533 @@
package haus.nightmare.a3270.ui
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.FileProvider
import haus.nightmare.lib3270j.graphics.GraphicsPlane
import haus.nightmare.lib3270j.graphics.ProgramSymbolManager
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenExporterDialog(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager? = null,
graphicsPlane: GraphicsPlane? = null,
maskHidden: Boolean = true,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var exportFormat by remember { mutableStateOf("TEXT") } // TEXT, HTML, PNG
var statusMessage by remember { mutableStateOf("") }
val plainTextContent = remember(screenBuffer) {
generatePlainText(screenBuffer, rows, cols, maskHidden)
}
val htmlContent = remember(screenBuffer) {
generateHtml(screenBuffer, rows, cols, maskHidden)
}
fun shareText(text: String, isHtml: Boolean) {
try {
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (isHtml) "text/html" else "text/plain"
putExtra(Intent.EXTRA_SUBJECT, "3270 Screen Capture")
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(Intent.createChooser(intent, "Share 3270 Screen"))
} catch (e: Exception) {
statusMessage = "Share failed: ${e.message}"
}
}
fun sharePng(bmp: Bitmap) {
try {
val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() }
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val file = File(cacheDir, "screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
val uri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share Screen PNG"))
} catch (e: Exception) {
statusMessage = "PNG Share failed: ${e.message}"
}
}
fun saveFileToStorage(format: String): String {
return try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
when (format) {
"HTML" -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.html")
file.writeText(htmlContent)
"Saved to Downloads/${file.name}"
}
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
val file = File(downloadsDir, "3270_screen_$timeStamp.png")
FileOutputStream(file).use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
"Saved to Downloads/${file.name}"
}
else -> {
val file = File(downloadsDir, "3270_screen_$timeStamp.txt")
file.writeText(plainTextContent)
"Saved to Downloads/${file.name}"
}
}
} catch (e: Exception) {
"Save failed: ${e.message}"
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Export / Share Screen",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Row {
FilterChip(
selected = (exportFormat == "TEXT"),
onClick = { exportFormat = "TEXT" },
label = { Text("Plain Text", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "HTML"),
onClick = { exportFormat = "HTML" },
label = { Text("HTML", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = (exportFormat == "PNG"),
onClick = { exportFormat = "PNG" },
label = { Text("PNG", fontSize = 11.sp) }
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Preview Box
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFF141517), shape = RoundedCornerShape(8.dp))
.padding(10.dp)
) {
if (exportFormat == "PNG") {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("🖼️ PNG Image Export", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(6.dp))
Text("Full resolution bitmap with 3270 styling and graphics plane.", fontSize = 12.sp, color = Color.LightGray)
}
} else {
val textToShow = if (exportFormat == "HTML") htmlContent else plainTextContent
Text(
text = textToShow,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = Color(0xFFA5D6A7),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
)
}
}
if (statusMessage.isNotBlank()) {
Text(
text = statusMessage,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (exportFormat != "PNG") {
OutlinedButton(
onClick = {
val content = if (exportFormat == "HTML") htmlContent else plainTextContent
clipboardManager.setText(AnnotatedString(content))
statusMessage = "Copied to clipboard."
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", fontSize = 12.sp)
}
}
Button(
onClick = {
val msg = saveFileToStorage(exportFormat)
statusMessage = msg
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1C7ED6))
) {
Text("Save File", fontSize = 12.sp)
}
Button(
onClick = {
when (exportFormat) {
"PNG" -> {
val bmp = generateBitmap(screenBuffer, rows, cols, programSymbolManager, graphicsPlane, maskHidden)
sharePng(bmp)
}
"HTML" -> shareText(htmlContent, true)
else -> shareText(plainTextContent, false)
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Share 📤", fontSize = 12.sp)
}
}
}
}
}
}
}
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return ""
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder()
var currentHidden = false
for (r in 0 until rows) {
val rowSb = StringBuilder()
for (c in 0 until cols) {
val addr = r * cols + c
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
rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else rowSb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
rowSb.append(' ')
} else {
if (currentHidden && maskHidden) {
rowSb.append(' ')
} else {
val ch = cell.ucs4
rowSb.append(if (ch in ' '..'~' || ch > '\u007F') ch else ' ')
}
}
}
sb.append(rowSb.toString().trimEnd()).append("\n")
}
return sb.toString()
}
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n")
sb.append("<style>\n")
sb.append("body { background-color: #0A0A0A; color: #00FF00; font-family: monospace; font-size: 14px; margin: 20px; }\n")
sb.append("pre { line-height: 1.2; font-family: monospace; }\n")
sb.append(".c-green { color: #00FF00; }\n")
sb.append(".c-white { color: #FFFFFF; }\n")
sb.append(".c-blue { color: #7890F0; }\n")
sb.append(".c-red { color: #FF0000; }\n")
sb.append(".c-yellow { color: #FFFF00; }\n")
sb.append(".c-turquoise { color: #00FFFF; }\n")
sb.append(".c-pink { color: #FF00FF; }\n")
sb.append(".c-orange { color: #FFA200; }\n")
sb.append(".c-mustard { color: #A0A000; }\n")
sb.append(".c-grey { color: #C0C0C0; }\n")
sb.append("</style>\n</head>\n<body>\n<pre>\n")
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
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
currentFieldEa = cell
sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_RIGHT) {
if (c > 0) continue else sb.append(' ')
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
sb.append(' ')
} else {
if (currentHidden && maskHidden) {
sb.append(' ')
} else {
val ch = cell.ucs4
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val colorClass = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> "c-blue"
2 -> "c-red"
3 -> "c-pink"
4 -> "c-green"
5 -> "c-turquoise"
6 -> "c-yellow"
7 -> "c-white"
9 -> "c-blue"
10 -> "c-orange"
11 -> "c-pink"
12 -> "c-green"
13 -> "c-turquoise"
14 -> "c-mustard"
15 -> "c-grey"
else -> "c-green"
}
} else {
when {
currentProt && currentHi -> "c-white"
currentProt && !currentHi -> "c-turquoise"
!currentProt && currentHi -> "c-red"
else -> "c-green"
}
}
val escChar = when (ch) {
'<' -> "&lt;"
'>' -> "&gt;"
'&' -> "&amp;"
'"' -> "&quot;"
else -> if (ch in ' '..'~' || ch > '\u007F') ch.toString() else " "
}
sb.append("<span class=\"$colorClass\">$escChar</span>")
}
}
}
sb.append("\n")
}
sb.append("</pre>\n</body>\n</html>")
return sb.toString()
}
private fun generateBitmap(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
programSymbolManager: ProgramSymbolManager?,
graphicsPlane: GraphicsPlane?,
maskHidden: Boolean
): Bitmap {
val charWidth = 14
val charHeight = 24
val width = cols * charWidth
val height = rows * charHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Fill background
val bgPaint = Paint().apply {
color = android.graphics.Color.parseColor("#0A0A0A")
style = Paint.Style.FILL
}
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()) {
val rgb = graphicsPlane.rgbBuffer
val gWidth = graphicsPlane.canvasWidth
val gHeight = graphicsPlane.canvasHeight
if (rgb != null && gWidth > 0 && gHeight > 0) {
val gfxBmp = Bitmap.createBitmap(rgb, gWidth, gHeight, Bitmap.Config.ARGB_8888)
val scaledGfx = Bitmap.createScaledBitmap(gfxBmp, width, height, true)
canvas.drawBitmap(scaledGfx, 0f, 0f, null)
}
}
val textPaint = Paint().apply {
isAntiAlias = true
typeface = Typeface.MONOSPACE
textSize = 18f
}
var currentHidden = false
var currentProt = false
var currentHi = false
var currentFieldEa: haus.nightmare.lib3270j.screen.ExtendedAttribute? = null
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
val cell = screenBuffer.getDisplayCell(addr) ?: continue
val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat()
if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF
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
currentFieldEa = cell
} else {
val csVal = cell.cs.toInt() and 0xFF
val ecVal = cell.ec.toInt() and 0xFF
val fgByte = if (cell.fg != 0.toByte()) (cell.fg.toInt() and 0xFF)
else (currentFieldEa?.fg?.toInt()?.and(0xFF) ?: 0)
val cellColor = if (fgByte in 0xf0..0xff) {
when (fgByte - 0xf0) {
1 -> android.graphics.Color.parseColor("#7890F0")
2 -> android.graphics.Color.parseColor("#FF0000")
3 -> android.graphics.Color.parseColor("#FF00FF")
4 -> android.graphics.Color.parseColor("#00FF00")
5 -> android.graphics.Color.parseColor("#00FFFF")
6 -> android.graphics.Color.parseColor("#FFFF00")
7 -> android.graphics.Color.WHITE
9 -> android.graphics.Color.parseColor("#000080")
10 -> android.graphics.Color.parseColor("#FFA200")
11 -> android.graphics.Color.parseColor("#800080")
12 -> android.graphics.Color.parseColor("#008000")
13 -> android.graphics.Color.parseColor("#008080")
14 -> android.graphics.Color.parseColor("#A0A000")
15 -> android.graphics.Color.parseColor("#C0C0C0")
else -> android.graphics.Color.parseColor("#00FF00")
}
} else {
when {
currentProt && currentHi -> android.graphics.Color.WHITE
currentProt && !currentHi -> android.graphics.Color.parseColor("#00FFFF")
!currentProt && currentHi -> android.graphics.Color.parseColor("#FF0000")
else -> android.graphics.Color.parseColor("#00FF00")
}
}
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 bgArgb = android.graphics.Color.TRANSPARENT
val rgbArray = slot.getRgbPixels(cellColor, 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 = cellColor
canvas.drawText(ch.toString(), left + 2f, top + 18f, textPaint)
}
}
}
}
}
return bitmap
}
@@ -0,0 +1,248 @@
package haus.nightmare.a3270.ui
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer.ScreenSnapshot
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ScreenHistoryDialog(
historyBuffer: ScreenHistoryBuffer?,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) }
var historySize by remember { mutableIntStateOf(historyBuffer?.size() ?: 0) }
var selectedIndex by remember { mutableIntStateOf((historyBuffer?.size() ?: 1) - 1) }
// Listen for live updates if host writes new screens
DisposableEffect(historyBuffer) {
if (historyBuffer == null) return@DisposableEffect onDispose {}
val listener = ScreenHistoryBuffer.ScreenHistoryListener { buf, _ ->
val newSize = buf.size()
historySize = newSize
if (selectedIndex >= newSize - 2) {
selectedIndex = newSize - 1
}
}
historyBuffer.addListener(listener)
historySize = historyBuffer.size()
if (historySize > 0 && selectedIndex < 0) {
selectedIndex = historySize - 1
}
onDispose {
historyBuffer.removeListener(listener)
}
}
val currentSnapshot: ScreenSnapshot? = remember(historyBuffer, historySize, selectedIndex) {
if (historyBuffer != null && historySize > 0 && selectedIndex in 0 until historySize) {
try {
historyBuffer.get(selectedIndex)
} catch (_: Exception) {
null
}
} else {
null
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f)
.padding(vertical = 12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Top Header: Title & Info
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Screen History Buffer",
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
if (currentSnapshot != null) {
Text(
dateFormat.format(Date(currentSnapshot.timestamp)),
fontSize = 12.sp,
color = Color.LightGray
)
}
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Scrub navigation slider & buttons
if (historySize > 0 && currentSnapshot != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { if (selectedIndex > 0) selectedIndex-- },
enabled = selectedIndex > 0,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("◄ Prev", fontSize = 12.sp)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Screen ${selectedIndex + 1} of $historySize",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
if (historySize > 1) {
Slider(
value = (selectedIndex + 1).toFloat(),
onValueChange = { selectedIndex = (it.toInt() - 1).coerceIn(0, historySize - 1) },
valueRange = 1f..historySize.toFloat(),
steps = if (historySize > 2) historySize - 2 else 0,
modifier = Modifier.fillMaxWidth().height(24.dp)
)
}
}
Button(
onClick = { if (selectedIndex < historySize - 1) selectedIndex++ },
enabled = selectedIndex < historySize - 1,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF343A40)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
modifier = Modifier.height(36.dp)
) {
Text("Next ►", fontSize = 12.sp)
}
}
}
// Terminal Preview Pane
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
if (currentSnapshot != null) {
val verticalScroll = rememberScrollState()
val horizontalScroll = rememberScrollState()
Text(
text = currentSnapshot.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF00FF41), // Classic 3270 Green Phosphor
lineHeight = 14.sp,
modifier = Modifier
.fillMaxSize()
.verticalScroll(verticalScroll)
.horizontalScroll(horizontalScroll)
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"No history frames captured yet.",
color = Color.Gray,
fontSize = 14.sp
)
}
}
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.text))
Toast.makeText(context, "Full screen copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Screen", fontSize = 12.sp)
}
Button(
onClick = {
if (currentSnapshot != null) {
clipboardManager.setText(AnnotatedString(currentSnapshot.formattedTable))
Toast.makeText(context, "Table text copied to clipboard", Toast.LENGTH_SHORT).show()
}
},
enabled = currentSnapshot != null,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C2D30)),
modifier = Modifier.weight(1f)
) {
Text("Copy Table", fontSize = 12.sp)
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF495057)),
modifier = Modifier.width(80.dp)
) {
Text("Close", fontSize = 12.sp)
}
}
}
}
}
}
@@ -0,0 +1,211 @@
package haus.nightmare.a3270.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun ScriptDialog(
initialScript: String = "",
onDismiss: () -> Unit,
onExecuteScript: (script: String, onStatus: (String) -> Unit, onComplete: (String?) -> Unit) -> Unit
) {
var scriptText by remember { mutableStateOf(initialScript.ifBlank { "# Sample 3270 Script\nLOGON APPLID(TSO)[enter]\n[wait]\nUSERID[tab]PASSWORD[enter]" }) }
var isExecuting by remember { mutableStateOf(false) }
var executionStatus by remember { mutableStateOf("") }
var executionError by remember { mutableStateOf<String?>(null) }
fun insertToken(token: String) {
scriptText += token
}
Dialog(
onDismissRequest = { if (!isExecuting) onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Run Keystroke Script / Macro",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
"ECL Mnemonic Tokens",
fontSize = 11.sp,
color = Color.Gray
)
}
HorizontalDivider(color = Color(0xFF2C2D30))
// Mnemonic Quick Insert Chips Row 1
Text("Insert Mnemonic Keys:", fontSize = 11.sp, color = Color.LightGray)
val tokenScroll1 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll1),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
listOf(
"[enter]", "[tab]", "[backtab]", "[home]", "[clear]",
"[reset]", "[eraseeof]", "[eraseinpt]", "[attn]", "[sysreq]",
"[wordleft]", "[wordright]", "[fieldend]", "[deleteword]", "[cursel]", "[lightpen]"
).forEach { tok ->
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text(tok, fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Mnemonic Quick Insert Chips Row 2 (PF Keys)
val tokenScroll2 = rememberScrollState()
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(tokenScroll2),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
(1..24).forEach { pf ->
val tok = "[pf$pf]"
SuggestionChip(
onClick = { insertToken(tok) },
label = { Text("PF$pf", fontSize = 10.sp, fontFamily = FontFamily.Monospace) }
)
}
}
// Script Editor Box
OutlinedTextField(
value = scriptText,
onValueChange = { scriptText = it },
label = { Text("Script Text / Mnemonic Commands") },
placeholder = { Text("Type characters or tokens like [enter], [pf3], etc.") },
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false,
keyboardType = KeyboardType.Ascii
),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)
)
// Status or Error
if (isExecuting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = executionStatus.ifBlank { "Executing script..." },
fontSize = 12.sp,
color = Color(0xFF339AF0)
)
}
} else if (executionError != null) {
Text(
text = "Error: $executionError",
fontSize = 12.sp,
color = Color(0xFFFF6B6B)
)
} else if (executionStatus.isNotBlank()) {
Text(
text = executionStatus,
fontSize = 12.sp,
color = Color(0xFF51CF66)
)
}
// Bottom Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = onDismiss,
enabled = !isExecuting
) {
Text("Close", color = Color.LightGray)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = { scriptText = "" },
enabled = !isExecuting
) {
Text("Clear")
}
Button(
onClick = {
if (scriptText.isNotBlank()) {
isExecuting = true
executionError = null
executionStatus = "Starting..."
onExecuteScript(
scriptText,
{ status -> executionStatus = status },
{ error ->
isExecuting = false
if (error != null) {
executionError = error
} else {
executionStatus = "Script completed successfully."
}
}
)
}
},
enabled = !isExecuting && scriptText.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Execute Script ▶")
}
}
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import android.content.Context
import android.text.InputType
@@ -8,7 +8,7 @@ import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import haus.nightmare.lib3270j.protocol.DS3270Constants.AID_ENTER
/**
* Custom InputConnection for Termux-style terminal keyboard interaction.
@@ -88,6 +88,14 @@ class TerminalInputView(context: Context) : View(context) {
var onBackspace: () -> Unit = {}
var onTab: () -> Unit = {}
var onBackTab: () -> Unit = {}
var isDbcsSession: Boolean = false
set(value) {
if (field != value) {
field = value
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.restartInput(this)
}
}
init {
isFocusable = true
@@ -97,9 +105,13 @@ class TerminalInputView(context: Context) : View(context) {
override fun onCheckIsTextEditor(): Boolean = true
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
outAttrs.inputType = if (isDbcsSession) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
} else {
InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
}
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_ACTION_NONE
@@ -111,4 +123,10 @@ class TerminalInputView(context: Context) : View(context) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
fun hideSoftKeyboard() {
clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(windowToken, 0)
}
}
@@ -0,0 +1,998 @@
package haus.nightmare.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette) aligned 1:1 with IBM Host On-Demand ColorRemapModel3270
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF7890F0), // 1: Blue (0x7890F0 CUSTOMBLUE)
Color(0xFFFF0000), // 2: Red
Color(0xFFFF00FF), // 3: Pink
Color(0xFF00FF00), // 4: Green
Color(0xFF00FFFF), // 5: Turquoise / Cyan
Color(0xFFFFFF00), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF000080), // 9: Deep Blue
Color(0xFFFFA200), // 10: Orange (0xFFFFA200)
Color(0xFF800080), // 11: Purple
Color(0xFF008000), // 12: Pale Green
Color(0xFF008080), // 13: Pale Turquoise
Color(0xFFA0A000), // 14: Mustard (0xFFA0A000)
Color(0xFFC0C0C0) // 15: Grey (0xFFC0C0C0)
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
private val SELECTION_COLOR = Color(75, 110, 175, 102) // 40% alpha blend per HoD
private val SEARCH_HIGHLIGHT_COLOR = Color(255, 215, 0, 120) // Gold per HoD
private val CROSSHAIR_RULER_COLOR = Color(0, 255, 0, 102) // 40% alpha green per HoD
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: haus.nightmare.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: haus.nightmare.lib3270j.graphics.GraphicsPlane? = null,
gocaDecoder: haus.nightmare.lib3270j.graphics.GocaDecoder? = null,
isLightPenMode: Boolean = false,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
crosshairRuler: Boolean = false,
cursorStyle: String = "BLOCK",
isInsertMode: Boolean = false,
blockSelectMode: Boolean = false,
blockPaste: Boolean = true,
trimRectSizingHandles: Boolean = false,
trimRectRemainAfterEdit: Boolean = false,
hotspotsEnabled: Boolean = false,
hotspotEngine: HotspotEngine? = null,
fourColorOverride: Boolean = false,
searchHighlightAddr: Int = -1,
searchHighlightLen: Int = 0,
onTapAddress: (Int) -> Unit,
onLightPenSelect: ((Int) -> Unit)? = null,
onSendHotspotAction: ((String) -> Unit)? = null,
onOpenUrl: ((String) -> Unit)? = null,
onGraphicTouch: ((Int, Int, Int) -> Unit)? = null,
onPasteText: (String) -> Unit = {},
onPasteLineWrap: ((String, Int, Boolean) -> Unit)? = null,
onToggleBlockSelectMode: (() -> Unit)? = null,
onToggleBlockPaste: (() -> Unit)? = null,
onToggleHotspots: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
var showPasteWrapDialog by remember { mutableStateOf(false) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
// Text blinking timer (500ms cycle matching j3270)
var textBlinkVisible by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
while (true) {
delay(500L)
textBlinkVisible = !textBlinkVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows, isLightPenMode, gocaDecoder) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
val isGraphicCursor = gocaDecoder != null && gocaDecoder.isGraphicsCursorActive
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop && !isLightPenMode && !isGraphicCursor) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop && !isLightPenMode && !isGraphicCursor) {
// Stationary Long Press (held >= 600ms without dragging) - only in normal alphanumeric mode
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap
showContextMenu = false
if (!trimRectRemainAfterEdit) {
selectionStart = null
selectionEnd = null
}
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val clickAddr = row * cols + col
if (isGraphicCursor) {
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane?.canvasWidth ?: gridW.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 py = if (gridH > 0 && gHeight > 0) (((startPos.y - metrics.offsetY) * gHeight) / gridH).toInt().coerceIn(0, gHeight - 1) else (startPos.y - metrics.offsetY).toInt()
screenBuffer?.cursorAddress = clickAddr
onGraphicTouch?.invoke(clickAddr, px, py)
} else if (isLightPenMode) {
onLightPenSelect?.invoke(clickAddr)
} else {
// 1. Hotspot tap check when enabled
var handledHotspot = false
if (hotspotsEnabled && hotspotEngine != null) {
val h = hotspotEngine.findHotspot(row, col)
if (h != null) {
if (h.type == HotspotEngine.HotspotType.URL) {
onOpenUrl?.invoke(h.action)
handledHotspot = true
} else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) {
// In 3270 / ISPF, clicking/tapping a menu choice hotspot positions the cursor
// on a protected screen label. Typing directly into a protected cell triggers
// INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via
// the command/option line (first unprotected field reachable via [home]).
var action = h.action
if (!action.startsWith("[home]")) {
action = "[home][eraseeof]$action"
}
onSendHotspotAction?.invoke(action)
handledHotspot = true
} else {
onSendHotspotAction?.invoke(h.action)
handledHotspot = true
}
}
}
if (!handledHotspot) {
// 2. Detectable field check (HoD parity)
val buf = screenBuffer
var isDetectable = false
if (buf != null && buf.isFormatted) {
val faPos = buf.findFieldAttribute(clickAddr)
if (faPos >= 0) {
val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
if (faIsSelectable(fa) && !faIsZero(fa)) {
val size = buf.displayRows * buf.displayCols
val desPos = if (size > 0) (faPos + 1) % size else -1
val desCell = if (desPos >= 0) buf.getDisplayCell(desPos) else null
val desAscii = desCell?.ucs4 ?: 0.toChar()
val desEbcdic = desCell?.ec?.toInt()?.and(0xFF) ?: 0
val isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E')
val isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06')
// Automatic tap selection applies only to toggle (?/>) and enter (&) fields.
// Standard text fields (space/null designators) must not trigger light-pen selection
// so normal cursor positioning and editing work as expected.
if (isToggleField || isEnterField) {
onLightPenSelect?.invoke(clickAddr)
isDetectable = true
}
}
}
}
// 3. Normal tap cursor positioning
if (!isDetectable) {
var addr = clickAddr
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
}
}
}
) {
val version = currentScreenVersion
if (version == -1L) return@Canvas
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
val cellHeight = metrics.cellHeight
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
val measuredW = paint.measureText("W")
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
}
val totalCells = snapRows * snapCols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
val start = selectionStart
val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
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)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
// 1. Draw Vector Graphics Plane under text if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt()
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)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
}
// 2. Draw Crosshair Ruler if enabled
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
drawRect(
color = CROSSHAIR_RULER_COLOR,
topLeft = Offset(offsetX, cy),
size = Size(gridW, cellHeight)
)
drawRect(
color = CROSSHAIR_RULER_COLOR,
topLeft = Offset(cx, offsetY),
size = Size(cellWidth, gridH)
)
}
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
val top = offsetY + r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var isBlink = false
var csVal = 0
var ecVal = 0
val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
if (ea != null) {
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA, fourColorOverride)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
if ((grVal and GR_BLINK) != 0) isBlink = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
val tr = if (ea != null && ea.tr != 0.toByte()) ea.tr else (currentFieldEa?.tr ?: 0)
val isOpaque = ((tr.toInt() and 0xFF) == (ExtendedAttribute.TR_OPAQUE.toInt() and 0xFF))
if (isOpaque || (bgColor != COLOR_BLACK && bgColor != Color.Transparent)) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Selection highlight (Block select vs Linear stream select)
val isSelected = if (blockSelectMode) {
r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
} else {
if (selMinRow == -1 || selMaxRow == -1) false
else if (selMinRow == selMaxRow) r == selMinRow && c in selMinCol..selMaxCol
else if (r == selMinRow) c >= selMinCol
else if (r == selMaxRow) c <= selMaxCol
else r in (selMinRow + 1) until selMaxRow
}
val isSearchHighlighted = searchHighlightAddr >= 0 && searchHighlightLen > 0 &&
addr >= searchHighlightAddr && addr < (searchHighlightAddr + searchHighlightLen)
if (isSearchHighlighted) {
drawRect(
color = SEARCH_HIGHLIGHT_COLOR,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
} else if (isSelected) {
drawRect(
color = SELECTION_COLOR,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer, cursor style, insert mode)
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
val isDbcsCur = ea != null && (ea.db == ExtendedAttribute.DB_LEFT || ea.db == ExtendedAttribute.DB_RIGHT)
val curWidth = if (isDbcsCur) cellWidth * 2f else cellWidth
val curLeft = if (isDbcsCur && ea?.db == ExtendedAttribute.DB_RIGHT && c > 0) left - cellWidth else left
val isUnderlineCursor = cursorStyle.equals("UNDERLINE", ignoreCase = true) || isInsertMode
if (isUnderlineCursor) {
val ulH = maxOf(4f, cellHeight / 4f)
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(curLeft, top + cellHeight - ulH),
size = Size(curWidth, ulH)
)
} else {
drawRect(
color = Color(255, 255, 255, 180),
topLeft = Offset(curLeft, top),
size = Size(curWidth, cellHeight)
)
}
}
// Draw Programmed Symbol (PS / APL) if defined
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 rgbArray = slot.getRgbPixels(fgColor.toArgb(), bgColor.toArgb())
if (rgbArray != null && symWidth > 0 && symHeight > 0) {
val bmp = android.graphics.Bitmap.createBitmap(rgbArray, symWidth, symHeight, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
val isDbcsRight = ea?.db == ExtendedAttribute.DB_RIGHT
val isDbcsSoSi = ea?.db == ExtendedAttribute.DB_SO || ea?.db == ExtendedAttribute.DB_SI
val suppressText = (isBlink && !textBlinkVisible) || isDbcsRight || isDbcsSoSi
if (!drawnAsPs && charVal != ' ' && !suppressText) {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textX = if (ea?.db == ExtendedAttribute.DB_LEFT) left + cellWidth else left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
textX,
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f
)
}
// Field Outlining (0xC2 / TY_FOUTLINE)
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
val olOverline = ExtendedAttribute.OL_OVERLINE.toInt() and 0xFF
val olLeft = ExtendedAttribute.OL_LEFT.toInt() and 0xFF
val olRight = ExtendedAttribute.OL_RIGHT.toInt() and 0xFF
if (outline == 0 && fEa != null && fEa.ol != 0.toByte()) {
val fol = fEa.ol.toInt() and 0xFF
if ((fol and olUnderline) != 0) outline = outline or olUnderline
if ((fol and olOverline) != 0) outline = outline or olOverline
val size = totalCells
val prevAddr = (addr - 1 + size) % size
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
val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
if ((fol and olRight) != 0) outline = outline or olRight
}
}
if (outline != 0) {
if ((outline and olUnderline) != 0) {
drawLine(fgColor, Offset(left, top + cellHeight - 1f), Offset(left + cellWidth, top + cellHeight - 1f), strokeWidth = 1.5f)
}
if ((outline and olRight) != 0) {
drawLine(fgColor, Offset(left + cellWidth - 1f, top), Offset(left + cellWidth - 1f, top + cellHeight), strokeWidth = 1.5f)
}
if ((outline and olOverline) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left + cellWidth, top), strokeWidth = 1.5f)
}
if ((outline and olLeft) != 0) {
drawLine(fgColor, Offset(left, top), Offset(left, top + cellHeight), strokeWidth = 1.5f)
}
}
}
}
// Rectangular Block Selection Sizing Handles & Box Outline
if (trimRectSizingHandles && selMinRow != -1 && selMinCol != -1) {
val x1 = offsetX + selMinCol * cellWidth
val y1 = offsetY + selMinRow * cellHeight
val x2 = offsetX + (selMaxCol + 1) * cellWidth
val y2 = offsetY + (selMaxRow + 1) * cellHeight
val w = x2 - x1
val h = y2 - y1
val xm = (x1 + x2) / 2f
val ym = (y1 + y2) / 2f
drawRect(
color = Color.Yellow,
topLeft = Offset(x1, y1),
size = Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
val handleSize = 8f
val handleHalf = handleSize / 2f
val handles = listOf(
Offset(x1, y1), Offset(xm, y1), Offset(x2, y1),
Offset(x2, ym), Offset(x2, y2), Offset(xm, y2),
Offset(x1, y2), Offset(x1, ym)
)
for (pt in handles) {
drawRect(
color = Color.Yellow,
topLeft = Offset(pt.x - handleHalf, pt.y - handleHalf),
size = Size(handleSize, handleSize)
)
}
}
// Interactive Hotspot Underline Rendering
if (hotspotsEnabled && hotspotEngine != null && buf != null) {
val activeHotspots = hotspotEngine.scan(buf)
for (h in activeHotspots) {
val hr0 = h.startRow
val hr1 = h.endRow
for (r in hr0..hr1) {
val c0 = if (r == hr0) h.startCol else 0
val c1 = if (r == hr1) h.endCol else cols - 1
val x1 = offsetX + c0 * cellWidth
val x2 = offsetX + (c1 + 1) * cellWidth
val y = offsetY + (r + 1) * cellHeight - 2f
drawLine(
color = Color(0xFF00DCFF),
start = Offset(x1, y),
end = Offset(x2, y),
strokeWidth = 2f
)
}
}
}
// Draw Graphic Cursor if active in GOCA or GraphicsPlane
val isGraphicCursorActive = (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) ||
(graphicsPlane != null && graphicsPlane.isGraphicCursorAttached)
if (isGraphicCursorActive && graphicsPlane != null) {
val gocaX = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorX else graphicsPlane.graphicCursorX
val gocaY = if (gocaDecoder != null && gocaDecoder.isGraphicsCursorActive) gocaDecoder.graphicCursorY else graphicsPlane.graphicCursorY
val canvasPx = graphicsPlane.mapX(gocaX)
val canvasPy = graphicsPlane.mapY(gocaY)
val gridW = metrics.gridWidth
val gridH = metrics.gridHeight
val gWidth = graphicsPlane.canvasWidth.toFloat()
val gHeight = graphicsPlane.canvasHeight.toFloat()
val px = offsetX + if (gWidth > 0) (canvasPx.toFloat() * gridW / gWidth) else canvasPx.toFloat()
val py = offsetY + if (gHeight > 0) (canvasPy.toFloat() * gridH / gHeight) else canvasPy.toFloat()
val shape = graphicsPlane.hodCursorShape
if (shape == 2) {
// Box cursor
drawRect(
color = Color.White,
topLeft = Offset(px - 6f, py - 6f),
size = Size(12f, 12f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
)
} else {
// Crosshair cursor
drawLine(
color = Color.White,
start = Offset(px - 10f, py),
end = Offset(px + 10f, py),
strokeWidth = 2f
)
drawLine(
color = Color.White,
start = Offset(px, py - 10f),
end = Offset(px, py + 10f),
strokeWidth = 2f
)
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context, blockSelectMode)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
DropdownMenuItem(
text = { Text("Paste with Line Wrap...") },
onClick = {
showContextMenu = false
showPasteWrapDialog = true
}
)
if (onToggleBlockSelectMode != null) {
DropdownMenuItem(
text = { Text(if (blockSelectMode) "✓ Block Select Mode" else " Block Select Mode") },
onClick = {
showContextMenu = false
onToggleBlockSelectMode()
}
)
}
if (onToggleBlockPaste != null) {
DropdownMenuItem(
text = { Text(if (blockPaste) "✓ Block Paste" else " Block Paste") },
onClick = {
showContextMenu = false
onToggleBlockPaste()
}
)
}
if (onToggleHotspots != null) {
DropdownMenuItem(
text = { Text(if (hotspotsEnabled) "✓ Interactive Hotspots" else " Interactive Hotspots") },
onClick = {
showContextMenu = false
onToggleHotspots()
}
)
}
}
if (showPasteWrapDialog) {
var marginText by remember { mutableStateOf(cols.toString()) }
var wordWrap by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = { showPasteWrapDialog = false },
title = { Text("Paste with Line Wrap") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Wrap text at column (1-$cols, 0 = no wrap):", fontSize = 13.sp)
OutlinedTextField(
value = marginText,
onValueChange = { marginText = it },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = wordWrap, onCheckedChange = { wordWrap = it })
Spacer(modifier = Modifier.width(4.dp))
Text("Word wrap lines")
}
}
},
confirmButton = {
TextButton(onClick = {
showPasteWrapDialog = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
val margin = marginText.trim().toIntOrNull() ?: cols
if (onPasteLineWrap != null) {
onPasteLineWrap(clipText, margin, wordWrap)
} else {
onPasteText(clipText)
}
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}) {
Text("Paste")
}
},
dismissButton = {
TextButton(onClick = { showPasteWrapDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
data class TerminalGridMetrics(
val cellWidth: Float,
val cellHeight: Float,
val gridWidth: Float,
val gridHeight: Float,
val offsetX: Float,
val offsetY: Float
)
fun calculateGridMetrics(
viewWidth: Float,
viewHeight: Float,
cols: Int,
rows: Int
): TerminalGridMetrics {
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
}
val rawCellW = viewWidth / cols
val rawCellH = viewHeight / rows
val currentRatio = rawCellW / rawCellH
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
val targetMinRatio = 0.48f
val targetMaxRatio = 0.60f
val cellW: Float
val cellH: Float
if (currentRatio > targetMaxRatio) {
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
cellH = rawCellH
cellW = rawCellH * targetMaxRatio
} else if (currentRatio < targetMinRatio) {
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
cellW = rawCellW
cellH = rawCellW / targetMinRatio
} else {
cellW = rawCellW
cellH = rawCellH
}
val gridW = cols * cellW
val gridH = rows * cellH
val offX = maxOf(0f, (viewWidth - gridW) / 2f)
val offY = maxOf(0f, (viewHeight - gridH) / 2f)
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context,
blockSelectMode: Boolean = false
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val viewW = displayMetrics.widthPixels.toFloat()
val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
val colStart = if (blockSelectMode) minCol else (if (r == minRow) minCol else 0)
val colEnd = if (blockSelectMode) maxCol else (if (r == maxRow) maxCol else cols - 1)
for (c in colStart..colEnd) {
val addr = r * cols + c
if (addr < (rows * cols)) {
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))
if (isSubsequent) {
continue // Character already added by DB_LEFT
} else {
line.append(' ')
}
} else if (cell.db == ExtendedAttribute.DB_SO || cell.db == ExtendedAttribute.DB_SI) {
line.append(' ')
} else if (cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selection to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
internal fun getFgColorForAttribute(
ea: ExtendedAttribute,
currentFieldEa: ExtendedAttribute?,
currentFA: Byte,
fourColorOverride: Boolean = false
): Color {
if (!fourColorOverride) {
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
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_TURQUOISE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}
@@ -1,4 +1,4 @@
package org.pubvm.a3270.ui
package haus.nightmare.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -1,631 +0,0 @@
package org.pubvm.a3270
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import android.content.Context
import android.view.KeyCharacterMap
import org.lib3270j.protocol.DS3270Constants.*
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.HostStorage
import org.pubvm.a3270.ui.ConnectDialog
import org.pubvm.a3270.ui.FileTransferDialog
import org.pubvm.a3270.ui.OiaStatusBar
import org.pubvm.a3270.ui.SettingsDialog
import org.pubvm.a3270.ui.TerminalInputView
import org.pubvm.a3270.ui.TerminalView
import org.pubvm.a3270.ui.TwoRowKeyBar
import org.pubvm.a3270.ui.UntrustedCertificateDialog
class MainActivity : ComponentActivity() {
companion object {
init {
val rootLogger = java.util.logging.Logger.getLogger("")
rootLogger.level = java.util.logging.Level.ALL
for (h in rootLogger.handlers) {
rootLogger.removeHandler(h)
}
rootLogger.addHandler(object : java.util.logging.Handler() {
override fun publish(record: java.util.logging.LogRecord?) {
if (record == null) return
val msg = record.message ?: ""
val tag = "a3270-" + (record.loggerName?.substringAfterLast('.') ?: "lib3270j")
when {
record.level.intValue() >= java.util.logging.Level.SEVERE.intValue() ->
android.util.Log.e(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.WARNING.intValue() ->
android.util.Log.w(tag, msg, record.thrown)
record.level.intValue() >= java.util.logging.Level.INFO.intValue() ->
android.util.Log.i(tag, msg, record.thrown)
else ->
android.util.Log.d(tag, msg, record.thrown)
}
}
override fun flush() {}
override fun close() {}
})
}
}
private val viewModel: TerminalViewModel by viewModels()
private val isShiftPressedState = mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
background = Color.Black,
surface = Color(0xFF1E1E1E),
primary = Color(0xFF339AF0)
)
) {
MainScreen(
viewModel = viewModel,
isShiftPressed = isShiftPressedState.value,
onClearShift = { isShiftPressedState.value = false }
)
}
}
}
private val log = java.util.logging.Logger.getLogger("a3270-MainActivity")
private var deadKey: Int = 0
private fun getEffectiveUnicodeChar(event: KeyEvent): Int {
val cleanMeta = event.metaState and (
KeyEvent.META_SHIFT_ON or
KeyEvent.META_SHIFT_LEFT_ON or
KeyEvent.META_SHIFT_RIGHT_ON or
KeyEvent.META_CAPS_LOCK_ON
)
// 1. Try getUnicodeChar with cleanMeta
var u = event.getUnicodeChar(cleanMeta)
if (u > 0) return u
// 2. Try unicodeChar property
u = event.unicodeChar
if (u > 0) return u
// 3. Try KeyCharacterMap with cleanMeta
val kcm = event.keyCharacterMap ?: KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
u = kcm.get(event.keyCode, cleanMeta)
if (u > 0) return u
// 4. Try KeyCharacterMap with meta = 0
u = kcm.get(event.keyCode, 0)
if (u > 0) {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (shift) {
val c = u.toChar()
if (c.isLowerCase()) return c.uppercaseChar().code
}
return u
}
// 5. Try displayLabel as fallback
val label = event.displayLabel
if (label >= ' ') {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
return if (shift) label.uppercaseChar().code else label.lowercaseChar().code
}
return 0
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
if (isShiftPressedState.value != shift) {
isShiftPressedState.value = shift
}
// Handle string / multi-character input (e.g. adb input text, barcode scanners, fast text input)
if (event.action == KeyEvent.ACTION_MULTIPLE) {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
val chars = event.characters
if (!chars.isNullOrEmpty()) {
viewModel.typeString(chars)
return true
}
} else {
val unicode = getEffectiveUnicodeChar(event)
if (unicode >= 32) {
val count = maxOf(1, event.repeatCount)
viewModel.typeString(unicode.toChar().toString().repeat(count))
return true
}
}
}
if (event.action == KeyEvent.ACTION_DOWN) {
val keyCode = event.keyCode
val ctrl = event.isCtrlPressed || (event.metaState and KeyEvent.META_CTRL_ON != 0)
val alt = event.isAltPressed || (event.metaState and KeyEvent.META_ALT_ON != 0)
// 1. Function Keys (F1..F12 -> PF1..PF12 or PF13..PF24 with Shift or Alt)
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
val pfNum = if (shift || alt) fNum + 12 else fNum
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
viewModel.sendAid(pfAids[pfNum - 1])
if (shift) {
isShiftPressedState.value = false
}
return true
}
// 2. Ctrl Shortcuts
if (ctrl) {
when (keyCode) {
KeyEvent.KEYCODE_V -> {
pasteFromClipboard()
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
KeyEvent.KEYCODE_L -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_Z -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
}
}
// 3. Alt Shortcuts (PA keys, Clear, Reset)
if (alt) {
when (keyCode) {
KeyEvent.KEYCODE_1 -> {
viewModel.sendAid(AID_PA1)
return true
}
KeyEvent.KEYCODE_2 -> {
viewModel.sendAid(AID_PA2)
return true
}
KeyEvent.KEYCODE_3 -> {
viewModel.sendAid(AID_PA3)
return true
}
KeyEvent.KEYCODE_C -> {
viewModel.sendAid(AID_CLEAR)
return true
}
KeyEvent.KEYCODE_R -> {
viewModel.resetKeyboard()
return true
}
}
}
// 4. Shift Shortcuts
if (shift && keyCode == KeyEvent.KEYCODE_INSERT) {
pasteFromClipboard()
return true
}
// 5. Standard Navigation & Special Keys
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
viewModel.cursorLeft()
return true
}
KeyEvent.KEYCODE_DPAD_UP -> {
viewModel.cursorUp()
return true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.cursorDown()
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
viewModel.cursorRight()
return true
}
KeyEvent.KEYCODE_MOVE_HOME -> {
viewModel.cursorHome()
return true
}
KeyEvent.KEYCODE_MOVE_END -> {
viewModel.eraseEof()
return true
}
KeyEvent.KEYCODE_PAGE_UP -> {
viewModel.sendAid(AID_PF7)
return true
}
KeyEvent.KEYCODE_PAGE_DOWN -> {
viewModel.sendAid(AID_PF8)
return true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
viewModel.sendAid(AID_ENTER)
return true
}
KeyEvent.KEYCODE_TAB -> {
if (shift) {
viewModel.backTab()
isShiftPressedState.value = false
} else {
viewModel.tab()
}
return true
}
KeyEvent.KEYCODE_DEL -> {
viewModel.backspace()
return true
}
KeyEvent.KEYCODE_FORWARD_DEL -> {
viewModel.deleteChar()
return true
}
KeyEvent.KEYCODE_ESCAPE -> {
if (shift) {
viewModel.sendAid(AID_CLEAR)
isShiftPressedState.value = false
} else {
viewModel.resetKeyboard()
}
return true
}
KeyEvent.KEYCODE_BREAK -> {
viewModel.sendAid(AID_PA1)
return true
}
}
// 6. Alphanumeric & Symbol Character Input
val unicode = getEffectiveUnicodeChar(event)
log.info("dispatchKeyEvent DOWN: keyCode=$keyCode, metaState=${event.metaState}, unicode=$unicode, char='${if (unicode > 0) unicode.toChar() else ' '}'")
if (unicode > 0) {
if ((unicode and KeyCharacterMap.COMBINING_ACCENT) != 0) {
deadKey = unicode and KeyCharacterMap.COMBINING_ACCENT_MASK
return true
}
val charToType = if (deadKey != 0) {
val combined = KeyCharacterMap.getDeadChar(deadKey, unicode)
deadKey = 0
if (combined != 0) combined.toChar() else unicode.toChar()
} else {
unicode.toChar()
}
if (charToType >= ' ') {
log.info("dispatchKeyEvent typing char: '$charToType'")
viewModel.typeString(charToType.toString())
return true
}
} else {
log.info("dispatchKeyEvent unhandled DOWN keyCode=$keyCode, metaState=${event.metaState}")
}
}
if (event.action == KeyEvent.ACTION_UP) {
when (event.keyCode) {
KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT,
KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT,
KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> {
// Let super handle modifier tracking
}
else -> {
return true
}
}
}
return super.dispatchKeyEvent(event)
}
private fun pasteFromClipboard() {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? android.content.ClipboardManager
val clip = clipboard?.primaryClip
if (clip != null && clip.itemCount > 0) {
val text = clip.getItemAt(0)?.coerceToText(this)?.toString()
if (!text.isNullOrEmpty()) {
viewModel.pasteString(text)
}
}
}
}
@Composable
fun MainScreen(
viewModel: TerminalViewModel,
isShiftPressed: Boolean = false,
onClearShift: () -> Unit = {}
) {
val context = LocalContext.current
val connectionState by viewModel.connectionState.collectAsState()
val screenBuffer by viewModel.screenBuffer.collectAsState()
val rows by viewModel.rows.collectAsState()
val cols by viewModel.cols.collectAsState()
val cursorAddr by viewModel.cursorAddress.collectAsState()
val oiaText by viewModel.oiaText.collectAsState()
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
val screenVersion by viewModel.screenVersion.collectAsState()
val maskHiddenInput by viewModel.maskHiddenInput.collectAsState()
val cursorBlink by viewModel.cursorBlink.collectAsState()
val hapticFeedback by viewModel.hapticFeedback.collectAsState()
val verifyCerts by viewModel.verifyCerts.collectAsState()
val defaultGraphicsMode by viewModel.defaultGraphicsMode.collectAsState()
val isTlsActive by viewModel.isTlsActive.collectAsState()
val isTlsVerified by viewModel.isTlsVerified.collectAsState()
val untrustedCertPrompt by viewModel.untrustedCertPrompt.collectAsState()
val ftState by viewModel.ftState.collectAsState()
var showConnectDialog by remember { mutableStateOf(false) }
var showFtDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.isAppInBackground = true
} else if (event == Lifecycle.Event.ON_START) {
viewModel.isAppInBackground = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(Unit) {
val autoHost = HostStorage.getAutoConnectHost(context)
if (autoHost != null && !connectionState.isConnected()) {
viewModel.connect(
autoHost.host,
autoHost.port,
autoHost.model,
autoHost.luName,
autoHost.hostType,
autoHost.useTls,
autoHost.tlsVerifyCert,
autoHost.tn3270e,
autoHost.graphicsMode
)
}
}
LaunchedEffect(connectionState) {
if (connectionState.isConnected()) {
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
TerminalService.start(context, activeHost)
} else {
TerminalService.stop(context)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.windowInsetsPadding(WindowInsets.systemBars)
.imePadding()
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Main 3270 Terminal Screen View
TerminalView(
screenBuffer = screenBuffer,
rows = rows,
cols = cols,
cursorAddr = cursorAddr,
screenVersion = screenVersion,
programSymbolManager = viewModel.getClient()?.programSymbolManager,
graphicsPlane = viewModel.getClient()?.graphicsPlane,
maskHiddenFields = maskHiddenInput,
blinkCursor = cursorBlink,
onTapAddress = { addr ->
viewModel.setCursor(addr)
terminalInputViewRef?.showSoftKeyboard()
},
onPasteText = { text ->
viewModel.pasteString(text)
},
modifier = Modifier.weight(1f)
)
// Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
AndroidView(
factory = { ctx ->
TerminalInputView(ctx).apply {
onInputText = { text ->
viewModel.typeString(text)
onClearShift()
}
onSendAid = { aid ->
viewModel.sendAid(aid)
}
onBackspace = {
viewModel.backspace()
onClearShift()
}
onTab = {
viewModel.tab()
}
onBackTab = {
viewModel.backTab()
}
terminalInputViewRef = this
}
},
update = { inputView ->
terminalInputViewRef = inputView
},
modifier = Modifier.size(1.dp)
)
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
TwoRowKeyBar(
connectionState = connectionState,
isShiftPressed = isShiftPressed,
hapticFeedbackEnabled = hapticFeedback,
onClearShift = onClearShift,
onConnectClick = { showConnectDialog = true },
onDisconnectClick = { viewModel.disconnect() },
onFtClick = { showFtDialog = true },
onSettingsClick = { showSettingsDialog = true },
onSendAid = { aid ->
viewModel.sendAid(aid)
terminalInputViewRef?.showSoftKeyboard()
},
onReset = {
viewModel.resetKeyboard()
terminalInputViewRef?.showSoftKeyboard()
},
onTab = {
viewModel.tab()
terminalInputViewRef?.showSoftKeyboard()
},
onBackTab = {
viewModel.backTab()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorLeft = {
viewModel.cursorLeft()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorUp = {
viewModel.cursorUp()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorDown = {
viewModel.cursorDown()
terminalInputViewRef?.showSoftKeyboard()
},
onCursorRight = {
viewModel.cursorRight()
terminalInputViewRef?.showSoftKeyboard()
}
)
// OIA Status Bar
OiaStatusBar(
connectionState = connectionState,
currentHost = viewModel.currentHost,
isKeyboardLocked = isKeyboardLocked,
oiaText = oiaText,
cursorAddr = cursorAddr,
rows = rows,
cols = cols,
isTls = isTlsActive,
isTlsVerified = isTlsVerified,
graphicsMode = defaultGraphicsMode
)
}
}
if (showConnectDialog) {
ConnectDialog(
onDismiss = { showConnectDialog = false },
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
showConnectDialog = false
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
if (showFtDialog) {
FileTransferDialog(
initialHostType = viewModel.activeHostType,
ftProgressState = ftState,
onDismiss = { showFtDialog = false },
onStartTransfer = { config ->
viewModel.startFileTransfer(config) { error ->
if (error != null) {
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
}
}
},
onCancelTransfer = {
viewModel.cancelFileTransfer()
}
)
}
if (showSettingsDialog) {
SettingsDialog(
initialMaskHiddenInput = maskHiddenInput,
initialCursorBlink = cursorBlink,
initialHapticFeedback = hapticFeedback,
initialVerifyCerts = verifyCerts,
initialDefaultGraphicsMode = defaultGraphicsMode,
onDismiss = { showSettingsDialog = false },
onSave = { mask, blink, haptic, verify, gfx ->
showSettingsDialog = false
viewModel.updateSettings(mask, blink, haptic, verify, gfx)
terminalInputViewRef?.showSoftKeyboard()
}
)
}
val prompt = untrustedCertPrompt
if (prompt != null) {
UntrustedCertificateDialog(
host = prompt.host,
port = prompt.port,
chain = prompt.chain,
exception = prompt.exception,
onAccept = {
viewModel.resolveUntrustedCert(true)
},
onReject = {
viewModel.resolveUntrustedCert(false)
}
)
}
}
@@ -1,707 +0,0 @@
package org.pubvm.a3270
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lib3270j.ConnectionConfig
import org.lib3270j.ConnectionState
import org.lib3270j.Telnet3270Client
import org.lib3270j.TerminalModel
import org.lib3270j.ft.FTConfig
import org.lib3270j.listener.ConnectionListener
import org.lib3270j.listener.ScreenUpdateListener
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
import org.lib3270j.protocol.DS3270Constants.faIsProtected
import org.lib3270j.screen.ScreenBuffer
import org.pubvm.a3270.ft.FileTransfer
import org.pubvm.a3270.service.TerminalService
import org.pubvm.a3270.storage.AppSettings
import java.io.File
import java.util.concurrent.Executors
import java.util.logging.Logger
sealed interface TerminalInputAction {
data class TypeText(val text: String) : TerminalInputAction
data class SendAid(val aidCode: Int) : TerminalInputAction
data object Backspace : TerminalInputAction
data object DeleteChar : TerminalInputAction
data object Tab : TerminalInputAction
data object BackTab : TerminalInputAction
data object CursorLeft : TerminalInputAction
data object CursorRight : TerminalInputAction
data object CursorUp : TerminalInputAction
data object CursorDown : TerminalInputAction
data object CursorHome : TerminalInputAction
data object EraseEof : TerminalInputAction
data object EraseInput : TerminalInputAction
data object Newline : TerminalInputAction
data object Reset : TerminalInputAction
data class SetCursor(val baddr: Int) : TerminalInputAction
}
data class UntrustedCertPromptState(
val host: String,
val port: Int,
val chain: Array<java.security.cert.X509Certificate>?,
val exception: java.security.cert.CertificateException?,
val deferred: kotlinx.coroutines.CompletableDeferred<Boolean>
)
data class FTProgressState(
val isActive: Boolean = false,
val isRunning: Boolean = false,
val bytesTransferred: Long = 0L,
val statusMessage: String = "",
val isError: Boolean = false
)
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
var isAppInBackground: Boolean = false
// Dedicated single-thread FIFO queue for all local keyboard and cursor actions
private val inputExecutor = Executors.newSingleThreadExecutor()
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
// Non-blocking FIFO input channel buffer
private val inputChannel = Channel<TerminalInputAction>(Channel.UNLIMITED)
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _screenBuffer = MutableStateFlow<ScreenBuffer?>(null)
val screenBuffer: StateFlow<ScreenBuffer?> = _screenBuffer.asStateFlow()
private val _rows = MutableStateFlow(24)
val rows: StateFlow<Int> = _rows.asStateFlow()
private val _cols = MutableStateFlow(80)
val cols: StateFlow<Int> = _cols.asStateFlow()
private val _cursorAddress = MutableStateFlow(0)
val cursorAddress: StateFlow<Int> = _cursorAddress.asStateFlow()
private val _oiaText = MutableStateFlow("Disconnected")
val oiaText: StateFlow<String> = _oiaText.asStateFlow()
private val _screenVersion = MutableStateFlow(0L)
val screenVersion: StateFlow<Long> = _screenVersion.asStateFlow()
private val _isKeyboardLocked = MutableStateFlow(false)
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
private val _maskHiddenInput = MutableStateFlow(AppSettings.isMaskHiddenInputEnabled(application))
val maskHiddenInput: StateFlow<Boolean> = _maskHiddenInput.asStateFlow()
private val _cursorBlink = MutableStateFlow(AppSettings.isCursorBlinkEnabled(application))
val cursorBlink: StateFlow<Boolean> = _cursorBlink.asStateFlow()
private val _hapticFeedback = MutableStateFlow(AppSettings.isHapticFeedbackEnabled(application))
val hapticFeedback: StateFlow<Boolean> = _hapticFeedback.asStateFlow()
private val _verifyCerts = MutableStateFlow(AppSettings.isVerifyCertsEnabled(application))
val verifyCerts: StateFlow<Boolean> = _verifyCerts.asStateFlow()
private val _defaultGraphicsMode = MutableStateFlow(AppSettings.getDefaultGraphicsMode(application))
val defaultGraphicsMode: StateFlow<String> = _defaultGraphicsMode.asStateFlow()
private val _isTlsActive = MutableStateFlow(false)
val isTlsActive: StateFlow<Boolean> = _isTlsActive.asStateFlow()
private val _isTlsVerified = MutableStateFlow(true)
val isTlsVerified: StateFlow<Boolean> = _isTlsVerified.asStateFlow()
private val _untrustedCertPrompt = MutableStateFlow<UntrustedCertPromptState?>(null)
val untrustedCertPrompt: StateFlow<UntrustedCertPromptState?> = _untrustedCertPrompt.asStateFlow()
// File Transfer State
private var fileTransferCoordinator: FileTransfer? = null
private val _ftState = MutableStateFlow(FTProgressState())
val ftState: StateFlow<FTProgressState> = _ftState.asStateFlow()
@Volatile
private var client: Telnet3270Client? = null
var currentHost: String = ""
private set
var activeHostType: String = "TSO"
private set
fun getClient(): Telnet3270Client? = client
private var lastScreenContentHash: Int = 0
private var hasInitialScreenLoaded: Boolean = false
init {
// Start continuous background input buffer consumer
viewModelScope.launch(inputDispatcher) {
for (action in inputChannel) {
processInputAction(action)
// Drain any additional pending actions in the buffer before updating the UI
while (true) {
val next = inputChannel.tryReceive().getOrNull() ?: break
processInputAction(next)
}
val c = client
if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress
}
_screenVersion.value = System.currentTimeMillis()
}
}
}
private fun processInputAction(action: TerminalInputAction) {
val c = client ?: return
val ip = c.inputProcessor
val buf = c.screenBuffer
try {
when (action) {
is TerminalInputAction.TypeText -> {
log.info("TypeText: text='${action.text}', curAddr=${buf.cursorAddress}, formatted=${buf.isFormatted}")
ip.isKeyboardLocked = false
for (ch in action.text) {
if (ch == '\n' || ch == '\r') {
ip.setKeyboardLocked(false)
ip.sendAid(AID_ENTER)
} else if (ch >= ' ') {
var curAddr = buf.cursorAddress
if (buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(curAddr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
curAddr = buf.findNextUnprotected(curAddr)
buf.cursorAddress = curAddr
}
}
ip.typeCharacter(ch)
log.info("After typeCharacter('$ch'): newAddr=${buf.cursorAddress}, cellChar='${buf.getCell(curAddr).ucs4}'")
}
}
}
is TerminalInputAction.SendAid -> {
ip.setKeyboardLocked(false)
ip.sendAid(action.aidCode)
}
is TerminalInputAction.Backspace -> {
ip.isKeyboardLocked = false
ip.backspace()
}
is TerminalInputAction.DeleteChar -> {
ip.isKeyboardLocked = false
ip.deleteChar()
}
is TerminalInputAction.Tab -> {
ip.tab()
}
is TerminalInputAction.BackTab -> {
ip.backTab()
}
is TerminalInputAction.CursorLeft -> {
ip.cursorLeft()
}
is TerminalInputAction.CursorRight -> {
ip.cursorRight()
}
is TerminalInputAction.CursorUp -> {
ip.cursorUp()
}
is TerminalInputAction.CursorDown -> {
ip.cursorDown()
}
is TerminalInputAction.CursorHome -> {
ip.cursorHome()
}
is TerminalInputAction.EraseEof -> {
ip.isKeyboardLocked = false
ip.eraseEof()
}
is TerminalInputAction.EraseInput -> {
ip.isKeyboardLocked = false
ip.eraseInput()
}
is TerminalInputAction.Newline -> {
ip.newline()
}
is TerminalInputAction.Reset -> {
ip.reset()
}
is TerminalInputAction.SetCursor -> {
if (action.baddr in 0 until (buf.rows * buf.cols)) {
buf.cursorAddress = action.baddr
}
}
}
} catch (e: Exception) {
log.warning("Error processing input action: ${e.message}")
}
}
fun resolveUntrustedCert(accept: Boolean) {
try {
val prompt = _untrustedCertPrompt.value
if (prompt != null) {
prompt.deferred.complete(accept)
_untrustedCertPrompt.value = null
}
} catch (t: Throwable) {
log.warning("Error resolving untrusted cert: ${t.message}")
}
}
fun updateSettings(maskHidden: Boolean, blink: Boolean, haptic: Boolean, verifyCertsVal: Boolean = true, defaultGraphicsVal: String = "BOTH") {
AppSettings.setMaskHiddenInputEnabled(getApplication(), maskHidden)
AppSettings.setCursorBlinkEnabled(getApplication(), blink)
AppSettings.setHapticFeedbackEnabled(getApplication(), haptic)
AppSettings.setVerifyCertsEnabled(getApplication(), verifyCertsVal)
AppSettings.setDefaultGraphicsMode(getApplication(), defaultGraphicsVal)
_maskHiddenInput.value = maskHidden
_cursorBlink.value = blink
_hapticFeedback.value = haptic
_verifyCerts.value = verifyCertsVal
_defaultGraphicsMode.value = defaultGraphicsVal
}
fun connect(
host: String,
port: Int = 23,
modelNum: Int = 2,
luName: String = "",
hostType: String = "TSO",
useTls: Boolean = false,
tlsVerifyCert: Boolean = true,
tn3270e: Boolean = true,
graphicsModeStr: String = "BOTH"
) {
currentHost = host
activeHostType = hostType
lastScreenContentHash = 0
hasInitialScreenLoaded = false
fileTransferCoordinator = null
_ftState.value = FTProgressState()
viewModelScope.launch(Dispatchers.IO) {
try {
// Always disconnect any existing client before establishing a new connection
if (client != null || _connectionState.value.isConnected()) {
try {
client?.disconnect()
} catch (_: Exception) {}
client = null
_connectionState.value = ConnectionState.NOT_CONNECTED
}
val model = when (modelNum) {
3 -> TerminalModel.IBM_3279_3
4 -> TerminalModel.IBM_3279_4
5 -> TerminalModel.IBM_3279_5
else -> TerminalModel.IBM_3279_2
}
val parsedConfig = ConnectionConfig.parseHostString(host, port, model)
val effectiveHost = parsedConfig.host
val effectivePort = parsedConfig.port
val effectiveTls = useTls || parsedConfig.isUseTls
val effectiveTn3270e = tn3270e && parsedConfig.isTn3270eEnabled
val globalVerify = AppSettings.isVerifyCertsEnabled(getApplication())
val effectiveVerify = tlsVerifyCert && globalVerify
val gMode = org.lib3270j.graphics.GraphicsMode.fromString(graphicsModeStr)
_isTlsActive.value = effectiveTls
_isTlsVerified.value = effectiveVerify
_oiaText.value = "Connecting to $effectiveHost:$effectivePort" + (if (effectiveTls) " [TLS]" else "") + "..."
val config = ConnectionConfig(effectiveHost, effectivePort, model).apply {
if (luName.isNotBlank()) {
setLuName(luName)
}
isUseTls = effectiveTls
isTlsVerifyCert = effectiveVerify
isTn3270eEnabled = effectiveTn3270e
graphicsMode = gMode
}
config.certificateVerifier = org.lib3270j.tls.TlsCertificateVerifier { chain, _, exception ->
if (!AppSettings.isVerifyCertsEnabled(getApplication())) {
log.info("Certificate verification bypassed via global AppSettings")
return@TlsCertificateVerifier true
}
log.info("Prompting user for untrusted certificate: $effectiveHost:$effectivePort")
val deferred = kotlinx.coroutines.CompletableDeferred<Boolean>()
_untrustedCertPrompt.value = UntrustedCertPromptState(
host = effectiveHost,
port = effectivePort,
chain = chain,
exception = exception,
deferred = deferred
)
val accepted = try {
kotlinx.coroutines.runBlocking { deferred.await() }
} catch (t: Throwable) {
log.warning("Certificate verifier prompt interrupted or failed: ${t.message}")
false
} finally {
_untrustedCertPrompt.value = null
}
accepted
}
val newClient = Telnet3270Client(config)
this@TerminalViewModel.client = newClient
newClient.addConnectionListener(object : ConnectionListener {
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
_connectionState.value = newState
val tlsSuffix = if (effectiveTls) {
if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
} else ""
_oiaText.value = if (newState.isFullSession()) {
"3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
} else if (newState.isHalfConnected()) {
"Connecting..."
} else {
"Disconnected"
}
}
override fun onConnectionError(message: String) {
log.warning("Connection error: $message")
_oiaText.value = "Error: $message"
}
})
newClient.inputProcessor.setLockStateListener { locked ->
_isKeyboardLocked.value = locked
}
newClient.addScreenUpdateListener(object : ScreenUpdateListener {
override fun onScreenUpdated() {
val buf = newClient.screenBuffer
_screenBuffer.value = buf
if (buf != null) {
_rows.value = buf.rows
_cols.value = buf.cols
_cursorAddress.value = buf.cursorAddress
}
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated()
val newHash = computeScreenContentHash(buf)
val contentChanged = (newHash != lastScreenContentHash)
if (!hasInitialScreenLoaded) {
if (buf != null && newHash != 0) {
hasInitialScreenLoaded = true
lastScreenContentHash = newHash
}
} else if (contentChanged) {
lastScreenContentHash = newHash
if (isAppInBackground) {
val snippet = extractScreenSnippet(buf)
val activeHost = currentHost.ifBlank { "Mainframe" }
TerminalService.notifyScreenUpdate(getApplication(), activeHost, snippet)
}
}
}
override fun onSoundAlarm() {
log.fine("Sound Alarm")
}
})
_rows.value = model.defaultRows
_cols.value = model.defaultCols
_screenBuffer.value = newClient.screenBuffer
newClient.connect()
} catch (e: Throwable) {
log.severe("Failed to connect: ${e.message}")
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
}
}
}
fun disconnect() {
viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect()
client = null
_connectionState.value = ConnectionState.NOT_CONNECTED
_oiaText.value = "Disconnected"
_ftState.value = FTProgressState()
} catch (e: Exception) {
log.warning("Error disconnecting: ${e.message}")
}
}
}
fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
val c = client
if (c == null || !_connectionState.value.isConnected()) {
val err = "Terminal is not connected."
_ftState.value = FTProgressState(isActive = false, statusMessage = err, isError = true)
onResult(err)
return
}
viewModelScope.launch(Dispatchers.IO) {
try {
// Resolve local path if relative (default to app files or download directory)
val rawPath = config.localFilename
if (!rawPath.startsWith("/")) {
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
val resolvedFile = File(appFilesDir, rawPath)
config.localFilename = resolvedFile.absolutePath
}
if (fileTransferCoordinator == null) {
fileTransferCoordinator = FileTransfer(c, object : FileTransfer.FileTransferCallback {
override fun onTransferStarted() {
_ftState.value = FTProgressState(
isActive = true,
isRunning = false,
bytesTransferred = 0L,
statusMessage = "Starting IND\$FILE transfer..."
)
}
override fun onTransferRunning() {
_ftState.value = _ftState.value.copy(
isRunning = true,
statusMessage = "Transfer in progress..."
)
}
override fun onBytesTransferred(bytes: Long) {
_ftState.value = _ftState.value.copy(
bytesTransferred = bytes,
statusMessage = "Transferring: $bytes bytes"
)
}
override fun onTransferComplete(message: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = message,
isError = false
)
}
override fun onTransferAborted(error: String) {
_ftState.value = FTProgressState(
isActive = false,
isRunning = false,
bytesTransferred = _ftState.value.bytesTransferred,
statusMessage = "Transfer failed: $error",
isError = true
)
}
})
}
val err = fileTransferCoordinator?.startTransfer(config)
if (err != null) {
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Error: $err",
isError = true
)
}
withContext(Dispatchers.Main) {
onResult(err)
}
} catch (e: Exception) {
log.warning("Error in startFileTransfer: ${e.message}")
_ftState.value = FTProgressState(
isActive = false,
statusMessage = "Transfer error: ${e.message}",
isError = true
)
withContext(Dispatchers.Main) {
onResult(e.message)
}
}
}
}
fun cancelFileTransfer() {
viewModelScope.launch(Dispatchers.IO) {
try {
fileTransferCoordinator?.cancel()
} catch (e: Exception) {
log.warning("Error cancelling transfer: ${e.message}")
}
}
}
fun typeChar(ch: Char) {
inputChannel.trySend(TerminalInputAction.TypeText(ch.toString()))
}
fun typeString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun pasteString(text: String) {
if (text.isNotEmpty()) {
inputChannel.trySend(TerminalInputAction.TypeText(text))
}
}
fun backspace() {
inputChannel.trySend(TerminalInputAction.Backspace)
}
fun deleteChar() {
inputChannel.trySend(TerminalInputAction.DeleteChar)
}
fun tab() {
inputChannel.trySend(TerminalInputAction.Tab)
}
fun backTab() {
inputChannel.trySend(TerminalInputAction.BackTab)
}
fun resetKeyboard() {
inputChannel.trySend(TerminalInputAction.Reset)
}
fun eraseEof() {
inputChannel.trySend(TerminalInputAction.EraseEof)
}
fun eraseInput() {
inputChannel.trySend(TerminalInputAction.EraseInput)
}
fun newline() {
inputChannel.trySend(TerminalInputAction.Newline)
}
fun sendAid(aidCode: Int) {
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
}
fun setCursor(baddr: Int) {
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
}
fun cursorLeft() {
inputChannel.trySend(TerminalInputAction.CursorLeft)
}
fun cursorUp() {
inputChannel.trySend(TerminalInputAction.CursorUp)
}
fun cursorDown() {
inputChannel.trySend(TerminalInputAction.CursorDown)
}
fun cursorRight() {
inputChannel.trySend(TerminalInputAction.CursorRight)
}
fun cursorHome() {
inputChannel.trySend(TerminalInputAction.CursorHome)
}
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
if (buf == null) return "Mainframe screen update received"
val rows = buf.rows
val cols = buf.cols
if (rows <= 0 || cols <= 0) return "Mainframe screen update received"
val curAddr = _cursorAddress.value
val cursorRow = (curAddr / cols).coerceIn(0, rows - 1)
// 1. Check bottom 3 lines first (where 3270 status, errors, reader & I/S MSGs land)
val minBottomRow = (rows - 3).coerceAtLeast(1)
for (r in (rows - 1) downTo minBottomRow) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4 && !line.startsWith("***") && !line.startsWith("===")) {
return line
}
}
// 2. Check cursor row if not row 0
if (cursorRow > 0) {
val cursorLine = getLineText(buf, cursorRow, cols).trim()
if (cursorLine.length >= 4) {
return cursorLine
}
}
// 3. Scan rows from bottom to top, skipping static header row 0
for (r in (rows - 1) downTo 1) {
val line = getLineText(buf, r, cols).trim()
if (line.length >= 4) {
return line
}
}
return getLineText(buf, 0, cols).trim().ifBlank { "Mainframe screen update received" }
}
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder()
for (c in 0 until cols) {
val addr = row * cols + c
val cell = buf.getCell(addr)
if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) {
sb.append(ch)
} else {
sb.append(' ')
}
} else {
sb.append(' ')
}
}
return sb.toString().replace(Regex("\\s+"), " ").trim()
}
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0
val sb = StringBuilder()
val total = buf.rows * buf.cols
for (i in 0 until total) {
val cell = buf.getCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4)
}
}
return sb.toString().hashCode()
}
override fun onCleared() {
super.onCleared()
fileTransferCoordinator?.reset()
fileTransferCoordinator = null
client?.disconnect()
inputChannel.close()
inputExecutor.shutdown()
}
}
@@ -1,58 +0,0 @@
package org.pubvm.a3270.storage
import android.content.Context
import android.content.SharedPreferences
object AppSettings {
private const val PREFS_NAME = "a3270_settings"
private const val KEY_MASK_HIDDEN_INPUT = "mask_hidden_input"
private const val KEY_CURSOR_BLINK = "cursor_blink"
private const val KEY_HAPTIC_FEEDBACK = "haptic_feedback"
private const val KEY_VERIFY_CERTS = "verify_certs"
private const val KEY_DEFAULT_GRAPHICS_MODE = "default_graphics_mode"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isMaskHiddenInputEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_MASK_HIDDEN_INPUT, true)
}
fun setMaskHiddenInputEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_MASK_HIDDEN_INPUT, enabled).apply()
}
fun isCursorBlinkEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_CURSOR_BLINK, true)
}
fun setCursorBlinkEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_CURSOR_BLINK, enabled).apply()
}
fun isHapticFeedbackEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_HAPTIC_FEEDBACK, true)
}
fun setHapticFeedbackEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_HAPTIC_FEEDBACK, enabled).apply()
}
fun isVerifyCertsEnabled(context: Context): Boolean {
return getPrefs(context).getBoolean(KEY_VERIFY_CERTS, true)
}
fun setVerifyCertsEnabled(context: Context, enabled: Boolean) {
getPrefs(context).edit().putBoolean(KEY_VERIFY_CERTS, enabled).apply()
}
fun getDefaultGraphicsMode(context: Context): String {
return getPrefs(context).getString(KEY_DEFAULT_GRAPHICS_MODE, "BOTH") ?: "BOTH"
}
fun setDefaultGraphicsMode(context: Context, mode: String) {
getPrefs(context).edit().putString(KEY_DEFAULT_GRAPHICS_MODE, mode).apply()
}
}
@@ -1,126 +0,0 @@
package org.pubvm.a3270.storage
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
data class SavedHost(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String,
val port: Int = 23,
val model: Int = 2,
val luName: String = "",
val autoConnect: Boolean = false,
val hostType: String = "TSO",
val useTls: Boolean = false,
val tlsVerifyCert: Boolean = true,
val tn3270e: Boolean = true,
val graphicsMode: String = "BOTH"
) {
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("name", name)
put("host", host)
put("port", port)
put("model", model)
put("luName", luName)
put("autoConnect", autoConnect)
put("hostType", hostType)
put("useTls", useTls)
put("tlsVerifyCert", tlsVerifyCert)
put("tn3270e", tn3270e)
put("graphicsMode", graphicsMode)
}
}
companion object {
fun fromJson(json: JSONObject): SavedHost {
return SavedHost(
id = json.optString("id", UUID.randomUUID().toString()),
name = json.optString("name", "Mainframe"),
host = json.optString("host", "127.0.0.1"),
port = json.optInt("port", 23),
model = json.optInt("model", 2),
luName = json.optString("luName", ""),
autoConnect = json.optBoolean("autoConnect", false),
hostType = json.optString("hostType", "TSO"),
useTls = json.optBoolean("useTls", false),
tlsVerifyCert = json.optBoolean("tlsVerifyCert", true),
tn3270e = json.optBoolean("tn3270e", true),
graphicsMode = json.optString("graphicsMode", "BOTH")
)
}
}
}
object HostStorage {
private const val PREFS_NAME = "a3270_host_prefs"
private const val KEY_SAVED_HOSTS = "saved_hosts"
private fun getPrefs(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun getSavedHosts(context: Context): List<SavedHost> {
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
val list = mutableListOf<SavedHost>()
try {
val jsonArray = JSONArray(jsonStr)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val saved = SavedHost.fromJson(obj)
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
saved.copy(graphicsMode = "BOTH")
} else {
saved
}
list.add(effective)
}
} catch (e: Exception) {
e.printStackTrace()
}
return list
}
fun saveHost(context: Context, host: SavedHost) {
val currentHosts = getSavedHosts(context).toMutableList()
val index = currentHosts.indexOfFirst { it.id == host.id }
// If this host is set to autoConnect, unset autoConnect on all others
val updatedHost = if (host.autoConnect) {
currentHosts.indices.forEach { i ->
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
}
host
} else {
host
}
if (index >= 0) {
currentHosts[index] = updatedHost
} else {
currentHosts.add(updatedHost)
}
saveAll(context, currentHosts)
}
fun deleteHost(context: Context, hostId: String) {
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
saveAll(context, currentHosts)
}
fun getAutoConnectHost(context: Context): SavedHost? {
return getSavedHosts(context).firstOrNull { it.autoConnect }
}
private fun saveAll(context: Context, hosts: List<SavedHost>) {
val array = JSONArray()
hosts.forEach { array.put(it.toJson()) }
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
}
}
@@ -1,258 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ft.FTConfig
import org.pubvm.a3270.FTProgressState
@Composable
fun FileTransferDialog(
initialHostType: String = "TSO",
ftProgressState: FTProgressState = FTProgressState(),
onDismiss: () -> Unit,
onStartTransfer: (FTConfig) -> Unit,
onCancelTransfer: () -> Unit = {}
) {
val initialTypeEnum = when (initialHostType.uppercase()) {
"CMS", "VM/CMS", "VM" -> FTConfig.HostType.CMS
"CICS" -> FTConfig.HostType.CICS
else -> FTConfig.HostType.TSO
}
var hostFile by remember { mutableStateOf("") }
var localFile by remember { mutableStateOf("") }
var isReceive by remember { mutableStateOf(true) }
var isAscii by remember { mutableStateOf(true) }
var hostType by remember { mutableStateOf(initialTypeEnum) }
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
var lreclStr by remember { mutableStateOf("") }
var blksizeStr by remember { mutableStateOf("") }
var overwrite by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Active Transfer Progress Banner
if (ftProgressState.isActive || ftProgressState.isRunning) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFF25262B)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = ftProgressState.statusMessage.ifBlank { "Transferring..." },
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
if (ftProgressState.bytesTransferred > 0) {
Text(
text = "${ftProgressState.bytesTransferred} bytes transferred",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onCancelTransfer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC92A2A)),
modifier = Modifier.align(Alignment.End)
) {
Text("Cancel Transfer", fontSize = 11.sp)
}
}
}
HorizontalDivider(color = Color(0xFF373A40))
} else if (ftProgressState.statusMessage.isNotBlank()) {
Text(
text = ftProgressState.statusMessage,
fontSize = 12.sp,
color = if (ftProgressState.isError) Color(0xFFFF6B6B) else Color(0xFF51CF66)
)
}
OutlinedTextField(
value = hostFile,
onValueChange = { hostFile = it },
label = { Text("Host Dataset / File Name") },
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = localFile,
onValueChange = { localFile = it },
label = { Text("Local File Name / Path") },
placeholder = { Text("sample.txt") },
supportingText = { Text("Relative names will be stored in app Downloads/Files storage", fontSize = 10.sp) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Transfer Direction
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isReceive,
onClick = { isReceive = true },
label = { Text("Receive (GET)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isReceive,
onClick = { isReceive = false },
label = { Text("Send (PUT)", fontSize = 11.sp) }
)
}
}
// Transfer Mode
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FilterChip(
selected = isAscii,
onClick = { isAscii = true },
label = { Text("ASCII (Text)", fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
FilterChip(
selected = !isAscii,
onClick = { isAscii = false },
label = { Text("Binary", fontSize = 11.sp) }
)
}
}
// Host Type (TSO, CMS, CICS)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Row {
FTConfig.HostType.values().forEach { ht ->
FilterChip(
selected = (hostType == ht),
onClick = { hostType = ht },
label = { Text(ht.name, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
// TSO Send Options
if (!isReceive && hostType == FTConfig.HostType.TSO) {
Text("TSO Dataset Allocation:", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Record Format:", fontSize = 11.sp)
Row {
listOf(
FTConfig.RecordFormat.DEFAULT to "Def",
FTConfig.RecordFormat.FIXED to "F",
FTConfig.RecordFormat.VARIABLE to "V"
).forEach { (rf, label) ->
FilterChip(
selected = (recfm == rf),
onClick = { recfm = rf },
label = { Text(label, fontSize = 11.sp) }
)
Spacer(modifier = Modifier.width(4.dp))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lreclStr,
onValueChange = { lreclStr = it },
label = { Text("LRECL", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = blksizeStr,
onValueChange = { blksizeStr = it },
label = { Text("BLKSIZE", fontSize = 11.sp) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
val config = FTConfig().apply {
setHostFilename(hostFile.trim())
setLocalFilename(localFile.trim())
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
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.toIntOrNull()?.let { setLrecl(it) }
blksizeStr.toIntOrNull()?.let { setBlksize(it) }
}
}
onStartTransfer(config)
}
},
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
) {
Text("Start Transfer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@@ -1,233 +0,0 @@
package org.pubvm.a3270.ui
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
import org.lib3270j.protocol.DS3270Constants.*
@Composable
fun TwoRowKeyBar(
connectionState: ConnectionState,
isShiftPressed: Boolean = false,
hapticFeedbackEnabled: Boolean = true,
onClearShift: () -> Unit = {},
onConnectClick: () -> Unit,
onDisconnectClick: () -> Unit,
onFtClick: () -> Unit,
onSettingsClick: () -> Unit = {},
onSendAid: (Int) -> Unit,
onReset: () -> Unit,
onTab: () -> Unit,
onBackTab: () -> Unit,
onCursorLeft: () -> Unit = {},
onCursorUp: () -> Unit = {},
onCursorDown: () -> Unit = {},
onCursorRight: () -> Unit = {},
modifier: Modifier = Modifier
) {
var menuExpanded by remember { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0xFF161719))
) {
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
Box(modifier = Modifier.weight(1f)) {
KeyButton(
label = "⋮",
color = Color(0xFF343A40),
modifier = Modifier.fillMaxWidth(),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = { menuExpanded = true }
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color(0xFF2C2D30))
) {
if (!connectionState.isConnected()) {
DropdownMenuItem(
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onConnectClick()
}
)
} else {
DropdownMenuItem(
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onDisconnectClick()
}
)
DropdownMenuItem(
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onFtClick()
}
)
}
DropdownMenuItem(
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
onClick = {
menuExpanded = false
onSettingsClick()
}
)
}
}
// 2. TAB / BTAB depending on Shift state
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
KeyButton(
label = tabLabel,
color = Color(0xFF1C7ED6),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
if (isShiftPressed) {
onBackTab()
onClearShift()
} else {
onTab()
}
}
)
// 3. RESET
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
// 4. ENTER
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
// 5. CLEAR
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
// 6. PA1
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
// 7. PA2
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
// 8. PA3
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
// 9. Left Navigation
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
// 10. Up Navigation
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
// 11. Down Navigation
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
// 12. Right Navigation
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
}
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
Row(
modifier = Modifier
.fillMaxWidth()
.height(35.dp)
.padding(horizontal = 1.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically
) {
val pfAids = intArrayOf(
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
)
val startPf = if (isShiftPressed) 13 else 1
val endPf = if (isShiftPressed) 24 else 12
for (i in startPf..endPf) {
val aid = pfAids[i - 1]
KeyButton(
label = "F$i",
color = Color(0xFF364FC7),
modifier = Modifier.weight(1f),
hapticFeedbackEnabled = hapticFeedbackEnabled,
innerPaddingHorizontal = 0.dp,
onClick = {
onSendAid(aid)
if (isShiftPressed) {
onClearShift()
}
}
)
}
}
}
}
@Composable
private fun KeyButton(
label: String,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
hapticFeedbackEnabled: Boolean = true,
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
) {
val view = LocalView.current
Surface(
onClick = {
if (hapticFeedbackEnabled) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
onClick()
},
shape = RoundedCornerShape(4.dp),
color = color,
shadowElevation = 1.dp,
modifier = modifier
.padding(horizontal = 1.dp, vertical = 1.dp)
.height(31.dp)
.focusProperties { canFocus = false }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
) {
Text(
text = label,
color = Color.White,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
@@ -1,149 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.lib3270j.ConnectionState
@Composable
fun OiaStatusBar(
connectionState: ConnectionState,
currentHost: String,
isKeyboardLocked: Boolean,
oiaText: String,
cursorAddr: Int,
rows: Int,
cols: Int,
isTls: Boolean = false,
isTlsVerified: Boolean = true,
graphicsMode: String = "NONE",
modifier: Modifier = Modifier
) {
val row = 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 hostText = if (currentHost.isNotBlank()) currentHost else oiaText
val lockStatusText = if (!connectionState.isConnected()) {
"OFFLINE"
} else if (isKeyboardLocked) {
"X SYSTEM"
} else {
"READY"
}
val lockStatusColor = if (!connectionState.isConnected()) {
Color(0xFF868E96)
} else if (isKeyboardLocked) {
Color(0xFFFF6B6B) // Red for locked
} else {
Color(0xFF51CF66) // Green for ready
}
Row(
modifier = modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF161719))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Connected Host & Status Indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = hostText,
color = Color.White,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 1
)
// TLS Badge
if (connectionState.isConnected() && isTls) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = if (isTlsVerified) Color(0xFF51CF66).copy(alpha = 0.2f) else Color(0xFFFFB450).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = if (isTlsVerified) "🔒 TLS" else "🔓 TLS",
color = if (isTlsVerified) Color(0xFF51CF66) else Color(0xFFFFB450),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
// GFX Mode Badge
if (connectionState.isConnected() && graphicsMode != "NONE" && graphicsMode.isNotBlank()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
color = Color(0xFF22B8CF).copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (graphicsMode) {
"VECTOR_GRAPHICS" -> "GOCA"
"PROGRAMMED_SYMBOLS" -> "PS"
else -> "GFX"
},
color = Color(0xFF22B8CF),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
Surface(
color = lockStatusColor.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = lockStatusText,
color = lockStatusColor,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Cursor Position
Text(
text = "R$posStr",
color = Color(0xFF22B8CF),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
@@ -1,268 +0,0 @@
package org.pubvm.a3270.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun SettingsDialog(
initialMaskHiddenInput: Boolean,
initialCursorBlink: Boolean,
initialHapticFeedback: Boolean = true,
initialVerifyCerts: Boolean = true,
initialDefaultGraphicsMode: String = "BOTH",
onDismiss: () -> Unit,
onSave: (maskHiddenInput: Boolean, cursorBlink: Boolean, hapticFeedback: Boolean, verifyCerts: Boolean, defaultGraphicsMode: String) -> Unit
) {
var maskHiddenInput by remember { mutableStateOf(initialMaskHiddenInput) }
var cursorBlink by remember { mutableStateOf(initialCursorBlink) }
var hapticFeedback by remember { mutableStateOf(initialHapticFeedback) }
var verifyCerts by remember { mutableStateOf(initialVerifyCerts) }
var defaultGraphicsMode by remember { mutableStateOf(initialDefaultGraphicsMode) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1E1E1E)),
modifier = Modifier
.fillMaxWidth(0.95f)
.heightIn(max = 680.dp)
.padding(vertical = 16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
Text(
text = "Terminal Settings",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
// Setting 1: Mask Protected/Hidden Input with '*'
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Show '*' for Hidden Fields",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Display asterisks for typed characters in non-display / password fields rather than blank spaces.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = maskHiddenInput,
onCheckedChange = { maskHiddenInput = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 2: Blinking Cursor
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Blinking Cursor",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Blink the terminal cursor to clearly indicate current screen position.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = cursorBlink,
onCheckedChange = { cursorBlink = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 3: Button Haptic Feedback
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Button Haptic Feedback",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Vibrate softly when tapping function and navigation buttons to match keyboard tactile feedback.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = hapticFeedback,
onCheckedChange = { hapticFeedback = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 4: Overall TLS Certificate Verification
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Verify TLS Certificates",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Enforce SSL/TLS certificate validation. Turn off to allow self-signed or unverified certificates without security prompts.",
fontSize = 12.sp,
color = Color.LightGray
)
}
Switch(
checked = verifyCerts,
onCheckedChange = { verifyCerts = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF2B8A3E)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFF2C2D30))
Spacer(modifier = Modifier.height(16.dp))
// Setting 5: Default Graphics Support Mode
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Default Graphics Mode",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = "Configure default vector graphics (GOCA) and Programmed Symbols (APL) mode.",
fontSize = 12.sp,
color = Color.LightGray
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"BOTH" to "Both",
"VECTOR_GRAPHICS" to "Vector",
"PROGRAMMED_SYMBOLS" to "Symbols",
"NONE" to "Off"
).forEach { (modeKey, modeLabel) ->
val isSelected = defaultGraphicsMode.equals(modeKey, ignoreCase = true)
Surface(
shape = RoundedCornerShape(6.dp),
color = if (isSelected) Color(0xFF2B8A3E) else Color(0xFF2C2D30),
modifier = Modifier
.weight(1f)
.height(34.dp)
.clickable { defaultGraphicsMode = modeKey }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
) {
Text(
text = modeLabel,
color = Color.White,
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.LightGray)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
onSave(maskHiddenInput, cursorBlink, hapticFeedback, verifyCerts, defaultGraphicsMode)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
) {
Text("Save", color = Color.White)
}
}
}
}
}
}
@@ -1,574 +0,0 @@
package org.pubvm.a3270.ui
import android.graphics.Paint
import android.graphics.Typeface
import android.widget.Toast
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ExtendedAttribute
import org.lib3270j.screen.ScreenBuffer
// Standard 3279 Host Colors (16-color palette)
private val HOST_COLORS = arrayOf(
Color(0xFF000000), // 0: Neutral Black
Color(0xFF5078FF), // 1: Blue
Color(0xFFFF3232), // 2: Red
Color(0xFFFF82B4), // 3: Pink
Color(0xFF32CD32), // 4: Green
Color(0xFF40E0D0), // 5: Turquoise
Color(0xFFFFFF50), // 6: Yellow
Color(0xFFFFFFFF), // 7: Neutral White
Color(0xFF000000), // 8: Black
Color(0xFF1E3CB4), // 9: Deep Blue
Color(0xFFFFA500), // 10: Orange
Color(0xFFB482FF), // 11: Purple
Color(0xFF90EE90), // 12: Pale Green
Color(0xFFAFEEEE), // 13: Pale Turquoise
Color(0xFFAAAAAA), // 14: Grey
Color(0xFFFFFFFF) // 15: White
)
private val COLOR_BLACK = Color(0xFF0A0A0A)
@Composable
fun TerminalView(
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
cursorAddr: Int,
screenVersion: Long,
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
maskHiddenFields: Boolean = true,
blinkCursor: Boolean = true,
onTapAddress: (Int) -> Unit,
onPasteText: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val density = LocalDensity.current
val currentScreenVersion by rememberUpdatedState(screenVersion)
val currentCursorAddr by rememberUpdatedState(cursorAddr)
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
var showContextMenu by remember { mutableStateOf(false) }
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
// Blinking cursor state (~530ms interval matching j3270)
var cursorVisible by remember { mutableStateOf(true) }
LaunchedEffect(cursorAddr, blinkCursor) {
if (!blinkCursor) {
cursorVisible = true
return@LaunchedEffect
}
cursorVisible = true
while (true) {
delay(530L)
cursorVisible = !cursorVisible
}
}
Box(modifier = modifier.fillMaxSize()) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(COLOR_BLACK)
.pointerInput(cols, rows) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val startTime = System.currentTimeMillis()
val startPos = down.position
var currentPos = startPos
var isDragStarted = false
val slop = viewConfiguration.touchSlop
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) {
break
}
currentPos = change.position
val distance = (currentPos - startPos).getDistance()
if (distance > slop) {
if (!isDragStarted) {
isDragStarted = true
selectionStart = startPos
showContextMenu = false
}
selectionEnd = currentPos
change.consume()
}
}
val duration = System.currentTimeMillis() - startTime
val totalDistance = (currentPos - startPos).getDistance()
if (isDragStarted) {
// Drag completed: Keep selection box highlighted, DO NOT open context menu
showContextMenu = false
} else if (duration >= 600L && totalDistance <= slop) {
// Stationary Long Press (held >= 600ms without dragging)
contextMenuOffset = startPos
showContextMenu = true
} else if (totalDistance <= slop) {
// Short Tap (Move Cursor)
showContextMenu = false
selectionStart = null
selectionEnd = null
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
var addr = row * cols + col
val buf = screenBuffer
if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
addr = buf.findNextUnprotected(addr)
}
}
onTapAddress(addr)
}
}
}
}
}
) {
val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth
val cellHeight = metrics.cellHeight
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
val measuredW = paint.measureText("W")
if (measuredW > cellWidth * 0.85f && measuredW > 0f) {
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
}
val buf = screenBuffer
val totalCells = rows * cols
var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null
// Calculate selected cell rectangle range if drag selection active
var selMinRow = -1
var selMaxRow = -1
var selMinCol = -1
var selMaxCol = -1
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)
selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow)
selMinCol = minOf(startCol, endCol)
selMaxCol = maxOf(startCol, endCol)
}
for (r in 0 until rows) {
for (c in 0 until cols) {
val addr = r * cols + c
if (addr >= totalCells) break
val left = offsetX + c * cellWidth
val top = offsetY + r * cellHeight
var charVal = ' '
var fgColor: Color
var bgColor = COLOR_BLACK
var isBold = false
var isUnderline = false
var isReverse = false
var csVal = 0
var ecVal = 0
if (buf != null) {
val ea = buf.getCell(addr)
if (ea.isFieldAttribute) {
currentFA = ea.fa
currentFieldEa = ea
continue
}
// Compute 3270 color and field intensity
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
bgColor = getBgColorForAttribute(ea, currentFieldEa)
csVal = if (ea.cs != 0.toByte()) (ea.cs.toInt() and 0xFF)
else (currentFieldEa?.cs?.toInt()?.and(0xFF) ?: 0)
ecVal = ea.ec.toInt() and 0xFF
// Intensity
if (faIsHigh(currentFA.toInt() and 0xFF)) {
isBold = true
}
// Invisible fields (passwords / hidden inputs)
if (faIsZero(currentFA.toInt() and 0xFF)) {
if (maskHiddenFields && ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = '*'
} else {
// Blanks out character
charVal = ' '
}
} else {
// Normal visible character
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
charVal = ea.ucs4
}
}
// Extended Graphic Rendition
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
if (gr != 0.toByte()) {
val grVal = gr.toInt() and 0xFF
if ((grVal and GR_INTENSIFY) != 0) isBold = true
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
if ((grVal and GR_REVERSE) != 0) isReverse = true
}
} else {
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
}
if (isReverse) {
val tmp = fgColor
fgColor = bgColor
bgColor = tmp
}
if (bgColor != COLOR_BLACK) {
drawRect(
color = bgColor,
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Draw selection box highlight if selected
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
if (isSelected) {
drawRect(
color = Color(0x773399FF),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Cursor indicator (respects blinking toggle & timer)
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
drawRect(
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
topLeft = Offset(left, top),
size = Size(cellWidth, cellHeight)
)
}
// Draw Programmed Symbol (PS / APL) if defined
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 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)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(left, top, left + cellWidth, top + cellHeight),
null
)
drawnAsPs = true
}
}
}
if (!drawnAsPs && charVal != ' ') {
paint.color = fgColor.toArgb()
paint.isFakeBoldText = isBold
val fontMetrics = paint.fontMetrics
val textX = left + cellWidth / 2f
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
drawContext.canvas.nativeCanvas.drawText(
charVal.toString(),
textX,
textY,
paint
)
}
if (isUnderline) {
drawLine(
color = fgColor,
start = Offset(left, top + cellHeight - 2f),
end = Offset(left + cellWidth, top + cellHeight - 2f),
strokeWidth = 2f
)
}
}
}
// Draw Vector Graphics Plane overlay if present
if (graphicsPlane != null && graphicsPlane.hasContent()) {
val gridW = metrics.gridWidth.toInt()
val gridH = metrics.gridHeight.toInt()
if (gridW > 0 && gridH > 0) {
graphicsPlane.resize(gridW, gridH)
val rgb = graphicsPlane.rgbBuffer
if (rgb != null) {
val bmp = android.graphics.Bitmap.createBitmap(rgb, gridW, gridH, android.graphics.Bitmap.Config.ARGB_8888)
drawContext.canvas.nativeCanvas.drawBitmap(
bmp,
null,
android.graphics.RectF(offsetX, offsetY, offsetX + gridW.toFloat(), offsetY + gridH.toFloat()),
null
)
}
}
}
}
// Context Menu Popup
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(
x = (contextMenuOffset.x / density.density).dp,
y = (contextMenuOffset.y / density.density).dp
)
) {
val hasSelection = selectionStart != null && selectionEnd != null
if (hasSelection) {
DropdownMenuItem(
text = { Text("Copy Selection") },
onClick = {
showContextMenu = false
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
}
)
DropdownMenuItem(
text = { Text("Clear Selection") },
onClick = {
showContextMenu = false
selectionStart = null
selectionEnd = null
}
)
} else {
DropdownMenuItem(
text = { Text("Select All") },
onClick = {
showContextMenu = false
selectionStart = Offset(0f, 0f)
selectionEnd = Offset(100000f, 100000f)
}
)
}
DropdownMenuItem(
text = { Text("Paste") },
onClick = {
showContextMenu = false
val clipText = clipboardManager.getText()?.text
if (!clipText.isNullOrEmpty()) {
onPasteText(clipText)
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
}
}
)
}
}
}
data class TerminalGridMetrics(
val cellWidth: Float,
val cellHeight: Float,
val gridWidth: Float,
val gridHeight: Float,
val offsetX: Float,
val offsetY: Float
)
fun calculateGridMetrics(
viewWidth: Float,
viewHeight: Float,
cols: Int,
rows: Int
): TerminalGridMetrics {
if (viewWidth <= 0f || viewHeight <= 0f || cols <= 0 || rows <= 0) {
return TerminalGridMetrics(0f, 0f, 0f, 0f, 0f, 0f)
}
val rawCellW = viewWidth / cols
val rawCellH = viewHeight / rows
val currentRatio = rawCellW / rawCellH
// Target terminal cell aspect ratio is roughly 0.50 to 0.60 (width / height)
val targetMinRatio = 0.48f
val targetMaxRatio = 0.60f
val cellW: Float
val cellH: Float
if (currentRatio > targetMaxRatio) {
// Screen is wider than ideal aspect ratio -> clamp width, pillarbox (equal blank bars left/right)
cellH = rawCellH
cellW = rawCellH * targetMaxRatio
} else if (currentRatio < targetMinRatio) {
// Screen is taller than ideal aspect ratio -> clamp height, letterbox (equal blank bars top/bottom)
cellW = rawCellW
cellH = rawCellW / targetMinRatio
} else {
cellW = rawCellW
cellH = rawCellH
}
val gridW = cols * cellW
val gridH = rows * cellH
val offX = (viewWidth - gridW) / 2f
val offY = (viewHeight - gridH) / 2f
return TerminalGridMetrics(cellW, cellH, gridW, gridH, offX, offY)
}
private fun copySelection(
start: Offset?,
end: Offset?,
screenBuffer: ScreenBuffer?,
rows: Int,
cols: Int,
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
context: android.content.Context
) {
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
val displayMetrics = context.resources.displayMetrics
val viewW = displayMetrics.widthPixels.toFloat()
val viewH = displayMetrics.heightPixels.toFloat()
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
val minRow = minOf(startRow, endRow)
val maxRow = maxOf(startRow, endRow)
val minCol = minOf(startCol, endCol)
val maxCol = maxOf(startCol, endCol)
val sb = StringBuilder()
for (r in minRow..maxRow) {
val line = StringBuilder()
for (c in minCol..maxCol) {
val addr = r * cols + c
if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
line.append(cell.ucs4)
} else {
line.append(' ')
}
}
}
sb.append(line.toString().trimEnd()).append('\n')
}
val textToCopy = sb.toString().trimEnd()
if (textToCopy.isNotBlank()) {
clipboardManager.setText(AnnotatedString(textToCopy))
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
}
}
}
private 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
if (fg in 0xf0..0xff) {
return HOST_COLORS[fg - 0xf0]
}
val fa = currentFA.toInt() and 0xFF
return if (faIsProtected(fa)) {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
} else {
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
}
}
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
else 0
if (bg in 0xf0..0xff) {
val idx = bg - 0xf0
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
return COLOR_BLACK
}
return HOST_COLORS[idx]
}
return COLOR_BLACK
}
private fun Color.toArgb(): Int {
return (alpha * 255).toInt() shl 24 or
((red * 255).toInt() shl 16) or
((green * 255).toInt() shl 8) or
(blue * 255).toInt()
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#000000"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.029365"
android:scaleY="-0.029365"
android:translateX="-8.57"
android:translateY="103.38">
<!-- Green Terminal Screen Frame -->
<path
android:strokeColor="#00FF00"
android:strokeWidth="40"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 1177.27,2220.71 V 1142.45 H 3084.31 V 2220.71 H 1177.27" />
<!-- Blue Wave / Exponential Curve -->
<path
android:strokeColor="#0000FF"
android:strokeWidth="60"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 1172.41,1137.58 79.1,-1.22 -0.21,-0.38 c 1.08,0 2.18,0 3.27,0 762.95,0 1464.86,417.88 1829.53,1089.21" />
<!-- Red Circular Arc -->
<path
android:strokeColor="#FF0000"
android:strokeWidth="60"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2593.86,1620.73 -1.22,-28 -0.08,-0.08 c 0.43,-250.15 202.81,-452.72 452.3,-452.72 0.14,0 0.28,0 0.42,0 l 43.9,2.52" />
<!-- Yellow Smiley Head -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2976,1363.94 v 0.08 c 44.73,34.29 71.21,87.25 71.8,143.61 l 0.01,-0.15 c -0.06,97.48 -79.11,176.47 -176.59,176.47 -0.36,0 -0.73,0 -1.09,0 l -0.07,0.03 c -0.25,0 -0.51,0 -0.75,0 -37.38,0 -73.79,-11.94 -103.92,-34.08 l 0.02,-0.03 c -44.38,-34.04 -70.82,-86.47 -71.8,-142.39 l 0.06,0.04 c 0.54,-97.43 79.04,-176.47 176.46,-177.69 l 0.08,0.02 c 0.64,-0.01 1.29,-0.01 1.92,-0.01 37.4,0 73.82,11.94 103.96,34.08" />
<!-- Magenta Typography: lib3270j -->
<!-- 'l' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1502.21,1785.02 h 23.13 v 32.86 h -23.13 l -4.87,8.52 v 154.56 h -18.25 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52" />
<!-- 'i' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1566.71,1785.02 h 13.39 v 32.86 h -13.39 l -4.86,8.52 v 105.88 h -18.26 V 1826.4 l 4.87,-25.56 4.87,-7.3 13.38,-8.52 m -4.86,163.08 v 32.86 h -18.26 v -32.86 h 18.26" />
<!-- 'b' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1598.36,1785.02 v 195.94 h 18.25 v -48.68 h 41.38 l 13.39,-8.52 4.87,-8.52 4.86,-24.34 v -64.5 l -4.86,-25.56 -4.87,-7.3 -13.39,-8.52 h -59.63 m 18.25,32.86 h 41.38 l 4.87,8.52 v 64.5 l -4.87,8.52 h -41.38 v -81.54" />
<!-- '3' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1722.49,1817.88 h -23.12 v -32.86 h 69.36 l 13.4,8.52 4.86,7.3 3.65,25.56 v 40.16 l -8.51,15.82 8.51,17.04 v 40.16 l -3.65,24.34 -4.86,8.52 -13.4,8.52 h -69.36 v -32.86 h 69.36 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24 v -32.86 h 46.24 l 3.66,-8.52 v -31.64 l -3.66,-8.52 h -46.24" />
<!-- '2' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1808.9,1785.02 h 92.49 v 32.86 h -73.02 v 32.86 l 3.65,7.3 h 46.25 l 13.38,8.52 4.87,8.52 4.87,24.34 v 40.16 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -46.25 l -13.39,-8.52 -4.86,-8.52 -4.87,-24.34 v -24.34 h 19.47 v 24.34 l 3.65,8.52 h 46.25 l 4.86,-8.52 v -40.16 l -4.86,-8.52 h -46.25 l -13.39,-8.52 -4.86,-7.3 -4.87,-24.34 v -65.72" />
<!-- '7' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 1919.64,1980.96 h 91.28 v -65.72 l -49.9,-130.22 h -23.12 l 54.76,138.74 v 24.34 h -73.02 v 32.86" />
<!-- '0' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 2047.43,1972.44 -4.87,-8.52 -3.65,-24.34 V 1826.4 l 3.65,-25.56 4.87,-7.3 14.61,-8.52 h 31.64 l 13.38,8.52 4.87,7.3 4.87,25.56 v 113.18 l -4.87,24.34 -4.87,8.52 -13.38,8.52 h -31.64 l -14.61,-8.52 m 14.61,-24.34 h 31.64 l 4.86,-8.52 V 1826.4 l -4.86,-8.52 h -31.64 l -4.88,8.52 v 113.18 l 4.88,8.52" />
<!-- 'j' -->
<path
android:fillColor="#FF00FF"
android:pathData="m 2176.43,1948.1 v 32.86 h -18.25 v -32.86 h 18.25 m 0,-15.82 V 1769.2 l -4.87,-25.56 -4.86,-7.3 -13.39,-8.52 h -18.26 v 32.86 h 18.26 l 4.87,8.52 v 163.08 h 18.25" />
<!-- Smiley Details -->
<!-- Mouth -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2778.85,1478.34 0.04,-0.02 c 21.03,-31.91 56.69,-51.12 94.91,-51.12 0.41,0 0.83,0 1.24,0.01 l -0.02,0.07 c 43.88,0.19 84.11,24.51 104.66,63.28" />
<!-- Left Eye -->
<path
android:strokeColor="#00FFFF"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2820.22,1595.17 0.01,0.01 c -14.62,-1.91 -25.56,-14.37 -25.56,-29.11 0,-0.03 0,-0.07 0,-0.1 l -0.02,-0.01 c 0,-0.03 0,-0.07 0,-0.1 0,-16.09 13.04,-29.14 29.13,-29.14 0.43,0 0.87,0.01 1.29,0.03 h 3.67 0.01 c 14.61,1.91 25.55,14.37 25.55,29.11 0,0.03 0,0.07 0,0.1 l 0.02,0.01 c 0,0.04 0,0.08 0,0.12 0,16.07 -13.03,29.09 -29.09,29.09 -0.03,0 -0.08,0 -0.12,0 l -4.89,-0.01" />
<!-- Right Eye -->
<path
android:strokeColor="#00FFFF"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2921.23,1569.61 v -3.65 l -0.01,-0.01 c 0,-0.27 -0.01,-0.54 -0.01,-0.81 0,-18.22 14.66,-33.05 32.87,-33.26 l 0.03,0.01 c 0.28,0 0.57,-0.01 0.84,-0.01 17.29,0 31.72,13.22 33.24,30.44 l -0.02,3.64 0.02,0.02 c 0,0.27 0.01,0.54 0.01,0.81 0,18.37 -14.9,33.28 -33.28,33.28 -0.26,0 -0.54,-0.01 -0.81,-0.02 l -0.02,-0.03 c -17.14,-0.18 -31.36,-13.34 -32.86,-30.42" />
<!-- Nose -->
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2892.03,1528.23 -29.21,-20.68" />
<path
android:strokeColor="#FFFF00"
android:strokeWidth="50"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="m 2862.82,1507.55 h 32.86" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_background" />
<item android:drawable="@drawable/ic_launcher_foreground" />
</layer-list>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="exports" path="exports/" />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
</paths>
@@ -0,0 +1,109 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.UUID
class CodePageAndStorageTest {
@Test
fun testSavedHostCodePageSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Test Mainframe",
host = "192.168.1.100",
port = 23,
model = 4,
luName = "LU3270",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.example.com",
proxyPort = 1080,
proxyUsername = "testuser",
proxyPassword = "secretpassword"
)
val json = host.toJson()
assertEquals("1047", json.getString("codePage"))
assertEquals("Test Mainframe", json.getString("name"))
assertTrue(json.getBoolean("autoConnect"))
assertEquals("SOCKS5", json.getString("proxyType"))
assertEquals("proxy.example.com", json.getString("proxyHost"))
assertEquals(1080, json.getInt("proxyPort"))
assertEquals("testuser", json.getString("proxyUsername"))
assertEquals("secretpassword", json.getString("proxyPassword"))
val deserialized = SavedHost.fromJson(json)
assertEquals(host.id, deserialized.id)
assertEquals(host.name, deserialized.name)
assertEquals(host.host, deserialized.host)
assertEquals(host.port, deserialized.port)
assertEquals(host.model, deserialized.model)
assertEquals(host.codePage, deserialized.codePage)
assertEquals(host.graphicsMode, deserialized.graphicsMode)
assertEquals(host.proxyType, deserialized.proxyType)
assertEquals(host.proxyHost, deserialized.proxyHost)
assertEquals(host.proxyPort, deserialized.proxyPort)
assertEquals(host.proxyUsername, deserialized.proxyUsername)
assertEquals(host.proxyPassword, deserialized.proxyPassword)
}
@Test
fun testSavedHostDefaultCodePageFallback() {
// Old JSON without codePage or proxy fields
val json = org.json.JSONObject().apply {
put("name", "Legacy")
put("host", "mvs.local")
}
val deserialized = SavedHost.fromJson(json)
assertEquals("037", deserialized.codePage)
assertEquals("NONE", deserialized.proxyType)
assertEquals("", deserialized.proxyHost)
assertEquals(0, deserialized.proxyPort)
}
@Test
fun testCodePageRegistryResolution() {
val cp037 = CodePageRegistry.getCodePage("037")
assertNotNull(cp037)
assertEquals("037", cp037.codePageId)
val cp1047 = CodePageRegistry.getCodePage("1047")
assertNotNull(cp1047)
assertEquals("1047", cp1047.codePageId)
val cp930 = CodePageRegistry.getCodePage("930")
assertNotNull(cp930)
assertTrue(cp930.isDBCS)
val cp1140 = CodePageRegistry.getCodePage("1140")
assertNotNull(cp1140)
}
@Test
fun testEbcdicTranslatorCodePageSwitching() {
val translator = EbcdicTranslator("037")
assertEquals("037", translator.codePageId)
val translator1047 = EbcdicTranslator("1047")
assertEquals("1047", translator1047.codePageId)
val model = TerminalModel.IBM_3279_2
val sb = ScreenBuffer(model, translator)
assertEquals('A', translator.ebcdicToUnicode(0xC1))
sb.getCell(0).ec = 0xC1.toByte()
sb.translateToUnicode()
assertEquals('A', sb.getCell(0).ucs4)
}
}
@@ -0,0 +1,318 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.a3270.storage.SavedHost
import haus.nightmare.a3270.ui.ConnectChoice
import haus.nightmare.a3270.ui.LastTransferState
import haus.nightmare.a3270.ui.formatCMSFilter
import haus.nightmare.a3270.ui.matchesCmsFilemode
import haus.nightmare.a3270.ui.matchesCmsFilter
import haus.nightmare.a3270.ui.matchesFilter
import haus.nightmare.a3270.ui.matchesTsoFilter
import haus.nightmare.a3270.ui.matchesWildcard
import haus.nightmare.a3270.ui.resolveDefaultQuery
import haus.nightmare.lib3270j.ConnectionConfig
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.ft.FTConfig
import haus.nightmare.lib3270j.ft.dir.CMSDirectoryEntry
import haus.nightmare.lib3270j.ft.dir.TSODirectoryEntry
import haus.nightmare.lib3270j.graphics.GraphicsMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.StringReader
import java.util.UUID
class ConnectionAndHostDirectoryTest {
@Test
fun testSavedHostToConnectionConfigPreservesMetadata() {
val hostId = UUID.randomUUID().toString()
val host = SavedHost(
id = hostId,
name = "MVS Production LPAR",
host = "mvs.prod.corp",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU3270A",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "mvsadmin",
proxyPassword = "mypassword"
)
val config = host.toConnectionConfig()
assertEquals("mvs.prod.corp", config.host)
assertEquals(992, config.port)
assertEquals(TerminalModel.IBM_3279_4, config.model)
assertEquals("LU3270A", config.luName)
assertTrue(config.isUseTls)
assertTrue(config.isTlsVerifyCert)
assertTrue(config.isTn3270eEnabled)
assertEquals(GraphicsMode.VECTOR_GRAPHICS, config.graphicsMode)
assertEquals("1047", config.codePage)
assertEquals(ConnectionConfig.ProxyType.SOCKS5, config.proxyType)
assertEquals("proxy.corp", config.proxyHost)
assertEquals(1080, config.proxyPort)
assertEquals("mvsadmin", config.proxyUsername)
assertEquals("mypassword", config.proxyPassword)
// Metadata preservation added in connection hardening
assertTrue("savedConnection flag must be true", config.isSavedConnection)
assertEquals(hostId, config.savedProfileId)
assertEquals("MVS Production LPAR", config.savedProfileName)
assertEquals("TSO", config.hostType)
assertEquals(FTConfig.HostType.TSO, config.ftHostType)
}
@Test
fun testSavedHostToConnectionConfigCmsHostType() {
val host = SavedHost(
id = "cms-1",
name = "VM/CMS Guest",
host = "vm.local",
port = 23,
hostType = "CMS"
)
val config = host.toConnectionConfig()
assertEquals("CMS", config.hostType)
assertEquals(FTConfig.HostType.CMS, config.ftHostType)
}
@Test
fun testResolveDefaultQuery() {
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, null))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, ""))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, " "))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "*"))
assertEquals("* * A", resolveDefaultQuery(FTConfig.HostType.CMS, "* * *"))
assertEquals("PROFILE EXEC A", resolveDefaultQuery(FTConfig.HostType.CMS, "PROFILE EXEC A"))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, null))
assertEquals("", resolveDefaultQuery(FTConfig.HostType.TSO, ""))
assertEquals("SYS1.*", resolveDefaultQuery(FTConfig.HostType.TSO, "SYS1.*"))
}
@Test
fun testFormatCMSFilter() {
assertEquals("* * A", formatCMSFilter(null))
assertEquals("* * A", formatCMSFilter(""))
assertEquals("* * A", formatCMSFilter(" "))
assertEquals("* * A", formatCMSFilter("*"))
assertEquals("* * A", formatCMSFilter("* * *"))
assertEquals("MYFILE * A", formatCMSFilter("MYFILE"))
assertEquals("MYFILE EXEC A", formatCMSFilter("MYFILE EXEC"))
assertEquals("MYFILE EXEC B", formatCMSFilter("MYFILE EXEC B"))
assertEquals("PROFILE EXEC A", formatCMSFilter("PROFILE.EXEC"))
assertEquals("PROFILE EXEC A", formatCMSFilter("'PROFILE.EXEC'"))
}
@Test
fun testMatchesWildcard() {
assertTrue(matchesWildcard("HELLO", "*"))
assertTrue(matchesWildcard("HELLO", "H*"))
assertTrue(matchesWildcard("HELLO", "H*O"))
assertTrue(matchesWildcard("HELLO", "H?LLO"))
assertTrue(matchesWildcard("HELLO", "?????"))
assertFalse(matchesWildcard("HELLO", "H?L"))
assertFalse(matchesWildcard("HELLO", "WORLD"))
assertTrue(matchesWildcard("DATA.SET.NAME", "*.NAME"))
assertTrue(matchesWildcard("DATA.SET.NAME", "DATA.*"))
assertFalse(matchesWildcard("DATA.SET.NAME", "SYS1.*"))
}
@Test
fun testMatchesCmsFilterAndFilemode() {
val entry = CMSDirectoryEntry("PROFILE", "EXEC", "A1").apply {
recfm = "F"
lrecl = 80
blksize = 80
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "* * *"))
assertTrue(matchesFilter(entry, "* * A"))
assertTrue(matchesFilter(entry, "PROFILE * A"))
assertTrue(matchesFilter(entry, "PROFILE EXEC *"))
assertTrue(matchesFilter(entry, "PROFILE EXEC A1"))
assertTrue(matchesFilter(entry, "PROFILE.EXEC"))
assertTrue(matchesFilter(entry, "PROFILE"))
assertTrue(matchesFilter(entry, "EXEC"))
assertFalse(matchesFilter(entry, "* * B"))
assertFalse(matchesFilter(entry, "TEST * *"))
assertFalse(matchesFilter(entry, "*.COBOL"))
assertFalse(matchesFilter(entry, "PROFILE EXEC A2"))
assertTrue(matchesCmsFilemode("A1", "A"))
assertTrue(matchesCmsFilemode("A1", "A1"))
assertTrue(matchesCmsFilemode("A1", "*"))
assertFalse(matchesCmsFilemode("A1", "B"))
assertFalse(matchesCmsFilemode("A1", "A2"))
}
@Test
fun testMatchesTsoFilter() {
val entry = TSODirectoryEntry("USER.SOURCE.COBOL").apply {
volume = "VOL001"
dsorg = "PO"
}
assertTrue(matchesFilter(entry, null))
assertTrue(matchesFilter(entry, ""))
assertTrue(matchesFilter(entry, "*"))
assertTrue(matchesFilter(entry, "*SOURCE*"))
assertTrue(matchesFilter(entry, "USER.*.COBOL"))
assertTrue(matchesFilter(entry, "COBOL")) // substring search
assertTrue(matchesFilter(entry, "USER.SOURCE"))
assertFalse(matchesFilter(entry, "SYS1.*"))
assertFalse(matchesFilter(entry, "*.PLI"))
assertFalse(matchesFilter(entry, "FORTRAN"))
}
@Test
fun testAppSettingsIniHostTypeParsing() {
val testIni = """
[Connection]
hostType = CMS
[General]
defaultHostType = CICS
""".trimIndent()
var parsedHostType: String? = null
AppSettings.parseIni(StringReader(testIni)) { _, key, value ->
if (key.equals("hostType", ignoreCase = true) ||
key.equals("hosttype", ignoreCase = true) ||
key.equals("host_type", ignoreCase = true) ||
key.equals("defaulthosttype", ignoreCase = true)
) {
parsedHostType = value
}
}
assertNotNull(parsedHostType)
// First or second value parsed
assertTrue(parsedHostType.equals("CMS", ignoreCase = true) || parsedHostType.equals("CICS", ignoreCase = true))
}
@Test
fun testLastTransferStateUnitsAndDirBlocks() {
val state = LastTransferState()
assertEquals(FTConfig.HostType.TSO, state.hostType)
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("", state.dirBlocks)
state.units = FTConfig.AllocationUnit.TRACKS
state.dirBlocks = "20"
state.hostType = FTConfig.HostType.CMS
assertEquals(FTConfig.AllocationUnit.TRACKS, state.units)
assertEquals("20", state.dirBlocks)
assertEquals(FTConfig.HostType.CMS, state.hostType)
}
@Test
fun testConnectChoiceEnum() {
val choices = ConnectChoice.values()
assertEquals(3, choices.size)
assertTrue(choices.contains(ConnectChoice.NEW_WINDOW))
assertTrue(choices.contains(ConnectChoice.ABORT_CURRENT))
assertTrue(choices.contains(ConnectChoice.CANCEL))
}
@Test
fun testHostStorageExportAndLoadIniRoundtrip() {
val host1 = SavedHost(
id = "test-h1",
name = "Test Mainframe MVS",
host = "mvs.corp.local",
port = 992,
model = 4,
dynamicRows = 62,
dynamicCols = 160,
luName = "LU01",
autoConnect = true,
hostType = "TSO",
useTls = true,
tlsVerifyCert = false,
tn3270e = true,
graphicsMode = "VECTOR_GRAPHICS",
codePage = "1047",
proxyType = "SOCKS5",
proxyHost = "proxy.corp",
proxyPort = 1080,
proxyUsername = "admin",
proxyPassword = "secret"
)
val host2 = SavedHost(
id = "test-h2",
name = "VM CMS Guest",
host = "vm.corp.local",
port = 23,
hostType = "CMS"
)
val sw = java.io.StringWriter()
HostStorage.exportToIni(listOf(host1, host2), sw)
val exportedIni = sw.toString()
assertTrue(exportedIni.contains("[Test Mainframe MVS]"))
assertTrue(exportedIni.contains("host = mvs.corp.local"))
assertTrue(exportedIni.contains("port = 992"))
assertTrue(exportedIni.contains("autoConnect = true"))
assertTrue(exportedIni.contains("hostType = TSO"))
assertTrue(exportedIni.contains("useTls = true"))
assertTrue(exportedIni.contains("[VM CMS Guest]"))
assertTrue(exportedIni.contains("hostType = CMS"))
val loaded = HostStorage.loadFromIni(StringReader(exportedIni))
assertEquals(2, loaded.size)
val loadedH1 = loaded.find { it.name == "Test Mainframe MVS" }
assertNotNull(loadedH1)
assertEquals("test-h1", loadedH1!!.id)
assertEquals("mvs.corp.local", loadedH1.host)
assertEquals(992, loadedH1.port)
assertEquals(4, loadedH1.model)
assertEquals(62, loadedH1.dynamicRows)
assertEquals(160, loadedH1.dynamicCols)
assertEquals("LU01", loadedH1.luName)
assertTrue(loadedH1.autoConnect)
assertEquals("TSO", loadedH1.hostType)
assertTrue(loadedH1.useTls)
assertFalse(loadedH1.tlsVerifyCert)
assertTrue(loadedH1.tn3270e)
assertEquals("VECTOR_GRAPHICS", loadedH1.graphicsMode)
assertEquals("1047", loadedH1.codePage)
assertEquals("SOCKS5", loadedH1.proxyType)
assertEquals("proxy.corp", loadedH1.proxyHost)
assertEquals(1080, loadedH1.proxyPort)
assertEquals("admin", loadedH1.proxyUsername)
assertEquals("secret", loadedH1.proxyPassword)
val loadedH2 = loaded.find { it.name == "VM CMS Guest" }
assertNotNull(loadedH2)
assertEquals("test-h2", loadedH2!!.id)
assertEquals("vm.corp.local", loadedH2.host)
assertEquals(23, loadedH2.port)
assertEquals("CMS", loadedH2.hostType)
}
}
@@ -0,0 +1,125 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.HostStorage
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
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.lang.reflect.Method
class DBCSAndParityCompatibilityTest {
@Test
fun testNewJapaneseCodePagesAvailableAndResolvable() {
val availableKeys = HostStorage.AVAILABLE_CODE_PAGES.map { it.first }
assertTrue("290 should be available in HostStorage", availableKeys.contains("290"))
assertTrue("1027 should be available in HostStorage", availableKeys.contains("1027"))
assertTrue("1390 should be available in HostStorage", availableKeys.contains("1390"))
assertTrue("1399 should be available in HostStorage", availableKeys.contains("1399"))
val cp290 = CodePageRegistry.getCodePage("290")
assertNotNull("Cp290 must resolve", cp290)
assertFalse("Cp290 is SBCS", cp290.isDBCS)
val cp1027 = CodePageRegistry.getCodePage("1027")
assertNotNull("Cp1027 must resolve", cp1027)
assertFalse("Cp1027 is SBCS", cp1027.isDBCS)
val cp1390 = CodePageRegistry.getCodePage("1390")
assertNotNull("Cp1390 must resolve", cp1390)
assertTrue("Cp1390 is DBCS", cp1390.isDBCS)
val cp1399 = CodePageRegistry.getCodePage("1399")
assertNotNull("Cp1399 must resolve", cp1399)
assertTrue("Cp1399 is DBCS", cp1399.isDBCS)
val trans1390 = EbcdicTranslator("1390")
assertTrue("Translator for 1390 must report isDBCS", trans1390.isDBCS)
}
@Test
fun testScreenExporterDBCSNoDuplication() {
val trans = EbcdicTranslator("930")
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, trans)
screen.erase(false)
// Put a 2-cell DBCS character at cells 0 and 1: '東' (\u6771)
val cell0 = screen.getCell(0)
cell0.db = ExtendedAttribute.DB_LEFT
cell0.ucs4 = '\u6771'
val cell1 = screen.getCell(1)
cell1.db = ExtendedAttribute.DB_RIGHT
cell1.ucs4 = '\u6771'
// Put SBCS 'X' at cell 2
val cell2 = screen.getCell(2)
cell2.db = ExtendedAttribute.DB_NONE
cell2.ucs4 = 'X'
// Invoke private generatePlainText via reflection
val exporterClass = Class.forName("haus.nightmare.a3270.ui.ScreenExporterDialogKt")
val method: Method = exporterClass.getDeclaredMethod(
"generatePlainText",
ScreenBuffer::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
)
method.isAccessible = true
val plainText = method.invoke(null, screen, 1, 80, true) as String
assertTrue("Output should start with single DBCS char + X: expected '東X', got: '$plainText'", plainText.startsWith("東X"))
assertFalse("DBCS char must not be doubled into '東東X'", plainText.startsWith("東東"))
}
@Test
fun testTerminalGridMetricsCenteringClampedAtZero() {
val terminalViewClass = Class.forName("haus.nightmare.a3270.ui.TerminalViewKt")
val method = terminalViewClass.getDeclaredMethod(
"calculateGridMetrics",
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
method.isAccessible = true
// View is constrained / non-negative offsets
val constrainedMetrics = method.invoke(null, 400f, 300f, 80, 24)
val offXField = constrainedMetrics.javaClass.getDeclaredField("offsetX")
offXField.isAccessible = true
val offX = offXField.getFloat(constrainedMetrics)
val offYField = constrainedMetrics.javaClass.getDeclaredField("offsetY")
offYField.isAccessible = true
val offY = offYField.getFloat(constrainedMetrics)
assertTrue("Constrained offX must be non-negative", offX >= 0f)
assertTrue("Constrained offY must be non-negative", offY >= 0f)
// Wide view (pillarbox with positive offX)
val wideMetrics = method.invoke(null, 3000f, 1000f, 80, 24)
val wideOffX = offXField.getFloat(wideMetrics)
val wideOffY = offYField.getFloat(wideMetrics)
assertTrue("Wide view offX should be positive", wideOffX > 0f)
assertEquals(0f, wideOffY, 0.001f)
// Tall view (letterbox with positive offY)
val tallMetrics = method.invoke(null, 1000f, 3000f, 80, 24)
val tallOffX = offXField.getFloat(tallMetrics)
val tallOffY = offYField.getFloat(tallMetrics)
assertEquals(0f, tallOffX, 0.001f)
assertTrue("Tall view offY should be positive", tallOffY > 0f)
}
@Test
fun testExtendedAttributeTransparencyConstants() {
assertEquals(0x00.toByte(), ExtendedAttribute.TR_DEFAULT)
assertEquals(0xF0.toByte(), ExtendedAttribute.TR_OR)
assertEquals(0xFF.toByte(), ExtendedAttribute.TR_OPAQUE)
}
}
@@ -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)
}
}
@@ -0,0 +1,179 @@
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 {
@Test
fun testSavedHostDynamicModelSerialization() {
val host = SavedHost(
id = UUID.randomUUID().toString(),
name = "Dynamic Mainframe Session",
host = "mainframe.example.org",
port = 23,
model = 0,
dynamicRows = 62,
dynamicCols = 160,
luName = "DYNLU01",
autoConnect = false,
hostType = "TSO",
useTls = true,
tlsVerifyCert = true,
tn3270e = true,
graphicsMode = "BOTH",
codePage = "037"
)
val json = host.toJson()
assertEquals(0, json.getInt("model"))
assertEquals(62, json.getInt("dynamicRows"))
assertEquals(160, json.getInt("dynamicCols"))
val deserialized = SavedHost.fromJson(json)
assertEquals(0, deserialized.model)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals("Dynamic Mainframe Session", deserialized.name)
assertEquals("mainframe.example.org", deserialized.host)
assertEquals(23, deserialized.port)
assertTrue(deserialized.useTls)
assertEquals("BOTH", deserialized.graphicsMode)
}
@Test
fun testSavedHostBackwardCompatibilityDefaults() {
// Legacy JSON without dynamicRows and dynamicCols
val json = JSONObject().apply {
put("id", "legacy-id")
put("name", "Legacy Host")
put("host", "legacy.host")
put("port", 23)
put("model", 2)
}
val deserialized = SavedHost.fromJson(json)
assertEquals(62, deserialized.dynamicRows)
assertEquals(160, deserialized.dynamicCols)
assertEquals(2, deserialized.model)
}
@Test
fun testConnectionConfigDynamicModel() {
val config = ConnectionConfig("10.0.0.1", 23)
config.setDynamicDimensions(72, 140)
assertEquals(TerminalModel.IBM_DYNAMIC, config.model)
assertTrue(config.isDynamicModel)
assertEquals(72, config.dynamicRows)
assertEquals(140, config.dynamicCols)
}
@Test
fun testHodColorRemappingConstants() {
// Base 4-Color Model parity verification:
// Normal Protected must be Turquoise/Cyan (0x00FFFF), NOT Blue
// Intensified Unprotected must be Red (0xFF0000)
// Normal Unprotected is Green (0x00FF00)
// Intensified Protected is White (0xFFFFFF)
val turquoiseRgb = 0x00FFFF
val redRgb = 0xFF0000
val greenRgb = 0x00FF00
val whiteRgb = 0xFFFFFF
assertEquals(0x00FFFF, turquoiseRgb)
assertEquals(0xFF0000, redRgb)
assertEquals(0x00FF00, greenRgb)
assertEquals(0xFFFFFF, whiteRgb)
// Validate bit patterns match IBM 3270 Field Attributes
val prot = FA_PROTECT
val num = FA_NUMERIC
val hi = FA_INT_HIGH_SEL
assertTrue((prot and FA_PROTECT) != 0)
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)
}
}
@@ -0,0 +1,102 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLFieldList
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class EclAndSearchTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: haus.nightmare.lib3270j.input.InputProcessor
private lateinit var ps: ECLPS
private lateinit var fieldList: ECLFieldList
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2 // 24x80
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, translator, null)
ps = ECLPS(screenBuffer, inputProcessor, translator)
fieldList = ps.fieldList
}
@Test
fun testPresentationSpaceSearchForwardAndBackward() {
val text = "USERID ===> IBMUSER"
for (i in text.indices) {
screenBuffer.getCell(100 + i).ucs4 = text[i]
screenBuffer.getCell(100 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
// Search Forward for "IBMUSER" from row 0 col 0
val foundPos = ps.searchString("IBMUSER", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundPos)
// Case-sensitive search failure
val notFound = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, false)
assertEquals(-1, notFound)
// Case-insensitive search match
val foundCaseInsensitive = ps.searchString("ibmuser", 0, 0, ECLConstants.SEARCH_FORWARD, true)
assertEquals(100 + text.indexOf("IBMUSER"), foundCaseInsensitive)
// Search Backward from bottom of screen
val foundBackward = ps.searchString("USERID", 23, 79, ECLConstants.SEARCH_BACKWARD, true)
assertEquals(100, foundBackward)
}
@Test
fun testFieldListExtractionAndFlags() {
// Position 0: Protected label
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
val label = "COMMAND ===>"
for (i in label.indices) {
screenBuffer.getCell(1 + i).ucs4 = label[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(label[i]).toByte()
}
// Position 20: Unprotected input field with MDT
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
val cmd = "TSO TEST"
for (i in cmd.indices) {
screenBuffer.getCell(21 + i).ucs4 = cmd[i]
screenBuffer.getCell(21 + i).ec = translator.unicodeToEbcdic(cmd[i]).toByte()
}
// Position 40: Hidden field (zero intensity)
screenBuffer.setCellFA(40, (FA_PRINTABLE or FA_PROTECT or FA_INT_ZERO_NSEL).toByte())
fieldList.refresh()
val fields = fieldList.fields
assertEquals(3, fields.size)
// Field 0: Label
val f0 = fields[0]
assertTrue(f0.isProtected)
assertFalse(f0.isModified)
assertEquals(0, f0.start)
assertEquals(1, f0.dataStart)
assertTrue(f0.text.startsWith("COMMAND ===>"))
// Field 1: Input command
val f1 = fields[1]
assertFalse(f1.isProtected)
assertTrue(f1.isModified)
assertEquals(20, f1.start)
assertEquals(21, f1.dataStart)
assertTrue(f1.text.startsWith("TSO TEST"))
// Field 2: Hidden field
val f2 = fields[2]
assertTrue(f2.isHidden)
}
}
@@ -0,0 +1,318 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.ecl.ECLOIA
import haus.nightmare.lib3270j.ecl.ECLPS
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class EntryAssistAndModesTest {
@Test
fun testEntryAssistDocModeAndWordWrap() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val inputProcessor = InputProcessor(screen, translator, null)
val oia = ECLOIA(screen, inputProcessor, null)
inputProcessor.setOIA(oia)
screen.isEntryAssistDOCmode = true
screen.isEntryAssistWordWrap = true
screen.setLeftMargin(0)
screen.setRightMargin(20)
assertTrue(screen.isEntryAssistDOCmode)
assertTrue(screen.isEntryAssistWordWrap)
assertEquals(0, screen.entryAssistStartColumn)
assertEquals(20, screen.entryAssistEndColumn)
// Type at col 18 with word crossing col 20
screen.setCursorPosition(0, 18)
inputProcessor.typeCharacter('T')
inputProcessor.typeCharacter('E')
inputProcessor.typeCharacter('S')
inputProcessor.typeCharacter('T')
// Word wrap moves partial word to next row starting at left margin
assertEquals(1, screen.cursorRow)
assertTrue(screen.cursorCol >= 0)
}
@Test
fun testDocModeTabStops() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
screen.isEntryAssistDOCmode = true
screen.setLeftMargin(0)
screen.setRightMargin(79)
screen.setWordTabPositions(intArrayOf(10, 20, 30))
screen.setCursorPosition(0, 0)
screen.processWordTab(true)
assertEquals(10, screen.cursorCol)
screen.processWordTab(true)
assertEquals(20, screen.cursorCol)
screen.processWordTab(false)
assertEquals(10, screen.cursorCol)
}
@Test
fun testAplModeAndGraphicEscape() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val inputProcessor = InputProcessor(screen, translator, null)
val oia = ECLOIA(screen, inputProcessor, null)
inputProcessor.setOIA(oia)
assertFalse(inputProcessor.isAplKeyboardMode)
inputProcessor.isAplKeyboardMode = true
assertTrue(inputProcessor.isAplKeyboardMode)
assertTrue(oia.isApl)
// Type 'a' in APL mode -> stores CS_GE Graphic Escape charset
screen.setCursorPosition(0, 0)
inputProcessor.typeCharacter('a')
val cell = screen.getCell(0)
assertEquals(CS_GE.toInt(), cell.cs.toInt())
// Toggle APL mode off
inputProcessor.toggleAplKeyboardMode()
assertFalse(inputProcessor.isAplKeyboardMode)
assertFalse(oia.isApl)
}
@Test
fun testAudibleBellCallback() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val inputProcessor = InputProcessor(screen, translator, null)
val bellCount = AtomicInteger(0)
inputProcessor.setBellListener { bellCount.incrementAndGet() }
inputProcessor.isBellEnabled = true
inputProcessor.bellColumn = 74
screen.setCursorPosition(0, 70)
inputProcessor.typeCharacter('A')
inputProcessor.typeCharacter('B')
inputProcessor.typeCharacter('C')
assertEquals(0, bellCount.get())
inputProcessor.typeCharacter('D') // Col 74 -> Bell fires
assertEquals(1, bellCount.get())
inputProcessor.typeCharacter('E') // Same line -> No re-fire
assertEquals(1, bellCount.get())
}
@Test
fun testInsertModeResetOnAid() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val inputProcessor = InputProcessor(screen, translator, null)
inputProcessor.isInsertOffOnAid = true
inputProcessor.isInsertMode = true
assertTrue(inputProcessor.isInsertMode)
inputProcessor.sendAid(AID_ENTER)
assertFalse(inputProcessor.isInsertMode)
// Disabled
inputProcessor.isInsertOffOnAid = false
inputProcessor.isInsertMode = true
inputProcessor.sendAid(AID_ENTER)
assertTrue(inputProcessor.isInsertMode)
}
@Test
fun testNumericFieldLock() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val inputProcessor = InputProcessor(screen, translator, null)
val oia = ECLOIA(screen, inputProcessor, null)
inputProcessor.setOIA(oia)
screen.setFieldAttribute(0, FA_NUMERIC.toByte())
screen.setFieldAttribute(6, FA_PROTECT.toByte())
screen.cursorAddress = 1
inputProcessor.isNumericFieldLock = true
inputProcessor.typeCharacter('Z') // Non-numeric
assertTrue(inputProcessor.isKeyboardLocked)
assertEquals(ECLConstants.INHIBIT_NUMERIC_ONLY, oia.inputInhibited)
inputProcessor.reset()
assertFalse(inputProcessor.isKeyboardLocked)
// With numeric lock disabled
inputProcessor.isNumericFieldLock = false
screen.cursorAddress = 1
inputProcessor.typeCharacter('Z')
assertFalse(inputProcessor.isKeyboardLocked)
}
@Test
fun testBase4ColorParity() {
// In 3279 Base 4-Color Mode:
// Intensified Protected -> White (0xFFFFFF)
// Normal Protected -> Turquoise / Cyan (0x00FFFF)
// Intensified Unprotected -> Red (0xFF0000)
// Normal Unprotected -> Green (0x00FF00)
val faNormProt = FA_PROTECT
val faHighProt = FA_PROTECT or FA_INT_HIGH_SEL
val faNormUnprot = 0
val faHighUnprot = FA_INT_HIGH_SEL
fun getBase4Color(fa: Int): Int {
val isProt = (fa and FA_PROTECT) != 0
val isHigh = (fa and FA_INT_HIGH_SEL) != 0
return if (isProt) {
if (isHigh) 0xFFFFFF else 0x00FFFF
} else {
if (isHigh) 0xFF0000 else 0x00FF00
}
}
assertEquals(0xFFFFFF, getBase4Color(faHighProt))
assertEquals(0x00FFFF, getBase4Color(faNormProt))
assertEquals(0xFF0000, getBase4Color(faHighUnprot))
assertEquals(0x00FF00, getBase4Color(faNormUnprot))
}
@Test
fun testIniSectionSyntaxMatchingJ3270() {
val sampleIni = """
[behavior]
blockSelectMode = false
blockPaste = true
[clipboard]
blockPasteMode = true
[entryassist]
docMode = true
wordWrap = true
startCol = 5
endCol = 75
bell = true
bellCol = 70
tabStops = 5,10,15,20
[modes]
resetInsertOnAid = true
fourColorOverride = true
numericLock = false
autoSkip = true
""".trimIndent()
val lines = sampleIni.lines()
var currentSection = ""
val parsed = mutableMapOf<String, String>()
for (line in lines) {
val trimmed = line.trim()
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
currentSection = trimmed.substring(1, trimmed.length - 1).lowercase()
} else if (trimmed.contains("=")) {
val parts = trimmed.split("=", limit = 2)
val key = parts[0].trim().lowercase()
val value = parts[1].trim()
parsed["$currentSection.$key"] = value
}
}
assertEquals("false", parsed["behavior.blockselectmode"])
assertEquals("true", parsed["behavior.blockpaste"])
assertEquals("true", parsed["clipboard.blockpastemode"])
assertEquals("true", parsed["entryassist.docmode"])
assertEquals("true", parsed["entryassist.wordwrap"])
assertEquals("5", parsed["entryassist.startcol"])
assertEquals("75", parsed["entryassist.endcol"])
assertEquals("true", parsed["entryassist.bell"])
assertEquals("70", parsed["entryassist.bellcol"])
assertEquals("5,10,15,20", parsed["entryassist.tabstops"])
assertEquals("true", parsed["modes.resetinsertonaid"])
assertEquals("true", parsed["modes.fourcoloroverride"])
assertEquals("false", parsed["modes.numericlock"])
assertEquals("true", parsed["modes.autoskip"])
}
@Test
fun testBlockPasteMultiLineAlignment() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3278_2, translator)
val input = InputProcessor(screen, translator, null)
val ps = ECLPS(screen, input, translator)
// Verify default blockPaste is true
assertTrue(input.isBlockPaste)
assertTrue(ps.isBlockPaste)
// 1. Unformatted screen: verify start column 15 is preserved across newlines
screen.erase(false)
val startPos = screen.rowColToAddress(0, 15)
screen.cursorAddress = startPos
val multilineText = "FIRST\nSECOND\nTHIRD"
val pasted = input.pasteText(multilineText, true, false, true)
assertEquals(16, pasted)
assertEquals("FIRST", ps.getString(startPos, 5))
val row1Col15 = screen.rowColToAddress(1, 15)
assertEquals("SECOND", ps.getString(row1Col15, 6))
val row2Col15 = screen.rowColToAddress(2, 15)
assertEquals("THIRD", ps.getString(row2Col15, 5))
// 2. Formatted screen with blockPaste = true
screen.erase(false)
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
screen.setFieldAttribute(160, FA_PRINTABLE.toByte())
screen.cursorAddress = screen.rowColToAddress(0, 25)
val pastedFormatted = input.pasteText("ALPHA\r\nBETA\r\nGAMMA", true, false, true)
assertEquals(14, pastedFormatted)
assertEquals("ALPHA", ps.getString(screen.rowColToAddress(0, 25), 5))
assertEquals("BETA", ps.getString(screen.rowColToAddress(1, 25), 4))
assertEquals("GAMMA", ps.getString(screen.rowColToAddress(2, 25), 5))
// 3. Formatted screen with blockPaste = false: reverts to first unprotected field on next row (pos 81)
screen.erase(false)
screen.setFieldAttribute(0, FA_PRINTABLE.toByte())
screen.setFieldAttribute(80, FA_PRINTABLE.toByte())
screen.cursorAddress = screen.rowColToAddress(0, 25)
input.pasteText("LINE1\nLINE2", true, false, false)
assertEquals("LINE1", ps.getString(screen.rowColToAddress(0, 25), 5))
assertEquals("LINE2", ps.getString(81, 5))
}
@Test
fun testCmsFilenameFormatting() {
// Upload (local -> host)
assertEquals("PROFILE EXEC A", haus.nightmare.a3270.ui.formatHostFilename("profile.exec", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
assertEquals("TEST TXT A", haus.nightmare.a3270.ui.formatHostFilename("test.txt", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
assertEquals("README FILE A", haus.nightmare.a3270.ui.formatHostFilename("README", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
// Download (host -> local)
assertEquals("profile.exec", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("PROFILE EXEC A", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
assertEquals("test.data", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("TEST DATA A1", haus.nightmare.lib3270j.ft.FTConfig.HostType.CMS))
// TSO
assertEquals("'MYDATA'", haus.nightmare.a3270.ui.formatHostFilename("mydata.txt", haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO))
assertEquals("dataset.txt", haus.nightmare.a3270.ui.formatLocalFilenameFromHost("'USER.DATASET'", haus.nightmare.lib3270j.ft.FTConfig.HostType.TSO))
}
}
@@ -0,0 +1,296 @@
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.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.*
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)
}
@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)
}
}
@@ -1,13 +1,13 @@
package org.pubvm.a3270
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.lib3270j.TerminalModel
import org.lib3270j.charset.EbcdicTranslator
import org.lib3270j.input.InputProcessor
import org.lib3270j.protocol.DS3270Constants.*
import org.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class HardwareKeyboardInputTest {
@@ -165,4 +165,77 @@ class HardwareKeyboardInputTest {
assertFalse(inputProcessor.isKeyboardLocked)
}
}
@Test
fun testLightPenSelection() {
// Field 1 at pos 0: Selectable Unprotected (FA_INT_HIGH)
// Designator at pos 1: '?' (EBCDIC 0x6F)
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
assertTrue(haus.nightmare.lib3270j.protocol.DS3270Constants.faIsSelectable(fa.toInt() and 0xFF))
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '?'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('?').toByte()
// Field 2 at pos 20: Non-selectable (normal intensity)
screenBuffer.setCellFA(20, FA_PRINTABLE.toByte())
screenBuffer.getCell(21).ucs4 = '?'
// Selecting field 1 ('?') toggles designator to '>' and sets modified bit
val result1 = inputProcessor.lightPenSelect(1)
assertTrue(result1)
assertEquals('>', screenBuffer.getCell(1).ucs4)
assertEquals(0x6E.toByte(), screenBuffer.getCell(1).ec)
val fa1 = screenBuffer.getCell(0).fa.toInt() and 0xFF
assertTrue((fa1 and FA_MODIFY) != 0)
// Selecting field 1 again ('>') toggles back to '?' and clears modified bit
val result2 = inputProcessor.lightPenSelect(1)
assertTrue(result2)
assertEquals('?', screenBuffer.getCell(1).ucs4)
assertEquals(0x6F.toByte(), screenBuffer.getCell(1).ec)
// Selecting field 2 (non-selectable) returns false
val resultNonSelectable = inputProcessor.lightPenSelect(21)
assertFalse(resultNonSelectable)
}
@Test
fun testLightPenEnterSelection() {
// Field with '&' designator immediately sends AID_ENTER
val fa = (FA_PRINTABLE or FA_INT_HIGH_SEL).toByte()
screenBuffer.setCellFA(0, fa)
screenBuffer.getCell(1).ucs4 = '&'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('&').toByte()
val result = inputProcessor.lightPenSelect(1)
assertTrue(result)
assertEquals(AID_ENTER, inputProcessor.lastAid)
}
@Test
fun testEraseInputAndAttention() {
// Position 0: Unprotected FA
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_MODIFY).toByte())
// Position 10: Protected FA
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
// Position 20: Unprotected FA
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_MODIFY).toByte())
screenBuffer.getCell(1).ucs4 = 'X'
screenBuffer.getCell(1).ec = translator.unicodeToEbcdic('X').toByte()
screenBuffer.getCell(21).ucs4 = 'Y'
screenBuffer.getCell(21).ec = translator.unicodeToEbcdic('Y').toByte()
inputProcessor.eraseInput()
// Unprotected fields should be cleared and reset
assertEquals(0, screenBuffer.getCell(1).ec.toInt())
assertEquals(0, screenBuffer.getCell(21).ec.toInt())
assertEquals(1, screenBuffer.cursorAddress)
// Attention AID
inputProcessor.sendAid(AID_PA1)
assertEquals(AID_PA1, inputProcessor.lastAid)
}
}
@@ -0,0 +1,312 @@
package haus.nightmare.a3270
import haus.nightmare.a3270.storage.AppSettings
import haus.nightmare.a3270.ui.HotspotEngine
import haus.nightmare.a3270.ui.HotspotEngine.HotspotType
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import haus.nightmare.lib3270j.screen.ScreenHistoryBuffer
import org.junit.Assert.*
import org.junit.Test
class HotspotAndHistoryFeaturesTest {
@Test
fun testHotspotUrlDetection() {
val engine = HotspotEngine()
val text = "Visit https://ibm.com/support or http://example.org:8080/test for help"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val urls = hotspots.filter { it.type == HotspotType.URL }
assertEquals(2, urls.size)
assertEquals("https://ibm.com/support", urls[0].action)
assertEquals("http://example.org:8080/test", urls[1].action)
}
@Test
fun testHotspotPfAndPaKeyDetection() {
val engine = HotspotEngine()
val text = "Press PF3 to Exit, PF12 to Cancel, F1 for Help, and PA1 for Attention"
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pfHotspots = hotspots.filter { it.type == HotspotType.PF_KEY }
assertTrue(pfHotspots.any { it.action == "[pf3]" && it.text.equals("PF3", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf12]" && it.text.equals("PF12", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pf1]" && it.text.equals("F1", ignoreCase = true) })
assertTrue(pfHotspots.any { it.action == "[pa1]" && it.text.equals("PA1", ignoreCase = true) })
}
@Test
fun testHotspotMenuChoiceAndEnterDetection() {
val engine = HotspotEngine()
val text = "1. Logon\n2. Logoff\n===> 3\n[Enter] to submit"
val hotspots = engine.scanText(text, rows = 4, cols = 30)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertTrue(menuChoices.any { it.action == "1[enter]" })
assertTrue(menuChoices.any { it.action == "2[enter]" })
assertTrue(menuChoices.any { it.action == "3[enter]" })
val enterHotspots = hotspots.filter { it.type == HotspotType.ENTER }
assertTrue(enterHotspots.any { it.action == "[enter]" })
}
@Test
fun testIspfPrimaryOptionMenuDetection() {
val engine = HotspotEngine()
val screen =
" Menu Utilities Compilers Options Status Help\n" +
" ──────────────────────────────────────────────────────────────────────────────\n" +
" ISPF Primary Option Menu\n" +
" Option ===>\n" +
" \n" +
" 0 Settings Terminal and user parameters User ID . : RUDI\n" +
" 1 View Display source data or listings Time. . . : 12:31\n" +
" 2 Edit Create or change source data Terminal. : 3278\n" +
" 3 Utilities Perform utility functions Screen. . : 1\n" +
" 4 Foreground Interactive language processing Language. : ENGLISH\n" +
" 5 Batch Submit job for language processing Appl ID . : ISR\n" +
" 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" +
" 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" +
" 9 IBM Products IBM program development products System ID : VS01\n" +
" 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" +
" 11 Workplace ISPF Object/Action Workplace\n" +
" M More Additional workplace options\n" +
" X Exit Terminate ISPF using log and list defaults"
val hotspots = engine.scanText(screen, 18, 80)
// 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11
assertEquals(14, hotspots.size)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertEquals(13, menuChoices.size)
// Option 0
val h0 = menuChoices[0]
assertEquals(HotspotType.MENU_CHOICE, h0.type)
assertEquals("0 Settings", h0.displayText)
assertEquals("0[enter]", h0.action)
assertEquals("Select option 0 (Settings)", h0.tooltip)
assertEquals(5, h0.startRow)
// Option 1
val h1 = menuChoices[1]
assertEquals("1 View", h1.displayText)
assertEquals("1[enter]", h1.action)
assertEquals("Select option 1 (View)", h1.tooltip)
assertEquals(6, h1.startRow)
// Option 2
val h2 = menuChoices[2]
assertEquals("2 Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
assertEquals("Select option 2 (Edit)", h2.tooltip)
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
val h10 = menuChoices[9]
assertEquals("10 SCLM", h10.displayText)
assertEquals("10[enter]", h10.action)
assertEquals("Select option 10 (SCLM)", h10.tooltip)
// Option M (letter, index 11)
val hM = menuChoices[11]
assertEquals("M More", hM.displayText)
assertEquals("M[enter]", hM.action)
assertEquals("Select option M (More)", hM.tooltip)
// Option X (exit, index 12)
val hX = menuChoices[12]
assertEquals("X Exit", hX.displayText)
assertEquals("X[enter]", hX.action)
assertEquals("Select option X (Exit)", hX.tooltip)
}
@Test
fun testIspfSubOptionsAndMultiColumn() {
val engine = HotspotEngine()
val screen =
" 3.1 Library - Compress or print data set\n" +
" 3.2 DataSet - Allocate, rename, or delete\n" +
" 1 - View 2 - Edit"
val hotspots = engine.scanText(screen, 3, 80)
assertEquals(4, hotspots.size)
val h31 = hotspots[0]
assertEquals("3.1 Library", h31.displayText)
assertEquals("3.1[enter]", h31.action)
assertEquals("Select option 3.1 (Library)", h31.tooltip)
val h32 = hotspots[1]
assertEquals("3.2 DataSet", h32.displayText)
assertEquals("3.2[enter]", h32.action)
val h1 = hotspots[2]
assertEquals("1 - View", h1.displayText)
assertEquals("1[enter]", h1.action)
val h2 = hotspots[3]
assertEquals("2 - Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
}
@Test
fun testHotspotHitTesting() {
val engine = HotspotEngine()
val text = "Row 0: PF7 Backward PF8 Forward "
val hotspots = engine.scanText(text, rows = 1, cols = text.length)
val pf7 = hotspots.find { it.action == "[pf7]" }
assertNotNull(pf7)
val hit = engine.findHotspotAt(hotspots, 0, pf7!!.startCol + 1)
assertNotNull(hit)
assertEquals("[pf7]", hit?.action)
// Pointing outside any hotspot returns null
val miss = engine.findHotspotAt(hotspots, 0, 0)
assertNull(miss)
}
@Test
fun testHotspotCategoryDisabling() {
val engine = HotspotEngine()
val text = "Visit https://example.com or press PF3 or choose 1. Option"
// Master toggle disabled
val disabledScan = engine.scanText(text, rows = 1, cols = text.length, enabled = false)
assertTrue(disabledScan.isEmpty())
// Disable URLs only
engine.urlEnabled = false
engine.pfKeysEnabled = true
engine.menuChoicesEnabled = true
val noUrls = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noUrls.none { it.type == HotspotType.URL })
assertTrue(noUrls.any { it.type == HotspotType.PF_KEY })
// Disable PF keys only
engine.urlEnabled = true
engine.pfKeysEnabled = false
val noPfs = engine.scanText(text, rows = 1, cols = text.length, enabled = true)
assertTrue(noPfs.none { it.type == HotspotType.PF_KEY })
assertTrue(noPfs.any { it.type == HotspotType.URL })
}
@Test
fun testScreenHistoryBufferCapturesSnapshots() {
val translator = EbcdicTranslator()
val screen = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
val history = screen.historyBuffer
assertNotNull(history)
assertEquals(0, history.size())
// Write first screen
screen.setCursorPosition(0, 0)
screen.setChar(0, 0, 'A')
screen.setChar(0, 1, 'B')
screen.setChar(0, 2, 'C')
history.snapshot(screen)
assertEquals(1, history.size())
val snap0 = history.get(0)
assertNotNull(snap0)
assertEquals('A', snap0?.getChar(0, 0))
assertEquals('B', snap0?.getChar(0, 1))
assertEquals('C', snap0?.getChar(0, 2))
// Consecutive identical snapshot is suppressed
history.snapshot(screen)
assertEquals(1, history.size())
// Modify screen and take second snapshot
screen.setChar(0, 0, 'X')
history.snapshot(screen)
assertEquals(2, history.size())
assertEquals('X', history.get(1)?.getChar(0, 0))
// Verify table formatting
val formattedTable = snap0?.formattedTable
assertNotNull(formattedTable)
assertTrue(formattedTable!!.startsWith("ABC"))
}
@Test
fun testFieldOutliningConstantsAndAttribute() {
assertEquals(0x01.toByte(), ExtendedAttribute.OL_UNDERLINE)
assertEquals(0x02.toByte(), ExtendedAttribute.OL_RIGHT)
assertEquals(0x04.toByte(), ExtendedAttribute.OL_OVERLINE)
assertEquals(0x08.toByte(), ExtendedAttribute.OL_LEFT)
assertEquals(0x0F.toByte(), ExtendedAttribute.OL_BOX)
val ea = ExtendedAttribute()
ea.ol = ExtendedAttribute.OL_BOX
assertEquals(ExtendedAttribute.OL_BOX, ea.ol)
}
@Test
fun testAppSettingsIniRoundtripWithHotspotsAndSelectionPreferences() {
// Check that default for hotspots is false (disabled for touch screens)
val testIni = """
[behavior]
hotspots_enabled=false
trim_rect_sizing_handles=true
trim_rect_remain_after_edit=true
paste_word_break=false
[hotspots]
url=true
pf_keys=true
menu_choices=false
""".trimIndent()
var hsEnabled: Boolean? = null
var trimHandles: Boolean? = null
var trimRemain: Boolean? = null
var wordBreak: Boolean? = null
var hsUrl: Boolean? = null
var hsPf: Boolean? = null
var hsChoices: Boolean? = null
AppSettings.parseIni(java.io.StringReader(testIni)) { _, k, v ->
when (k) {
"hotspots_enabled" -> hsEnabled = v.toBoolean()
"trim_rect_sizing_handles" -> trimHandles = v.toBoolean()
"trim_rect_remain_after_edit" -> trimRemain = v.toBoolean()
"paste_word_break" -> wordBreak = v.toBoolean()
"hotspots_url_enabled", "url" -> hsUrl = v.toBoolean()
"hotspots_pf_enabled", "pf_keys" -> hsPf = v.toBoolean()
"hotspots_menu_choices_enabled", "menu_choices" -> hsChoices = v.toBoolean()
}
}
assertEquals(false, hsEnabled)
assertEquals(true, trimHandles)
assertEquals(true, trimRemain)
assertEquals(false, wordBreak)
assertEquals(true, hsUrl)
assertEquals(true, hsPf)
assertEquals(false, hsChoices)
}
@Test
fun testMenuChoiceActionPrefixingForSafeSubmission() {
val engine = HotspotEngine()
val screen = " 1 View Display source data or listings"
val hotspots = engine.scanText(screen, 1, 80)
assertEquals(1, hotspots.size)
val h = hotspots[0]
assertEquals(HotspotType.MENU_CHOICE, h.type)
assertEquals("1[enter]", h.action)
// Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock
var safeAction = h.action
if (!safeAction.startsWith("[home]")) {
safeAction = "[home][eraseeof]$safeAction"
}
assertEquals("[home][eraseeof]1[enter]", safeAction)
}
}
@@ -0,0 +1,179 @@
package haus.nightmare.a3270
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.CodePageRegistry
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.ecl.ECLConstants
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.nvt.NvtProcessor
import haus.nightmare.lib3270j.printer.PrinterDefinitionTable
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
class NvtAndExtendedFeaturesTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testExtendedCodePagesAvailability() {
val testCps = listOf(
"420", "424", "803", "838", "1160",
"1025", "1123", "1154", "880", "905", "1026", "1155",
"1140", "1141", "1142", "1143", "1144", "1145", "1146", "1147", "1148", "1149",
"930", "939", "935", "937", "1388", "1371", "933"
)
for (cp in testCps) {
val trans = CodePageRegistry.getCodePage(cp)
assertNotNull("Code page $cp should be registered in CodePageRegistry", trans)
assertEquals(cp, trans.codePageId)
}
}
@Test
fun testExtendedCodePageTranslation() {
// Test Greek Cp875
val greekTrans = CodePageRegistry.getCodePage("875")
assertNotNull(greekTrans)
val alphaEbcdic = greekTrans.unicodeToEbcdic('α')
val alphaUnicode = greekTrans.ebcdicToUnicode(alphaEbcdic.toInt() and 0xFF)
assertEquals('α', alphaUnicode)
// Test Cyrillic Cp1025
val cyrTrans = CodePageRegistry.getCodePage("1025")
assertNotNull(cyrTrans)
val cyrA = 'А' // Cyrillic capital A
val cyrEbcdic = cyrTrans.unicodeToEbcdic(cyrA)
val cyrResult = cyrTrans.ebcdicToUnicode(cyrEbcdic.toInt() and 0xFF)
assertEquals(cyrA, cyrResult)
// Test Euro variant Cp1140 has € symbol at 0x9F
val euroTrans = CodePageRegistry.getCodePage("1140")
assertNotNull(euroTrans)
val euroUnicode = euroTrans.ebcdicToUnicode(0x9F)
assertEquals('€', euroUnicode)
}
@Test
fun testPDTTables() {
val pcl5 = PrinterDefinitionTable.createPcl5PDT()
assertNotNull(pcl5)
assertEquals("PCL_5", pcl5.name)
assertNotNull(pcl5.getControlCode(PrinterDefinitionTable.CMD_START_JOB))
val escp = PrinterDefinitionTable.createEpsonEscPPDT()
assertNotNull(escp)
assertEquals("EPSON_ESC_P", escp.name)
assertNotNull(escp.getControlCode(PrinterDefinitionTable.CMD_START_BOLD))
val ps = PrinterDefinitionTable.createPostScriptPDT()
assertNotNull(ps)
assertEquals("POSTSCRIPT", ps.name)
val psStart = String(ps.getControlCode(PrinterDefinitionTable.CMD_START_JOB) ?: ByteArray(0), java.nio.charset.StandardCharsets.ISO_8859_1)
assertTrue(psStart.contains("%!PS"))
val plain = PrinterDefinitionTable.createPlainTextPDT()
assertNotNull(plain)
assertEquals("PLAIN_TEXT", plain.name)
}
@Test
fun testWordNavigationAndEditing() {
val text = "IBM MAINFRAME SYSTEM"
for (i in text.indices) {
screenBuffer.getCell(i).ucs4 = text[i]
screenBuffer.getCell(i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
screenBuffer.cursorAddress = 0
inputProcessor.processWordRight()
assertEquals(5, screenBuffer.cursorAddress)
inputProcessor.processWordRight()
assertEquals(16, screenBuffer.cursorAddress)
inputProcessor.processWordLeft()
assertEquals(5, screenBuffer.cursorAddress)
inputProcessor.processWordLeft()
assertEquals(0, screenBuffer.cursorAddress)
}
@Test
fun testFieldEndAndToggleInsert() {
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
val text = "HELLO"
for (i in text.indices) {
screenBuffer.getCell(1 + i).ucs4 = text[i]
screenBuffer.getCell(1 + i).ec = translator.unicodeToEbcdic(text[i]).toByte()
}
screenBuffer.cursorAddress = 1
inputProcessor.processFieldEnd()
assertEquals(6, screenBuffer.cursorAddress)
assertFalse(inputProcessor.isInsertMode)
inputProcessor.processToggleInsert()
assertTrue(inputProcessor.isInsertMode)
inputProcessor.processToggleInsert()
assertFalse(inputProcessor.isInsertMode)
}
@Test
fun testNvtFunctionKeySequences() {
val nvt = NvtProcessor(screenBuffer, null)
val f1 = nvt.getFunctionKeySequence(1)
val f5 = nvt.getFunctionKeySequence(5)
val f10 = nvt.getFunctionKeySequence(10)
assertNotNull(f1)
assertNotNull(f5)
assertNotNull(f10)
assertTrue(f1.startsWith("\u001B"))
assertTrue(f5.startsWith("\u001B"))
assertTrue(f10.startsWith("\u001B"))
}
@Test
fun testECLFieldWrapping() {
val totalCells = screenBuffer.rows * screenBuffer.cols
val faPos = totalCells - 10
screenBuffer.setCellFA(faPos, FA_PRINTABLE.toByte())
screenBuffer.setCellFA(15, (FA_PRINTABLE or FA_PROTECT).toByte())
val fieldList = screenBuffer.buildFieldList()
assertNotNull(fieldList)
val wrappedField = fieldList.findField(totalCells - 5)
assertNotNull(wrappedField)
assertTrue("Field spanning buffer boundary should be marked wrapped", wrappedField.isWrapped)
assertTrue(wrappedField.contains(totalCells - 2))
assertTrue(wrappedField.contains(5))
}
@Test
fun testOiaInhibitConstants() {
assertEquals(1, ECLConstants.INHIBIT_SYSTEM_LOCK)
assertEquals(0, ECLConstants.INHIBIT_NOT_INHIBITED)
assertEquals(5, ECLConstants.INHIBIT_COMM_CHECK)
assertEquals(3, ECLConstants.INHIBIT_PROTECTED_FIELD)
assertEquals(2, ECLConstants.INHIBIT_NUMERIC_ONLY)
assertEquals(4, ECLConstants.INHIBIT_OVERFLOW)
assertEquals(6, ECLConstants.INHIBIT_OPERATOR_DUE)
}
}
@@ -0,0 +1,67 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.input.InputProcessor
import haus.nightmare.lib3270j.printer.PrinterConfig
import haus.nightmare.lib3270j.printer.PrinterConstants
import haus.nightmare.lib3270j.protocol.DS3270Constants.*
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
class ScriptAndPrinterTest {
private lateinit var screenBuffer: ScreenBuffer
private lateinit var translator: EbcdicTranslator
private lateinit var inputProcessor: InputProcessor
@Before
fun setUp() {
val model = TerminalModel.IBM_3279_2
translator = EbcdicTranslator()
screenBuffer = ScreenBuffer(model, translator)
inputProcessor = InputProcessor(screenBuffer, translator, null)
}
@Test
fun testEmulateInputMnemonicTokens() {
// Position 0: Unprotected input field
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
screenBuffer.cursorAddress = 1
inputProcessor.sendKeys("HELLO[enter]")
assertEquals('H', screenBuffer.getCell(1).ucs4)
assertEquals('E', screenBuffer.getCell(2).ucs4)
assertEquals('L', screenBuffer.getCell(3).ucs4)
assertEquals('L', screenBuffer.getCell(4).ucs4)
assertEquals('O', screenBuffer.getCell(5).ucs4)
assertEquals(AID_ENTER, inputProcessor.lastAid)
inputProcessor.reset()
inputProcessor.sendKeys("[pf3]")
assertEquals(AID_PF3, inputProcessor.lastAid)
}
@Test
fun testPrinterConfigParameters() {
val config = PrinterConfig("10.0.0.1", 992, "PRT01", true).apply {
associatedDisplayLuName = "DSP01"
codePage = "1047"
mpp = 132
mpl = 66
destinationType = PrinterConfig.DestinationType.MEMORY
}
assertEquals("10.0.0.1", config.host)
assertEquals(992, config.port)
assertEquals("PRT01", config.printerLuName)
assertEquals("DSP01", config.associatedDisplayLuName)
assertTrue(config.isUseTls)
assertEquals("1047", config.codePage)
assertEquals(132, config.mpp)
assertEquals(66, config.mpl)
assertEquals(PrinterConfig.DestinationType.MEMORY, config.destinationType)
}
}