Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2a22a636b6
|
|||
|
d3c149fe4a
|
|||
|
8c8e1f17db
|
|||
|
17f8cae925
|
|||
|
9bf91f5c49
|
|||
|
cf58b5c72c
|
|||
|
96ee5ebce1
|
|||
|
3a7811543d
|
|||
|
c767953c69
|
|||
|
60315098e2
|
|||
|
4b112d47f4
|
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# a3270
|
# a3270
|
||||||
|
|
||||||
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
[](https://git.hugfreevikings.wtf/rudi/a3270/actions)
|
||||||
[](https://git.hugfreevikings.wtf/rudi/a3270/releases)
|
|
||||||
[](https://developer.android.com)
|
[](https://developer.android.com)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
@@ -94,35 +93,6 @@ build/outputs/apk/debug/a3270-debug.apk
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏷️ How to Tag & Publish (Beta) Releases
|
|
||||||
|
|
||||||
Releases can be published automatically to Gitea via Git tags:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create a version tag (stable or beta)
|
|
||||||
git tag -a v0.1.0-beta.1 -m "Release v0.1.0-beta.1"
|
|
||||||
|
|
||||||
# Or a stable release:
|
|
||||||
git tag -a v0.1.0 -m "Release v0.1.0"
|
|
||||||
|
|
||||||
# 2. Push the tag to Gitea
|
|
||||||
git push origin v0.1.0-beta.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Pushing any `v*` tag triggers the automated release workflow (`.gitea/workflows/release.yaml`), which:
|
|
||||||
1. Automatically detects pre-releases (tags containing `beta`, `alpha`, `rc`, `pre`, `preview`, or `dev`) and marks them as **Pre-release** on Gitea.
|
|
||||||
2. Builds the APK and packages it.
|
|
||||||
3. Publishes a new Gitea Release with `a3270.apk` attached automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Topics & Discoverability
|
|
||||||
|
|
||||||
Suggested keywords/topics for repository indexing:
|
|
||||||
`tn3270` · `tn3270e` · `ibm-3270` · `mainframe` · `terminal-emulator` · `android` · `android-app` · `jetpack-compose` · `kotlin` · `x3270` · `j3270` · `ind-file` · `file-transfer` · `ebcdic` · `z-vm` · `z-os` · `cms` · `tso` · `cics`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
|
Contributions are welcome! Please feel free to open a bug report or feature request via the Gitea issue tracker.
|
||||||
@@ -139,3 +109,6 @@ I have contributed no code to this project, it was entirely written by LLMs with
|
|||||||
- Claude Opus 4.6
|
- Claude Opus 4.6
|
||||||
- Gemini 3.1 Pro
|
- Gemini 3.1 Pro
|
||||||
- Gemini 3.7 Flash
|
- Gemini 3.7 Flash
|
||||||
|
- Gemma4 12B and 26B
|
||||||
|
- GPT-OSS 120B
|
||||||
|
- Qwen3 4B and 32B
|
||||||
|
|||||||
+12
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "org.pubvm.a3270"
|
applicationId "org.pubvm.a3270"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 1
|
versionCode 4
|
||||||
versionName "0.1.0"
|
versionName "0.1.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -42,6 +42,13 @@ android {
|
|||||||
if (findProject(':lib3270j') != null) {
|
if (findProject(':lib3270j') != null) {
|
||||||
project(':lib3270j') {
|
project(':lib3270j') {
|
||||||
apply plugin: 'java-library'
|
apply plugin: 'java-library'
|
||||||
|
dependencies {
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
}
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -62,4 +69,7 @@ dependencies {
|
|||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation 'androidx.compose.material3:material3'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||||
|
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
echo "=== Building a3270 (Android APK) ==="
|
echo "=== Building a3270 (Android APK) ==="
|
||||||
|
|
||||||
# Set up JAVA_HOME if not already set
|
# Set up JAVA_HOME (prefer JDK 21 if available)
|
||||||
if [ -z "$JAVA_HOME" ]; then
|
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
||||||
if [ -d "/Users/rudi/.sdkman/candidates/java/21.0.6-sem" ]; then
|
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/21.0.6-sem"
|
||||||
elif [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
elif [ -z "$JAVA_HOME" ]; then
|
||||||
|
if [ -d "/Users/rudi/.sdkman/candidates/java/current" ]; then
|
||||||
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
export JAVA_HOME="/Users/rudi/.sdkman/candidates/java/current"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -42,7 +42,7 @@ if [ ! -d "$J3270_PATH/lib3270j" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building debug APK..."
|
echo "Building debug APK..."
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew assembleDebug
|
||||||
|
|
||||||
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
APK_PATH="$SCRIPT_DIR/build/outputs/apk/debug/a3270-debug.apk"
|
||||||
if [ -f "$APK_PATH" ]; then
|
if [ -f "$APK_PATH" ]; then
|
||||||
|
|||||||
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
org.gradle.jvmargs=-Xmx2048m -XX:+IgnoreUnrecognizedVMOptions -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
|
|||||||
@@ -4,74 +4,90 @@ import android.content.pm.PackageManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.TextRange
|
|
||||||
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.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
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.service.TerminalService
|
||||||
import org.pubvm.a3270.storage.HostStorage
|
import org.pubvm.a3270.storage.HostStorage
|
||||||
import org.pubvm.a3270.ui.ConnectDialog
|
import org.pubvm.a3270.ui.ConnectDialog
|
||||||
import org.pubvm.a3270.ui.FileTransferDialog
|
import org.pubvm.a3270.ui.FileTransferDialog
|
||||||
import org.pubvm.a3270.ui.TwoRowKeyBar
|
|
||||||
import org.pubvm.a3270.ui.OiaStatusBar
|
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.TerminalView
|
||||||
|
import org.pubvm.a3270.ui.TwoRowKeyBar
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.size
|
import org.pubvm.a3270.ui.UntrustedCertificateDialog
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
|
||||||
import androidx.compose.ui.input.key.key
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.input.key.type
|
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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 viewModel: TerminalViewModel by viewModels()
|
||||||
private val isShiftPressedState = mutableStateOf(false)
|
private val isShiftPressedState = mutableStateOf(false)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
window.setSoftInputMode(
|
|
||||||
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or
|
|
||||||
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
if (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101)
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = darkColorScheme(
|
colorScheme = darkColorScheme(
|
||||||
background = Color(0xFF000000),
|
background = Color.Black,
|
||||||
surface = Color(0xFF1E1E1E)
|
surface = Color(0xFF1E1E1E),
|
||||||
|
primary = Color(0xFF339AF0)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
@@ -83,62 +99,280 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
||||||
if (isShiftPressedState.value != shift) {
|
if (isShiftPressedState.value != shift) {
|
||||||
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) {
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||||
val keyCode = event.keyCode
|
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) {
|
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
|
||||||
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
||||||
val pfNum = if (shift) fNum + 12 else fNum
|
val pfNum = if (shift || alt) fNum + 12 else fNum
|
||||||
val pfAids = intArrayOf(
|
val pfAids = intArrayOf(
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
|
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
|
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
|
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
|
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF9, org.lib3270j.protocol.DS3270Constants.AID_PF10,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF11, org.lib3270j.protocol.DS3270Constants.AID_PF12,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF13, org.lib3270j.protocol.DS3270Constants.AID_PF14,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF15, org.lib3270j.protocol.DS3270Constants.AID_PF16,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF17, org.lib3270j.protocol.DS3270Constants.AID_PF18,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF19, org.lib3270j.protocol.DS3270Constants.AID_PF20,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF21, org.lib3270j.protocol.DS3270Constants.AID_PF22,
|
|
||||||
org.lib3270j.protocol.DS3270Constants.AID_PF23, org.lib3270j.protocol.DS3270Constants.AID_PF24
|
|
||||||
)
|
)
|
||||||
viewModel.sendAid(pfAids[pfNum - 1])
|
viewModel.sendAid(pfAids[pfNum - 1])
|
||||||
if (shift) {
|
if (shift) {
|
||||||
isShiftPressedState.value = false
|
isShiftPressedState.value = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
}
|
||||||
|
|
||||||
|
// 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()
|
viewModel.cursorLeft()
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||||
viewModel.cursorUp()
|
viewModel.cursorUp()
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||||
viewModel.cursorDown()
|
viewModel.cursorDown()
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||||
viewModel.cursorRight()
|
viewModel.cursorRight()
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
|
}
|
||||||
|
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)
|
viewModel.sendAid(AID_ENTER)
|
||||||
return true
|
return true
|
||||||
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
|
}
|
||||||
if (event.isShiftPressed) {
|
KeyEvent.KEYCODE_TAB -> {
|
||||||
|
if (shift) {
|
||||||
viewModel.backTab()
|
viewModel.backTab()
|
||||||
|
isShiftPressedState.value = false
|
||||||
} else {
|
} else {
|
||||||
viewModel.tab()
|
viewModel.tab()
|
||||||
}
|
}
|
||||||
return true
|
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)
|
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
|
@Composable
|
||||||
@@ -156,14 +390,21 @@ fun MainScreen(
|
|||||||
val oiaText by viewModel.oiaText.collectAsState()
|
val oiaText by viewModel.oiaText.collectAsState()
|
||||||
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
||||||
val screenVersion by viewModel.screenVersion.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 showConnectDialog by remember { mutableStateOf(false) }
|
||||||
var showFtDialog by remember { mutableStateOf(false) }
|
var showFtDialog by remember { mutableStateOf(false) }
|
||||||
|
var showSettingsDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
var terminalInputViewRef by remember { mutableStateOf<TerminalInputView?>(null) }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
|
|
||||||
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
@@ -183,7 +424,17 @@ fun MainScreen(
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val autoHost = HostStorage.getAutoConnectHost(context)
|
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||||
if (autoHost != null && !connectionState.isConnected()) {
|
if (autoHost != null && !connectionState.isConnected()) {
|
||||||
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName)
|
viewModel.connect(
|
||||||
|
autoHost.host,
|
||||||
|
autoHost.port,
|
||||||
|
autoHost.model,
|
||||||
|
autoHost.luName,
|
||||||
|
autoHost.hostType,
|
||||||
|
autoHost.useTls,
|
||||||
|
autoHost.tlsVerifyCert,
|
||||||
|
autoHost.tn3270e,
|
||||||
|
autoHost.graphicsMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +457,20 @@ fun MainScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays)
|
// Main 3270 Terminal Screen View
|
||||||
TerminalView(
|
TerminalView(
|
||||||
screenBuffer = screenBuffer,
|
screenBuffer = screenBuffer,
|
||||||
rows = rows,
|
rows = rows,
|
||||||
cols = cols,
|
cols = cols,
|
||||||
cursorAddr = cursorAddr,
|
cursorAddr = cursorAddr,
|
||||||
screenVersion = screenVersion,
|
screenVersion = screenVersion,
|
||||||
|
programSymbolManager = viewModel.getClient()?.programSymbolManager,
|
||||||
|
graphicsPlane = viewModel.getClient()?.graphicsPlane,
|
||||||
|
maskHiddenFields = maskHiddenInput,
|
||||||
|
blinkCursor = cursorBlink,
|
||||||
onTapAddress = { addr ->
|
onTapAddress = { addr ->
|
||||||
viewModel.setCursor(addr)
|
viewModel.setCursor(addr)
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
keyboardController?.show()
|
|
||||||
},
|
},
|
||||||
onPasteText = { text ->
|
onPasteText = { text ->
|
||||||
viewModel.pasteString(text)
|
viewModel.pasteString(text)
|
||||||
@@ -224,92 +478,77 @@ fun MainScreen(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Transparent BasicTextField maintaining active Android IME connection
|
// Termux-Style Native Input Connection View (Permanent Number Row, No Mode Reset, Lock Support)
|
||||||
BasicTextField(
|
AndroidView(
|
||||||
value = textFieldValue,
|
factory = { ctx ->
|
||||||
onValueChange = { newValue ->
|
TerminalInputView(ctx).apply {
|
||||||
val text = newValue.text
|
onInputText = { text ->
|
||||||
if (text.isNotEmpty()) {
|
viewModel.typeString(text)
|
||||||
for (ch in text) {
|
|
||||||
if (ch == '\n' || ch == '\r') {
|
|
||||||
viewModel.sendAid(AID_ENTER)
|
|
||||||
} else {
|
|
||||||
viewModel.typeChar(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
textFieldValue = TextFieldValue("")
|
|
||||||
onClearShift()
|
onClearShift()
|
||||||
}
|
}
|
||||||
},
|
onSendAid = { aid ->
|
||||||
textStyle = TextStyle(color = Color.Transparent),
|
viewModel.sendAid(aid)
|
||||||
cursorBrush = SolidColor(Color.Transparent),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
autoCorrect = false,
|
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction = ImeAction.None
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = { viewModel.sendAid(AID_ENTER) },
|
|
||||||
onGo = { viewModel.sendAid(AID_ENTER) },
|
|
||||||
onSend = { viewModel.sendAid(AID_ENTER) },
|
|
||||||
onNext = {
|
|
||||||
// Stubbed for future form field navigation
|
|
||||||
}
|
}
|
||||||
),
|
onBackspace = {
|
||||||
modifier = Modifier
|
|
||||||
.size(1.dp)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.onPreviewKeyEvent { keyEvent ->
|
|
||||||
if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == androidx.compose.ui.input.key.Key.Backspace) {
|
|
||||||
viewModel.backspace()
|
viewModel.backspace()
|
||||||
onClearShift()
|
onClearShift()
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
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)
|
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
|
||||||
TwoRowKeyBar(
|
TwoRowKeyBar(
|
||||||
connectionState = connectionState,
|
connectionState = connectionState,
|
||||||
isShiftPressed = isShiftPressed,
|
isShiftPressed = isShiftPressed,
|
||||||
|
hapticFeedbackEnabled = hapticFeedback,
|
||||||
onClearShift = onClearShift,
|
onClearShift = onClearShift,
|
||||||
onConnectClick = { showConnectDialog = true },
|
onConnectClick = { showConnectDialog = true },
|
||||||
onDisconnectClick = { viewModel.disconnect() },
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
onFtClick = { showFtDialog = true },
|
onFtClick = { showFtDialog = true },
|
||||||
|
onSettingsClick = { showSettingsDialog = true },
|
||||||
onSendAid = { aid ->
|
onSendAid = { aid ->
|
||||||
viewModel.sendAid(aid)
|
viewModel.sendAid(aid)
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onReset = {
|
onReset = {
|
||||||
viewModel.resetKeyboard()
|
viewModel.resetKeyboard()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onTab = {
|
onTab = {
|
||||||
viewModel.tab()
|
viewModel.tab()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onBackTab = {
|
onBackTab = {
|
||||||
viewModel.backTab()
|
viewModel.backTab()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onCursorLeft = {
|
onCursorLeft = {
|
||||||
viewModel.cursorLeft()
|
viewModel.cursorLeft()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onCursorUp = {
|
onCursorUp = {
|
||||||
viewModel.cursorUp()
|
viewModel.cursorUp()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onCursorDown = {
|
onCursorDown = {
|
||||||
viewModel.cursorDown()
|
viewModel.cursorDown()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
},
|
},
|
||||||
onCursorRight = {
|
onCursorRight = {
|
||||||
viewModel.cursorRight()
|
viewModel.cursorRight()
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,7 +560,10 @@ fun MainScreen(
|
|||||||
oiaText = oiaText,
|
oiaText = oiaText,
|
||||||
cursorAddr = cursorAddr,
|
cursorAddr = cursorAddr,
|
||||||
rows = rows,
|
rows = rows,
|
||||||
cols = cols
|
cols = cols,
|
||||||
|
isTls = isTlsActive,
|
||||||
|
isTlsVerified = isTlsVerified,
|
||||||
|
graphicsMode = defaultGraphicsMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,19 +571,60 @@ fun MainScreen(
|
|||||||
if (showConnectDialog) {
|
if (showConnectDialog) {
|
||||||
ConnectDialog(
|
ConnectDialog(
|
||||||
onDismiss = { showConnectDialog = false },
|
onDismiss = { showConnectDialog = false },
|
||||||
onConnect = { host, port, model, luName ->
|
onConnect = { host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode ->
|
||||||
showConnectDialog = false
|
showConnectDialog = false
|
||||||
viewModel.connect(host, port, model, luName)
|
viewModel.connect(host, port, model, luName, hostType, useTls, tlsVerifyCert, tn3270e, graphicsMode)
|
||||||
focusRequester.requestFocus()
|
terminalInputViewRef?.showSoftKeyboard()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showFtDialog) {
|
if (showFtDialog) {
|
||||||
FileTransferDialog(
|
FileTransferDialog(
|
||||||
|
initialHostType = viewModel.activeHostType,
|
||||||
|
ftProgressState = ftState,
|
||||||
onDismiss = { showFtDialog = false },
|
onDismiss = { showFtDialog = false },
|
||||||
onStartTransfer = { config ->
|
onStartTransfer = { config ->
|
||||||
showFtDialog = false
|
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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,64 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.lib3270j.ConnectionConfig
|
import org.lib3270j.ConnectionConfig
|
||||||
import org.lib3270j.ConnectionState
|
import org.lib3270j.ConnectionState
|
||||||
import org.lib3270j.Telnet3270Client
|
import org.lib3270j.Telnet3270Client
|
||||||
import org.lib3270j.TerminalModel
|
import org.lib3270j.TerminalModel
|
||||||
|
import org.lib3270j.ft.FTConfig
|
||||||
import org.lib3270j.listener.ConnectionListener
|
import org.lib3270j.listener.ConnectionListener
|
||||||
import org.lib3270j.listener.ScreenUpdateListener
|
import org.lib3270j.listener.ScreenUpdateListener
|
||||||
|
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
||||||
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
|
import org.pubvm.a3270.ft.FileTransfer
|
||||||
import org.pubvm.a3270.service.TerminalService
|
import org.pubvm.a3270.service.TerminalService
|
||||||
|
import org.pubvm.a3270.storage.AppSettings
|
||||||
|
import java.io.File
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.logging.Logger
|
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) {
|
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
||||||
@@ -31,6 +73,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val inputExecutor = Executors.newSingleThreadExecutor()
|
private val inputExecutor = Executors.newSingleThreadExecutor()
|
||||||
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
|
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)
|
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
|
||||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
@@ -55,22 +100,205 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
private val _isKeyboardLocked = MutableStateFlow(false)
|
private val _isKeyboardLocked = MutableStateFlow(false)
|
||||||
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
|
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
|
private var client: Telnet3270Client? = null
|
||||||
var currentHost: String = ""
|
var currentHost: String = ""
|
||||||
private set
|
private set
|
||||||
|
var activeHostType: String = "TSO"
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun getClient(): Telnet3270Client? = client
|
||||||
|
|
||||||
private var lastScreenContentHash: Int = 0
|
private var lastScreenContentHash: Int = 0
|
||||||
private var hasInitialScreenLoaded: Boolean = false
|
private var hasInitialScreenLoaded: Boolean = false
|
||||||
|
|
||||||
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
|
init {
|
||||||
if (_connectionState.value.isConnected()) return
|
// 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
|
currentHost = host
|
||||||
|
activeHostType = hostType
|
||||||
lastScreenContentHash = 0
|
lastScreenContentHash = 0
|
||||||
hasInitialScreenLoaded = false
|
hasInitialScreenLoaded = false
|
||||||
|
fileTransferCoordinator = null
|
||||||
|
_ftState.value = FTProgressState()
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
_oiaText.value = "Connecting to $host:$port..."
|
// 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) {
|
val model = when (modelNum) {
|
||||||
3 -> TerminalModel.IBM_3279_3
|
3 -> TerminalModel.IBM_3279_3
|
||||||
@@ -79,10 +307,52 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
else -> TerminalModel.IBM_3279_2
|
else -> TerminalModel.IBM_3279_2
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = ConnectionConfig(host, port, model).apply {
|
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()) {
|
if (luName.isNotBlank()) {
|
||||||
setLuName(luName)
|
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)
|
val newClient = Telnet3270Client(config)
|
||||||
@@ -91,8 +361,11 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
newClient.addConnectionListener(object : ConnectionListener {
|
newClient.addConnectionListener(object : ConnectionListener {
|
||||||
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
||||||
_connectionState.value = newState
|
_connectionState.value = newState
|
||||||
|
val tlsSuffix = if (effectiveTls) {
|
||||||
|
if (effectiveVerify) " [🔒 TLS]" else " [🔓 TLS/Unverified]"
|
||||||
|
} else ""
|
||||||
_oiaText.value = if (newState.isFullSession()) {
|
_oiaText.value = if (newState.isFullSession()) {
|
||||||
"3270 Connected ($host)"
|
"3270 Connected ($effectiveHost - $activeHostType)$tlsSuffix"
|
||||||
} else if (newState.isHalfConnected()) {
|
} else if (newState.isHalfConnected()) {
|
||||||
"Connecting..."
|
"Connecting..."
|
||||||
} else {
|
} else {
|
||||||
@@ -122,6 +395,9 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Drive CUT mode file transfers if active
|
||||||
|
fileTransferCoordinator?.onScreenUpdated()
|
||||||
|
|
||||||
val newHash = computeScreenContentHash(buf)
|
val newHash = computeScreenContentHash(buf)
|
||||||
val contentChanged = (newHash != lastScreenContentHash)
|
val contentChanged = (newHash != lastScreenContentHash)
|
||||||
|
|
||||||
@@ -150,10 +426,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
_screenBuffer.value = newClient.screenBuffer
|
_screenBuffer.value = newClient.screenBuffer
|
||||||
|
|
||||||
newClient.connect()
|
newClient.connect()
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
log.severe("Failed to connect: ${e.message}")
|
log.severe("Failed to connect: ${e.message}")
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}"
|
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message ?: "Connection error"}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,186 +437,194 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
fileTransferCoordinator?.reset()
|
||||||
|
fileTransferCoordinator = null
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = null
|
client = null
|
||||||
_connectionState.value = ConnectionState.NOT_CONNECTED
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
_oiaText.value = "Disconnected"
|
_oiaText.value = "Disconnected"
|
||||||
|
_ftState.value = FTProgressState()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.warning("Error disconnecting: ${e.message}")
|
log.warning("Error disconnecting: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun typeChar(ch: Char) {
|
fun startFileTransfer(config: FTConfig, onResult: (String?) -> Unit = {}) {
|
||||||
val c = client ?: return
|
val c = client
|
||||||
viewModelScope.launch(inputDispatcher) {
|
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 {
|
try {
|
||||||
val ip = c.inputProcessor
|
// Resolve local path if relative (default to app files or download directory)
|
||||||
ip.isKeyboardLocked = false // Ensure keyboard lock is cleared on user typing
|
val rawPath = config.localFilename
|
||||||
val buf = c.screenBuffer
|
if (!rawPath.startsWith("/")) {
|
||||||
var curAddr = buf.cursorAddress
|
val appFilesDir = getApplication<Application>().getExternalFilesDir(null) ?: getApplication<Application>().filesDir
|
||||||
if (buf.isFormatted) {
|
val resolvedFile = File(appFilesDir, rawPath)
|
||||||
val faVal = buf.getFieldAttributeAt(curAddr)
|
config.localFilename = resolvedFile.absolutePath
|
||||||
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(curAddr).isFieldAttribute) {
|
|
||||||
curAddr = buf.findNextUnprotected(curAddr)
|
|
||||||
buf.cursorAddress = curAddr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
ip.typeCharacter(ch)
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.warning("Error typing char: ${e.message}")
|
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) {
|
fun pasteString(text: String) {
|
||||||
val c = client ?: return
|
if (text.isNotEmpty()) {
|
||||||
viewModelScope.launch(inputDispatcher) {
|
inputChannel.trySend(TerminalInputAction.TypeText(text))
|
||||||
try {
|
|
||||||
val ip = c.inputProcessor
|
|
||||||
ip.isKeyboardLocked = false
|
|
||||||
for (ch in text) {
|
|
||||||
if (ch == '\n' || ch == '\r') {
|
|
||||||
ip.tab()
|
|
||||||
} else if (ch >= ' ') {
|
|
||||||
ip.typeCharacter(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error pasting text: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backspace() {
|
fun backspace() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Backspace)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.isKeyboardLocked = false
|
|
||||||
c.inputProcessor.backspace()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error backspacing: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteChar() {
|
||||||
|
inputChannel.trySend(TerminalInputAction.DeleteChar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tab() {
|
fun tab() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Tab)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.tab()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error tabbing: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun backTab() {
|
fun backTab() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.BackTab)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.backTab()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error back-tabbing: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetKeyboard() {
|
fun resetKeyboard() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.Reset)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.reset()
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error resetting keyboard: ${e.message}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun eraseEof() {
|
||||||
|
inputChannel.trySend(TerminalInputAction.EraseEof)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun eraseInput() {
|
||||||
|
inputChannel.trySend(TerminalInputAction.EraseInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newline() {
|
||||||
|
inputChannel.trySend(TerminalInputAction.Newline)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendAid(aidCode: Int) {
|
fun sendAid(aidCode: Int) {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.SendAid(aidCode))
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.setKeyboardLocked(false)
|
|
||||||
c.inputProcessor.sendAid(aidCode)
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error sending AID: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCursor(baddr: Int) {
|
fun setCursor(baddr: Int) {
|
||||||
val buf = _screenBuffer.value ?: return
|
inputChannel.trySend(TerminalInputAction.SetCursor(baddr))
|
||||||
if (baddr in 0 until (buf.rows * buf.cols)) {
|
|
||||||
buf.cursorAddress = baddr
|
|
||||||
_cursorAddress.value = baddr
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorLeft() {
|
fun cursorLeft() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorLeft)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.cursorLeft()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error cursorLeft: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorUp() {
|
fun cursorUp() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorUp)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.cursorUp()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error cursorUp: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorDown() {
|
fun cursorDown() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorDown)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.cursorDown()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error cursorDown: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cursorRight() {
|
fun cursorRight() {
|
||||||
val c = client ?: return
|
inputChannel.trySend(TerminalInputAction.CursorRight)
|
||||||
viewModelScope.launch(inputDispatcher) {
|
|
||||||
try {
|
|
||||||
c.inputProcessor.cursorRight()
|
|
||||||
_cursorAddress.value = c.screenBuffer.cursorAddress
|
|
||||||
_screenVersion.value = System.currentTimeMillis()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.warning("Error cursorRight: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cursorHome() {
|
||||||
|
inputChannel.trySend(TerminalInputAction.CursorHome)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
||||||
@@ -414,7 +698,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
fileTransferCoordinator?.reset()
|
||||||
|
fileTransferCoordinator = null
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
|
inputChannel.close()
|
||||||
inputExecutor.shutdown()
|
inputExecutor.shutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package org.pubvm.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 java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Timer
|
||||||
|
import java.util.TimerTask
|
||||||
|
import java.util.logging.Logger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android coordinator for IND$FILE file transfers.
|
||||||
|
* Manages the transfer state machine, timeouts, and coordinates between
|
||||||
|
* UI, Telnet3270Client, and lower-level CUT/DFT protocol handlers.
|
||||||
|
*/
|
||||||
|
class FileTransfer(
|
||||||
|
private val client: Telnet3270Client,
|
||||||
|
private val callback: FileTransferCallback
|
||||||
|
) : FTCut.FTCutListener, FTDft.FTDftListener {
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(FileTransfer::class.java.name)
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
enum class FTMode {
|
||||||
|
UNKNOWN, CUT, DFT
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileTransferCallback {
|
||||||
|
fun onTransferStarted()
|
||||||
|
fun onTransferRunning()
|
||||||
|
fun onBytesTransferred(bytes: Long)
|
||||||
|
fun onTransferComplete(message: String)
|
||||||
|
fun onTransferAborted(error: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentConfig: FTConfig? = null
|
||||||
|
private var localFile: File? = null
|
||||||
|
private var state = FTState.NONE
|
||||||
|
private var activeMode = FTMode.UNKNOWN
|
||||||
|
|
||||||
|
private var cutHandler: FTCut? = null
|
||||||
|
private var dftHandler: FTDft? = null
|
||||||
|
|
||||||
|
private var timeoutTimer: Timer? = null
|
||||||
|
private val startTimeoutMs = 30000L // 30 seconds
|
||||||
|
|
||||||
|
fun startTransfer(config: FTConfig): String? {
|
||||||
|
if (state != FTState.NONE) {
|
||||||
|
return "A transfer is already in progress."
|
||||||
|
}
|
||||||
|
|
||||||
|
val validationError = config.validate()
|
||||||
|
if (validationError != null) {
|
||||||
|
return validationError
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentConfig = config
|
||||||
|
this.localFile = File(config.localFilename)
|
||||||
|
this.activeMode = FTMode.UNKNOWN
|
||||||
|
|
||||||
|
// Check overwrite
|
||||||
|
if (config.isReceive && !config.isAppend && !config.isOverwrite) {
|
||||||
|
if (localFile?.exists() == true) {
|
||||||
|
return "Local file already exists and overwrite is not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize protocol handlers lazily
|
||||||
|
if (cutHandler == null) {
|
||||||
|
cutHandler = FTCut(
|
||||||
|
client.screenBuffer,
|
||||||
|
client.inputProcessor,
|
||||||
|
client.translator,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dftHandler == null) {
|
||||||
|
dftHandler = FTDft(
|
||||||
|
client.inputProcessor,
|
||||||
|
client.translator,
|
||||||
|
this
|
||||||
|
)
|
||||||
|
client.dataStreamProcessor.setFTDft(dftHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and type the IND$FILE command
|
||||||
|
val command = config.buildCommand()
|
||||||
|
log.info("Starting IND\$FILE transfer with command: $command")
|
||||||
|
|
||||||
|
// Erase field and verify it can hold the command
|
||||||
|
val capacity = client.inputProcessor.kybdPrime()
|
||||||
|
if (capacity < 0) {
|
||||||
|
cleanupHandlers(false)
|
||||||
|
return when (capacity) {
|
||||||
|
-1 -> "Keyboard is locked."
|
||||||
|
-3 -> "No unprotected input field found on screen."
|
||||||
|
else -> "Cannot start transfer from current screen state."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (capacity < command.length) {
|
||||||
|
cleanupHandlers(false)
|
||||||
|
return "Current input field is too small for IND\$FILE command ($capacity chars max)."
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(FTState.AWAIT_ACK)
|
||||||
|
client.emulateInput(command + "\n")
|
||||||
|
|
||||||
|
startTimeout()
|
||||||
|
mainHandler.post { callback.onTransferStarted() }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
if (state == FTState.RUNNING || state == FTState.AWAIT_ACK) {
|
||||||
|
log.info("User cancelled transfer")
|
||||||
|
setState(FTState.ABORT_WAIT)
|
||||||
|
} else if (state != FTState.NONE) {
|
||||||
|
log.info("Forcing cancel from state $state")
|
||||||
|
completeTransfer("Transfer cancelled.")
|
||||||
|
mainHandler.post { callback.onTransferAborted("Cancelled by user.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
log.info("Force resetting FileTransfer state from $state")
|
||||||
|
completeTransfer("Reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTransferActive(): Boolean = state != FTState.NONE
|
||||||
|
|
||||||
|
fun getActiveMode(): FTMode = activeMode
|
||||||
|
|
||||||
|
fun onScreenUpdated() {
|
||||||
|
if ((activeMode == FTMode.CUT || activeMode == FTMode.UNKNOWN) &&
|
||||||
|
(state == FTState.AWAIT_ACK || state == FTState.RUNNING || state == FTState.ABORT_WAIT)) {
|
||||||
|
cutHandler?.processScreenUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTimeout() {
|
||||||
|
cancelTimeout()
|
||||||
|
timeoutTimer = Timer("FTTimeout", true).apply {
|
||||||
|
schedule(object : TimerTask() {
|
||||||
|
override fun run() {
|
||||||
|
mainHandler.post {
|
||||||
|
if (state == FTState.AWAIT_ACK) {
|
||||||
|
log.warning("Transfer start timeout")
|
||||||
|
completeTransfer("Transfer failed to start within 30 seconds.")
|
||||||
|
callback.onTransferAborted("Transfer start timeout.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, startTimeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTimeout() {
|
||||||
|
timeoutTimer?.cancel()
|
||||||
|
timeoutTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupHandlers(success: Boolean) {
|
||||||
|
cutHandler?.cleanup()
|
||||||
|
dftHandler?.cleanup()
|
||||||
|
|
||||||
|
if (!success && currentConfig != null && currentConfig?.isReceive == true && !currentConfig!!.isAppend) {
|
||||||
|
if (state != FTState.NONE && state != FTState.AWAIT_ACK && localFile != null && localFile!!.exists()) {
|
||||||
|
log.info("Cleaning up incomplete download: ${localFile!!.absolutePath}")
|
||||||
|
localFile!!.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completeTransfer(errorMessage: String?) {
|
||||||
|
cancelTimeout()
|
||||||
|
val success = (errorMessage == null)
|
||||||
|
cleanupHandlers(success)
|
||||||
|
setState(FTState.NONE)
|
||||||
|
activeMode = FTMode.UNKNOWN
|
||||||
|
currentConfig = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCutRunning() {
|
||||||
|
handleTransferRunning(FTMode.CUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDftRunning() {
|
||||||
|
handleTransferRunning(FTMode.DFT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleTransferRunning(mode: FTMode) {
|
||||||
|
if (activeMode == FTMode.UNKNOWN) {
|
||||||
|
activeMode = mode
|
||||||
|
log.info("FT mode established: $activeMode")
|
||||||
|
try {
|
||||||
|
if (activeMode == FTMode.DFT) {
|
||||||
|
dftHandler?.initTransfer(localFile)
|
||||||
|
} else {
|
||||||
|
cutHandler?.initTransfer(localFile)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
log.warning("Failed to open local file for $activeMode: ${e.message}")
|
||||||
|
onTransferAborted("Failed to open local file: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTimeout()
|
||||||
|
setState(FTState.RUNNING)
|
||||||
|
mainHandler.post { callback.onTransferRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransferComplete(errorMessage: String?) {
|
||||||
|
completeTransfer(errorMessage)
|
||||||
|
mainHandler.post {
|
||||||
|
if (errorMessage == null) {
|
||||||
|
callback.onTransferComplete("File transfer complete.")
|
||||||
|
} else {
|
||||||
|
callback.onTransferAborted(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTransferAborted(errorMessage: String?) {
|
||||||
|
completeTransfer(errorMessage)
|
||||||
|
mainHandler.post { callback.onTransferAborted(errorMessage ?: "Transfer aborted") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBytesTransferred(bytes: Long) {
|
||||||
|
mainHandler.post { callback.onBytesTransferred(bytes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentState(): FTState = state
|
||||||
|
|
||||||
|
override fun setState(state: FTState) {
|
||||||
|
this.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getConfig(): FTConfig? = currentConfig
|
||||||
|
|
||||||
|
override fun getLocalFile(): File? = localFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,12 @@ data class SavedHost(
|
|||||||
val port: Int = 23,
|
val port: Int = 23,
|
||||||
val model: Int = 2,
|
val model: Int = 2,
|
||||||
val luName: String = "",
|
val luName: String = "",
|
||||||
val autoConnect: Boolean = false
|
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 {
|
fun toJson(): JSONObject {
|
||||||
return JSONObject().apply {
|
return JSONObject().apply {
|
||||||
@@ -24,6 +29,11 @@ data class SavedHost(
|
|||||||
put("model", model)
|
put("model", model)
|
||||||
put("luName", luName)
|
put("luName", luName)
|
||||||
put("autoConnect", autoConnect)
|
put("autoConnect", autoConnect)
|
||||||
|
put("hostType", hostType)
|
||||||
|
put("useTls", useTls)
|
||||||
|
put("tlsVerifyCert", tlsVerifyCert)
|
||||||
|
put("tn3270e", tn3270e)
|
||||||
|
put("graphicsMode", graphicsMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +46,12 @@ data class SavedHost(
|
|||||||
port = json.optInt("port", 23),
|
port = json.optInt("port", 23),
|
||||||
model = json.optInt("model", 2),
|
model = json.optInt("model", 2),
|
||||||
luName = json.optString("luName", ""),
|
luName = json.optString("luName", ""),
|
||||||
autoConnect = json.optBoolean("autoConnect", false)
|
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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +72,13 @@ object HostStorage {
|
|||||||
val jsonArray = JSONArray(jsonStr)
|
val jsonArray = JSONArray(jsonStr)
|
||||||
for (i in 0 until jsonArray.length()) {
|
for (i in 0 until jsonArray.length()) {
|
||||||
val obj = jsonArray.getJSONObject(i)
|
val obj = jsonArray.getJSONObject(i)
|
||||||
list.add(SavedHost.fromJson(obj))
|
val saved = SavedHost.fromJson(obj)
|
||||||
|
val effective = if (saved.graphicsMode == "PROGRAMMED_SYMBOLS") {
|
||||||
|
saved.copy(graphicsMode = "BOTH")
|
||||||
|
} else {
|
||||||
|
saved
|
||||||
|
}
|
||||||
|
list.add(effective)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.pubvm.a3270.ui
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -10,22 +11,34 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import org.pubvm.a3270.storage.HostStorage
|
import org.pubvm.a3270.storage.HostStorage
|
||||||
import org.pubvm.a3270.storage.SavedHost
|
import org.pubvm.a3270.storage.SavedHost
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectDialog(
|
fun ConnectDialog(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit
|
onConnect: (
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
model: Int,
|
||||||
|
luName: String,
|
||||||
|
hostType: String,
|
||||||
|
useTls: Boolean,
|
||||||
|
tlsVerifyCert: Boolean,
|
||||||
|
tn3270e: Boolean,
|
||||||
|
graphicsMode: String
|
||||||
|
) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
||||||
@@ -37,6 +50,11 @@ fun ConnectDialog(
|
|||||||
var modelNum by remember { mutableIntStateOf(2) }
|
var modelNum by remember { mutableIntStateOf(2) }
|
||||||
var luName by remember { mutableStateOf("") }
|
var luName by remember { mutableStateOf("") }
|
||||||
var autoConnect by remember { mutableStateOf(false) }
|
var autoConnect by remember { mutableStateOf(false) }
|
||||||
|
var hostType by remember { mutableStateOf("TSO") }
|
||||||
|
var useTls by remember { mutableStateOf(false) }
|
||||||
|
var tlsVerifyCert by remember { mutableStateOf(true) }
|
||||||
|
var tn3270e by remember { mutableStateOf(true) }
|
||||||
|
var graphicsMode by remember { mutableStateOf("BOTH") }
|
||||||
|
|
||||||
fun loadProfile(saved: SavedHost) {
|
fun loadProfile(saved: SavedHost) {
|
||||||
selectedHostId = saved.id
|
selectedHostId = saved.id
|
||||||
@@ -46,6 +64,11 @@ fun ConnectDialog(
|
|||||||
modelNum = saved.model
|
modelNum = saved.model
|
||||||
luName = saved.luName
|
luName = saved.luName
|
||||||
autoConnect = saved.autoConnect
|
autoConnect = saved.autoConnect
|
||||||
|
hostType = saved.hostType
|
||||||
|
useTls = saved.useTls
|
||||||
|
tlsVerifyCert = saved.tlsVerifyCert
|
||||||
|
tn3270e = saved.tn3270e
|
||||||
|
graphicsMode = saved.graphicsMode
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearFields() {
|
fun clearFields() {
|
||||||
@@ -56,10 +79,15 @@ fun ConnectDialog(
|
|||||||
modelNum = 2
|
modelNum = 2
|
||||||
luName = ""
|
luName = ""
|
||||||
autoConnect = false
|
autoConnect = false
|
||||||
|
hostType = "TSO"
|
||||||
|
useTls = false
|
||||||
|
tlsVerifyCert = true
|
||||||
|
tn3270e = true
|
||||||
|
graphicsMode = "BOTH"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentProfile(): SavedHost? {
|
fun saveCurrentProfile(): SavedHost? {
|
||||||
val port = portStr.toIntOrNull() ?: 23
|
val port = portStr.toIntOrNull() ?: if (useTls) 992 else 23
|
||||||
if (host.isBlank()) return null
|
if (host.isBlank()) return null
|
||||||
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
||||||
val hostToSave = SavedHost(
|
val hostToSave = SavedHost(
|
||||||
@@ -69,7 +97,12 @@ fun ConnectDialog(
|
|||||||
port = port,
|
port = port,
|
||||||
model = modelNum,
|
model = modelNum,
|
||||||
luName = luName.trim(),
|
luName = luName.trim(),
|
||||||
autoConnect = autoConnect
|
autoConnect = autoConnect,
|
||||||
|
hostType = hostType,
|
||||||
|
useTls = useTls,
|
||||||
|
tlsVerifyCert = tlsVerifyCert,
|
||||||
|
tn3270e = tn3270e,
|
||||||
|
graphicsMode = graphicsMode
|
||||||
)
|
)
|
||||||
HostStorage.saveHost(context, hostToSave)
|
HostStorage.saveHost(context, hostToSave)
|
||||||
savedHosts = HostStorage.getSavedHosts(context)
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
@@ -86,20 +119,24 @@ fun ConnectDialog(
|
|||||||
keyboardController?.show()
|
keyboardController?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = Color(0xFF1E1E1E),
|
color = Color(0xFF1E1E1E),
|
||||||
tonalElevation = 6.dp,
|
tonalElevation = 6.dp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.96f)
|
.fillMaxWidth(0.95f)
|
||||||
.wrapContentHeight()
|
.heightIn(max = 680.dp)
|
||||||
|
.padding(vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(14.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Title Row
|
// Title Row
|
||||||
Row(
|
Row(
|
||||||
@@ -107,12 +144,17 @@ fun ConnectDialog(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
|
Text(
|
||||||
|
"Connect / Manage Hosts",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { clearFields() },
|
onClick = { clearFields() },
|
||||||
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||||
) {
|
) {
|
||||||
Text("+ New Profile", fontSize = 12.sp)
|
Text("+ New Profile", fontSize = 12.sp, color = Color(0xFF339AF0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,96 +162,107 @@ fun ConnectDialog(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 300.dp)
|
.weight(1f, fill = false)
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// Saved Profiles List Section
|
||||||
|
if (savedHosts.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Saved Host Profiles:",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
// Saved Profiles List
|
savedHosts.forEach { saved ->
|
||||||
if (savedHosts.isNotEmpty()) {
|
val isSelected = (saved.id == selectedHostId)
|
||||||
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
val tlsTag = if (saved.useTls) {
|
||||||
Box(
|
if (saved.tlsVerifyCert) " [🔒 TLS]" else " [🔓 TLS/NoVerify]"
|
||||||
modifier = Modifier
|
} else ""
|
||||||
.fillMaxWidth()
|
val eTag = if (!saved.tn3270e) " [Non-E]" else ""
|
||||||
.heightIn(max = 95.dp)
|
val gfxTag = if (saved.graphicsMode != "NONE") " [GFX: ${saved.graphicsMode}]" else ""
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
savedHosts.forEach { profile ->
|
|
||||||
val isSelected = profile.id == selectedHostId
|
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(6.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719),
|
color = if (isSelected) Color(0xFF2C3E50) else Color(0xFF25262B),
|
||||||
|
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF339AF0)) else null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.border(
|
.clickable { loadProfile(saved) }
|
||||||
width = if (isSelected) 1.dp else 0.dp,
|
|
||||||
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
|
|
||||||
shape = RoundedCornerShape(6.dp)
|
|
||||||
)
|
|
||||||
.clickable { loadProfile(profile) }
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(profile.name, fontWeight = FontWeight.Bold, fontSize = 12.sp, color = Color.White)
|
|
||||||
if (profile.autoConnect) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Surface(
|
|
||||||
color = Color(0xFF2B8A3E),
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"AUTO",
|
text = saved.name,
|
||||||
fontSize = 9.sp,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.Bold,
|
fontSize = 13.sp,
|
||||||
color = Color.White,
|
color = Color.White
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
)
|
||||||
|
Text(
|
||||||
|
text = "${saved.host}:${saved.port} (M${saved.model} - ${saved.hostType})$tlsTag$eTag$gfxTag" +
|
||||||
|
if (saved.autoConnect) " [Auto]" else "",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = Color.LightGray
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
IconButton(
|
||||||
Text("${profile.host}:${profile.port} (M${profile.model})", fontSize = 11.sp, color = Color.Gray)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
TextButton(onClick = { loadProfile(profile) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
|
||||||
Text("Edit", fontSize = 11.sp)
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
HostStorage.deleteHost(context, profile.id)
|
loadProfile(saved)
|
||||||
|
val hostToConn = saveCurrentProfile() ?: saved
|
||||||
|
onConnect(
|
||||||
|
hostToConn.host,
|
||||||
|
hostToConn.port,
|
||||||
|
hostToConn.model,
|
||||||
|
hostToConn.luName,
|
||||||
|
hostToConn.hostType,
|
||||||
|
hostToConn.useTls,
|
||||||
|
hostToConn.tlsVerifyCert,
|
||||||
|
hostToConn.tn3270e,
|
||||||
|
hostToConn.graphicsMode
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Text("▶", fontSize = 14.sp, color = Color(0xFF51CF66))
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
HostStorage.deleteHost(context, saved.id)
|
||||||
savedHosts = HostStorage.getSavedHosts(context)
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
if (selectedHostId == profile.id) {
|
if (selectedHostId == saved.id) {
|
||||||
clearFields()
|
clearFields()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
modifier = Modifier.size(32.dp)
|
||||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
|
|
||||||
) {
|
) {
|
||||||
Text("Delete", fontSize = 11.sp)
|
Text("✕", fontSize = 13.sp, color = Color(0xFFFF6B6B))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(color = Color(0xFF373A40), modifier = Modifier.padding(vertical = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
|
text = if (selectedHostId != null) "Editing Profile:" else "Host Connection Details:",
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.Gray
|
color = Color.Gray
|
||||||
@@ -223,46 +276,223 @@ fun ConnectDialog(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = { host = it },
|
onValueChange = { host = it },
|
||||||
label = { Text("Host / IP Address") },
|
label = { Text("Host / IP Address") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(0.68f)
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = portStr,
|
value = portStr,
|
||||||
onValueChange = { portStr = it },
|
onValueChange = { portStr = it },
|
||||||
label = { Text("Port (default 23)") },
|
label = { Text("Port") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.weight(0.32f)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal Model Selector
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Terminal Model:", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
Text("Model:", fontSize = 11.sp, color = Color.Gray)
|
listOf(
|
||||||
listOf(2, 3, 4, 5).forEach { m ->
|
2 to "M2",
|
||||||
FilterChip(
|
3 to "M3",
|
||||||
selected = (modelNum == m),
|
4 to "M4",
|
||||||
onClick = { modelNum = m },
|
5 to "M5"
|
||||||
label = { Text("M$m", fontSize = 11.sp) }
|
).forEach { (m, label) ->
|
||||||
|
val isSelected = (modelNum == m)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(34.dp)
|
||||||
|
.clickable { modelNum = m }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host System Type (TSO, CMS, CICS)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Host System Type:", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
listOf("TSO", "CMS", "CICS").forEach { ht ->
|
||||||
|
val isSelected = (hostType == ht)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = if (isSelected) Color(0xFF1C7ED6) else Color(0xFF2C2D30),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(34.dp)
|
||||||
|
.clickable { hostType = ht }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = ht,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graphics Mode (Both, Vector, Symbols, Off)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Graphics Mode:", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
Spacer(modifier = Modifier.height(4.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 = graphicsMode.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 { graphicsMode = 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 = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = luName,
|
value = luName,
|
||||||
onValueChange = { luName = it },
|
onValueChange = { luName = it },
|
||||||
label = { Text("LU Name (Optional)") },
|
label = { Text("LU Name / Device Pool (Optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Protocol & Security Options
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
// TLS / SSL Checkbox
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
val newTls = !useTls
|
||||||
|
useTls = newTls
|
||||||
|
if (newTls && portStr.trim() == "23") {
|
||||||
|
portStr = "992"
|
||||||
|
} else if (!newTls && portStr.trim() == "992") {
|
||||||
|
portStr = "23"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = useTls,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
useTls = checked
|
||||||
|
if (checked && portStr.trim() == "23") {
|
||||||
|
portStr = "992"
|
||||||
|
} else if (!checked && portStr.trim() == "992") {
|
||||||
|
portStr = "23"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Enable TLS / SSL Connection", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Certificate Checkbox (only when TLS is active)
|
||||||
|
if (useTls) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp)
|
||||||
|
.clickable { tlsVerifyCert = !tlsVerifyCert }
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = tlsVerifyCert,
|
||||||
|
onCheckedChange = { tlsVerifyCert = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
"Verify Server Certificate",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (tlsVerifyCert) Color.LightGray else Color(0xFFFFB450)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TN3270E Checkbox
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { tn3270e = !tn3270e }
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = tn3270e,
|
||||||
|
onCheckedChange = { tn3270e = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Enable TN3270E (Extended 3270)", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-connect Checkbox
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -274,7 +504,8 @@ fun ConnectDialog(
|
|||||||
onCheckedChange = { autoConnect = it }
|
onCheckedChange = { autoConnect = it }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text("Auto-connect on app startup", fontSize = 12.sp)
|
Text("Auto-connect on app startup", fontSize = 12.sp, color = Color.White)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,22 +519,33 @@ fun ConnectDialog(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text("Cancel", color = Color.LightGray)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
OutlinedButton(onClick = { saveCurrentProfile() }) {
|
OutlinedButton(onClick = { saveCurrentProfile() }) {
|
||||||
Text("Save")
|
Text("Save")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val saved = saveCurrentProfile()
|
val saved = saveCurrentProfile()
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
onConnect(saved.host, saved.port, saved.model, saved.luName)
|
onConnect(
|
||||||
}
|
saved.host,
|
||||||
|
saved.port,
|
||||||
|
saved.model,
|
||||||
|
saved.luName,
|
||||||
|
saved.hostType,
|
||||||
|
saved.useTls,
|
||||||
|
saved.tlsVerifyCert,
|
||||||
|
saved.tn3270e,
|
||||||
|
saved.graphicsMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2B8A3E))
|
||||||
) {
|
) {
|
||||||
Text("Connect")
|
Text("Connect", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,227 @@
|
|||||||
package org.pubvm.a3270.ui
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import org.lib3270j.ft.FTConfig
|
import org.lib3270j.ft.FTConfig
|
||||||
|
import org.pubvm.a3270.FTProgressState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FileTransferDialog(
|
fun FileTransferDialog(
|
||||||
|
initialHostType: String = "TSO",
|
||||||
|
ftProgressState: FTProgressState = FTProgressState(),
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onStartTransfer: (FTConfig) -> 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 hostFile by remember { mutableStateOf("") }
|
||||||
var localFile by remember { mutableStateOf("") }
|
var localFile by remember { mutableStateOf("") }
|
||||||
var isReceive by remember { mutableStateOf(true) }
|
var isReceive by remember { mutableStateOf(true) }
|
||||||
var isAscii by remember { mutableStateOf(true) }
|
var isAscii by remember { mutableStateOf(true) }
|
||||||
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) }
|
var hostType by remember { mutableStateOf(initialTypeEnum) }
|
||||||
|
var recfm by remember { mutableStateOf(FTConfig.RecordFormat.DEFAULT) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
var lreclStr by remember { mutableStateOf("") }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
var blksizeStr by remember { mutableStateOf("") }
|
||||||
|
var overwrite by remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
kotlinx.coroutines.delay(150)
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("IND\$FILE File Transfer") },
|
title = {
|
||||||
|
Text("IND\$FILE File Transfer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.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(
|
OutlinedTextField(
|
||||||
value = hostFile,
|
value = hostFile,
|
||||||
onValueChange = { hostFile = it },
|
onValueChange = { hostFile = it },
|
||||||
label = { Text("Host File Name") },
|
label = { Text("Host Dataset / File Name") },
|
||||||
|
placeholder = { Text(if (hostType == FTConfig.HostType.TSO) "'USER.DATA'" else "PROFILE EXEC A") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = localFile,
|
value = localFile,
|
||||||
onValueChange = { localFile = it },
|
onValueChange = { localFile = it },
|
||||||
label = { Text("Local File Path") },
|
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,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Transfer Direction
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Direction:")
|
Text("Direction:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Row {
|
Row {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = isReceive,
|
selected = isReceive,
|
||||||
onClick = { isReceive = true },
|
onClick = { isReceive = true },
|
||||||
label = { Text("Receive (GET)") }
|
label = { Text("Receive (GET)", fontSize = 11.sp) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = !isReceive,
|
selected = !isReceive,
|
||||||
onClick = { isReceive = false },
|
onClick = { isReceive = false },
|
||||||
label = { Text("Send (PUT)") }
|
label = { Text("Send (PUT)", fontSize = 11.sp) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transfer Mode
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Mode:")
|
Text("Mode:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Row {
|
Row {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = isAscii,
|
selected = isAscii,
|
||||||
onClick = { isAscii = true },
|
onClick = { isAscii = true },
|
||||||
label = { Text("ASCII") }
|
label = { Text("ASCII (Text)", fontSize = 11.sp) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = !isAscii,
|
selected = !isAscii,
|
||||||
onClick = { isAscii = false },
|
onClick = { isAscii = false },
|
||||||
label = { Text("Binary") }
|
label = { Text("Binary", fontSize = 11.sp) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host Type (TSO, CMS, CICS)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Host:")
|
Text("Host Type:", fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||||
Row {
|
Row {
|
||||||
FTConfig.HostType.values().forEach { ht ->
|
FTConfig.HostType.values().forEach { ht ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = (hostType == ht),
|
selected = (hostType == ht),
|
||||||
onClick = { hostType = ht },
|
onClick = { hostType = ht },
|
||||||
label = { Text(ht.name) }
|
label = { Text(ht.name, fontSize = 11.sp) }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
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 = {
|
confirmButton = {
|
||||||
@@ -127,20 +231,27 @@ fun FileTransferDialog(
|
|||||||
val config = FTConfig().apply {
|
val config = FTConfig().apply {
|
||||||
setHostFilename(hostFile.trim())
|
setHostFilename(hostFile.trim())
|
||||||
setLocalFilename(localFile.trim())
|
setLocalFilename(localFile.trim())
|
||||||
setReceive(isReceive)
|
setDirection(if (isReceive) FTConfig.Direction.RECEIVE else FTConfig.Direction.SEND)
|
||||||
setAscii(isAscii)
|
setTransferMode(if (isAscii) FTConfig.TransferMode.ASCII else FTConfig.TransferMode.BINARY)
|
||||||
setHostType(hostType)
|
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)
|
onStartTransfer(config)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
enabled = hostFile.isNotBlank() && localFile.isNotBlank() && !ftProgressState.isRunning
|
||||||
) {
|
) {
|
||||||
Text("Start Transfer")
|
Text("Start Transfer")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Cancel")
|
Text("Close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
package org.pubvm.a3270.ui
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.graphics.Color
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import org.lib3270j.ConnectionState
|
import org.lib3270j.ConnectionState
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
|
|
||||||
@@ -22,10 +22,12 @@ import org.lib3270j.protocol.DS3270Constants.*
|
|||||||
fun TwoRowKeyBar(
|
fun TwoRowKeyBar(
|
||||||
connectionState: ConnectionState,
|
connectionState: ConnectionState,
|
||||||
isShiftPressed: Boolean = false,
|
isShiftPressed: Boolean = false,
|
||||||
|
hapticFeedbackEnabled: Boolean = true,
|
||||||
onClearShift: () -> Unit = {},
|
onClearShift: () -> Unit = {},
|
||||||
onConnectClick: () -> Unit,
|
onConnectClick: () -> Unit,
|
||||||
onDisconnectClick: () -> Unit,
|
onDisconnectClick: () -> Unit,
|
||||||
onFtClick: () -> Unit,
|
onFtClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit = {},
|
||||||
onSendAid: (Int) -> Unit,
|
onSendAid: (Int) -> Unit,
|
||||||
onReset: () -> Unit,
|
onReset: () -> Unit,
|
||||||
onTab: () -> Unit,
|
onTab: () -> Unit,
|
||||||
@@ -51,12 +53,13 @@ fun TwoRowKeyBar(
|
|||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
|
// 1. Menu Dropdown Button (Connect/Disconnect/FT/Settings)
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
KeyButton(
|
KeyButton(
|
||||||
label = "⋮",
|
label = "⋮",
|
||||||
color = Color(0xFF343A40),
|
color = Color(0xFF343A40),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = { menuExpanded = true }
|
onClick = { menuExpanded = true }
|
||||||
)
|
)
|
||||||
@@ -89,6 +92,13 @@ fun TwoRowKeyBar(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Settings", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSettingsClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +108,7 @@ fun TwoRowKeyBar(
|
|||||||
label = tabLabel,
|
label = tabLabel,
|
||||||
color = Color(0xFF1C7ED6),
|
color = Color(0xFF1C7ED6),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isShiftPressed) {
|
if (isShiftPressed) {
|
||||||
@@ -110,34 +121,34 @@ fun TwoRowKeyBar(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 3. RESET
|
// 3. RESET
|
||||||
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset)
|
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onReset)
|
||||||
|
|
||||||
// 4. ENTER
|
// 4. ENTER
|
||||||
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
||||||
|
|
||||||
// 5. CLEAR
|
// 5. CLEAR
|
||||||
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
||||||
|
|
||||||
// 6. PA1
|
// 6. PA1
|
||||||
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
||||||
|
|
||||||
// 7. PA2
|
// 7. PA2
|
||||||
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
||||||
|
|
||||||
// 8. PA3
|
// 8. PA3
|
||||||
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
||||||
|
|
||||||
// 9. Left Navigation
|
// 9. Left Navigation
|
||||||
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
||||||
|
|
||||||
// 10. Up Navigation
|
// 10. Up Navigation
|
||||||
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
||||||
|
|
||||||
// 11. Down Navigation
|
// 11. Down Navigation
|
||||||
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
||||||
|
|
||||||
// 12. Right Navigation
|
// 12. Right Navigation
|
||||||
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), hapticFeedbackEnabled = hapticFeedbackEnabled, innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
||||||
@@ -166,6 +177,7 @@ fun TwoRowKeyBar(
|
|||||||
label = "F$i",
|
label = "F$i",
|
||||||
color = Color(0xFF364FC7),
|
color = Color(0xFF364FC7),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
hapticFeedbackEnabled = hapticFeedbackEnabled,
|
||||||
innerPaddingHorizontal = 0.dp,
|
innerPaddingHorizontal = 0.dp,
|
||||||
onClick = {
|
onClick = {
|
||||||
onSendAid(aid)
|
onSendAid(aid)
|
||||||
@@ -178,16 +190,24 @@ fun TwoRowKeyBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun KeyButton(
|
private fun KeyButton(
|
||||||
label: String,
|
label: String,
|
||||||
color: Color,
|
color: Color,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
hapticFeedbackEnabled: Boolean = true,
|
||||||
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||||
) {
|
) {
|
||||||
|
val view = LocalView.current
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
if (hapticFeedbackEnabled) {
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
|
}
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
color = color,
|
color = color,
|
||||||
shadowElevation = 1.dp,
|
shadowElevation = 1.dp,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ fun OiaStatusBar(
|
|||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
rows: Int,
|
rows: Int,
|
||||||
cols: Int,
|
cols: Int,
|
||||||
|
isTls: Boolean = false,
|
||||||
|
isTlsVerified: Boolean = true,
|
||||||
|
graphicsMode: String = "NONE",
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
||||||
@@ -76,6 +79,46 @@ fun OiaStatusBar(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1
|
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)
|
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.InputType
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom InputConnection for Termux-style terminal keyboard interaction.
|
||||||
|
*
|
||||||
|
* It avoids the destructive TextFieldValue("") resetting cycle which causes Android IME
|
||||||
|
* (Gboard/Samsung Keyboard) to reset symbol locks, cancel Caps Lock, and drop fast keystrokes.
|
||||||
|
*/
|
||||||
|
class TerminalInputConnection(
|
||||||
|
targetView: View,
|
||||||
|
private val onInputText: (String) -> Unit,
|
||||||
|
private val onSendAid: (Int) -> Unit,
|
||||||
|
private val onBackspace: () -> Unit,
|
||||||
|
private val onTab: () -> Unit,
|
||||||
|
private val onBackTab: () -> Unit
|
||||||
|
) : BaseInputConnection(targetView, false) {
|
||||||
|
|
||||||
|
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||||
|
if (!text.isNullOrEmpty()) {
|
||||||
|
onInputText(text.toString())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||||
|
if (beforeLength > 0) {
|
||||||
|
repeat(beforeLength) { onBackspace() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
|
||||||
|
if (beforeLength > 0) {
|
||||||
|
repeat(beforeLength) { onBackspace() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
when (event.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||||
|
onSendAid(AID_ENTER)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DEL -> {
|
||||||
|
onBackspace()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_TAB -> {
|
||||||
|
if (event.isShiftPressed) onBackTab() else onTab()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val unicode = event.keyCharacterMap.get(event.keyCode, event.metaState)
|
||||||
|
if (unicode > 0) {
|
||||||
|
onInputText(unicode.toChar().toString())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.sendKeyEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native Android View hosting the terminal InputConnection with Termux-style EditorInfo.
|
||||||
|
*
|
||||||
|
* TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + TYPE_CLASS_TEXT forces keyboards like Gboard
|
||||||
|
* to permanently display the alphanumeric number row across the top, disable intrusive
|
||||||
|
* autocorrect overlays, and preserve Shift/Caps Lock and Symbol mode (?123) state across typing.
|
||||||
|
*/
|
||||||
|
class TerminalInputView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
var onInputText: (String) -> Unit = {}
|
||||||
|
var onSendAid: (Int) -> Unit = {}
|
||||||
|
var onBackspace: () -> Unit = {}
|
||||||
|
var onTab: () -> Unit = {}
|
||||||
|
var onBackTab: () -> Unit = {}
|
||||||
|
|
||||||
|
init {
|
||||||
|
isFocusable = true
|
||||||
|
isFocusableInTouchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or
|
||||||
|
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
|
||||||
|
EditorInfo.IME_ACTION_NONE
|
||||||
|
return TerminalInputConnection(this, onInputText, onSendAid, onBackspace, onTab, onBackTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showSoftKeyboard() {
|
||||||
|
requestFocus()
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||||
|
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.DpOffset
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.lib3270j.protocol.DS3270Constants.*
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
import org.lib3270j.screen.ExtendedAttribute
|
import org.lib3270j.screen.ExtendedAttribute
|
||||||
import org.lib3270j.screen.ScreenBuffer
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
@@ -57,6 +58,10 @@ fun TerminalView(
|
|||||||
cols: Int,
|
cols: Int,
|
||||||
cursorAddr: Int,
|
cursorAddr: Int,
|
||||||
screenVersion: Long,
|
screenVersion: Long,
|
||||||
|
programSymbolManager: org.lib3270j.graphics.ProgramSymbolManager? = null,
|
||||||
|
graphicsPlane: org.lib3270j.graphics.GraphicsPlane? = null,
|
||||||
|
maskHiddenFields: Boolean = true,
|
||||||
|
blinkCursor: Boolean = true,
|
||||||
onTapAddress: (Int) -> Unit,
|
onTapAddress: (Int) -> Unit,
|
||||||
onPasteText: (String) -> Unit = {},
|
onPasteText: (String) -> Unit = {},
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@@ -65,11 +70,28 @@ fun TerminalView(
|
|||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val currentScreenVersion by rememberUpdatedState(screenVersion)
|
||||||
|
val currentCursorAddr by rememberUpdatedState(cursorAddr)
|
||||||
|
|
||||||
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
||||||
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -120,11 +142,10 @@ fun TerminalView(
|
|||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
selectionStart = null
|
selectionStart = null
|
||||||
selectionEnd = null
|
selectionEnd = null
|
||||||
val cellWidth = size.width / cols
|
val metrics = calculateGridMetrics(size.width.toFloat(), size.height.toFloat(), cols, rows)
|
||||||
val cellHeight = size.height / rows
|
if (metrics.cellWidth > 0 && metrics.cellHeight > 0) {
|
||||||
if (cellWidth > 0 && cellHeight > 0) {
|
val col = ((startPos.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
val row = ((startPos.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
|
||||||
var addr = row * cols + col
|
var addr = row * cols + col
|
||||||
|
|
||||||
val buf = screenBuffer
|
val buf = screenBuffer
|
||||||
@@ -141,17 +162,30 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val width = size.width
|
val version = currentScreenVersion
|
||||||
val height = size.height
|
val activeCursorAddr = currentCursorAddr
|
||||||
val cellWidth = width / cols
|
if (version == -1L) return@Canvas
|
||||||
val cellHeight = height / rows
|
|
||||||
|
|
||||||
if (cellWidth <= 0 || cellHeight <= 0) 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 {
|
val paint = Paint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
typeface = Typeface.MONOSPACE
|
typeface = Typeface.MONOSPACE
|
||||||
textSize = cellHeight * 0.85f
|
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 buf = screenBuffer
|
||||||
@@ -168,11 +202,11 @@ fun TerminalView(
|
|||||||
|
|
||||||
val start = selectionStart
|
val start = selectionStart
|
||||||
val end = selectionEnd
|
val end = selectionEnd
|
||||||
if (start != null && end != null) {
|
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
|
||||||
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
|
||||||
selMinRow = minOf(startRow, endRow)
|
selMinRow = minOf(startRow, endRow)
|
||||||
selMaxRow = maxOf(startRow, endRow)
|
selMaxRow = maxOf(startRow, endRow)
|
||||||
@@ -185,8 +219,8 @@ fun TerminalView(
|
|||||||
val addr = r * cols + c
|
val addr = r * cols + c
|
||||||
if (addr >= totalCells) break
|
if (addr >= totalCells) break
|
||||||
|
|
||||||
val left = c * cellWidth
|
val left = offsetX + c * cellWidth
|
||||||
val top = r * cellHeight
|
val top = offsetY + r * cellHeight
|
||||||
|
|
||||||
var charVal = ' '
|
var charVal = ' '
|
||||||
var fgColor: Color
|
var fgColor: Color
|
||||||
@@ -194,6 +228,8 @@ fun TerminalView(
|
|||||||
var isBold = false
|
var isBold = false
|
||||||
var isUnderline = false
|
var isUnderline = false
|
||||||
var isReverse = false
|
var isReverse = false
|
||||||
|
var csVal = 0
|
||||||
|
var ecVal = 0
|
||||||
|
|
||||||
if (buf != null) {
|
if (buf != null) {
|
||||||
val ea = buf.getCell(addr)
|
val ea = buf.getCell(addr)
|
||||||
@@ -208,14 +244,28 @@ fun TerminalView(
|
|||||||
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
||||||
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
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
|
// Intensity
|
||||||
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
||||||
isBold = true
|
isBold = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invisible fields (passwords / hidden)
|
// Invisible fields (passwords / hidden inputs)
|
||||||
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
||||||
continue
|
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
|
// Extended Graphic Rendition
|
||||||
@@ -226,10 +276,6 @@ fun TerminalView(
|
|||||||
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||||
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
|
||||||
charVal = ea.ucs4
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
}
|
}
|
||||||
@@ -248,7 +294,7 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight selected block range
|
// Draw selection box highlight if selected
|
||||||
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
drawRect(
|
drawRect(
|
||||||
@@ -258,7 +304,8 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addr == cursorAddr && !isSelected) {
|
// Cursor indicator (respects blinking toggle & timer)
|
||||||
|
if (addr == activeCursorAddr && !isSelected && cursorVisible) {
|
||||||
drawRect(
|
drawRect(
|
||||||
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
||||||
topLeft = Offset(left, top),
|
topLeft = Offset(left, top),
|
||||||
@@ -266,15 +313,37 @@ fun TerminalView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (charVal != ' ') {
|
// 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.color = fgColor.toArgb()
|
||||||
paint.isFakeBoldText = isBold
|
paint.isFakeBoldText = isBold
|
||||||
val fontMetrics = paint.fontMetrics
|
val fontMetrics = paint.fontMetrics
|
||||||
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
|
val textX = left + cellWidth / 2f
|
||||||
|
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2f - fontMetrics.top
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.drawText(
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
charVal.toString(),
|
charVal.toString(),
|
||||||
left + (cellWidth / 4),
|
textX,
|
||||||
textY,
|
textY,
|
||||||
paint
|
paint
|
||||||
)
|
)
|
||||||
@@ -283,13 +352,32 @@ fun TerminalView(
|
|||||||
if (isUnderline) {
|
if (isUnderline) {
|
||||||
drawLine(
|
drawLine(
|
||||||
color = fgColor,
|
color = fgColor,
|
||||||
start = Offset(left, top + cellHeight - 2),
|
start = Offset(left, top + cellHeight - 2f),
|
||||||
end = Offset(left + cellWidth, top + cellHeight - 2),
|
end = Offset(left + cellWidth, top + cellHeight - 2f),
|
||||||
strokeWidth = 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
|
// Context Menu Popup
|
||||||
@@ -346,6 +434,57 @@ fun TerminalView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private fun copySelection(
|
||||||
start: Offset?,
|
start: Offset?,
|
||||||
end: Offset?,
|
end: Offset?,
|
||||||
@@ -357,13 +496,15 @@ private fun copySelection(
|
|||||||
) {
|
) {
|
||||||
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
||||||
val displayMetrics = context.resources.displayMetrics
|
val displayMetrics = context.resources.displayMetrics
|
||||||
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
|
val viewW = displayMetrics.widthPixels.toFloat()
|
||||||
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
|
val viewH = displayMetrics.heightPixels.toFloat()
|
||||||
|
val metrics = calculateGridMetrics(viewW, viewH, cols, rows)
|
||||||
|
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return
|
||||||
|
|
||||||
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
val startCol = ((start.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
val startRow = ((start.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
val endCol = ((end.x - metrics.offsetX) / metrics.cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
val endRow = ((end.y - metrics.offsetY) / metrics.cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
|
||||||
val minRow = minOf(startRow, endRow)
|
val minRow = minOf(startRow, endRow)
|
||||||
val maxRow = maxOf(startRow, endRow)
|
val maxRow = maxOf(startRow, endRow)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.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 androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UntrustedCertificateDialog(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
chain: Array<X509Certificate>?,
|
||||||
|
exception: CertificateException?,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onReject: () -> Unit
|
||||||
|
) {
|
||||||
|
val cert = chain?.firstOrNull()
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||||
|
|
||||||
|
val detailsText = buildString {
|
||||||
|
if (exception != null) {
|
||||||
|
append("Validation Error:\n ")
|
||||||
|
append(exception.localizedMessage ?: exception.message ?: exception.toString())
|
||||||
|
append("\n\n")
|
||||||
|
}
|
||||||
|
if (cert != null) {
|
||||||
|
try {
|
||||||
|
append("Subject:\n ")
|
||||||
|
append(cert.subjectX500Principal?.name ?: cert.subjectDN?.name ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Subject: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Issuer:\n ")
|
||||||
|
append(cert.issuerX500Principal?.name ?: cert.issuerDN?.name ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Issuer: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Validity:\n From: ")
|
||||||
|
append(cert.notBefore?.let { sdf.format(it) } ?: "Unknown")
|
||||||
|
append("\n To: ")
|
||||||
|
append(cert.notAfter?.let { sdf.format(it) } ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Validity: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("Serial Number:\n ")
|
||||||
|
append(cert.serialNumber?.toString(16)?.uppercase(Locale.US) ?: "Unknown")
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("Serial Number: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("SHA-256 Fingerprint:\n ")
|
||||||
|
append(computeFingerprint(cert, "SHA-256"))
|
||||||
|
append("\n\n")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("SHA-256 Fingerprint: ${t.message ?: "Unavailable"}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
append("SHA-1 Fingerprint:\n ")
|
||||||
|
append(computeFingerprint(cert, "SHA-1"))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
append("SHA-1 Fingerprint: ${t.message ?: "Unavailable"}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
append("No peer certificate information available.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onReject,
|
||||||
|
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.96f)
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚠️",
|
||||||
|
fontSize = 24.sp
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Untrusted SSL/TLS Certificate",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFFB450)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "The server certificate for $host:$port could not be verified.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color.LightGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable Certificate Details Box
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 280.dp)
|
||||||
|
.background(Color(0xFF121212), RoundedCornerShape(6.dp))
|
||||||
|
.border(1.dp, Color(0xFF333333), RoundedCornerShape(6.dp))
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Text(
|
||||||
|
text = detailsText,
|
||||||
|
color = Color(0xFFDCDCDC),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
lineHeight = 15.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onReject) {
|
||||||
|
Text("Cancel Connection", color = Color.LightGray)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onAccept,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB46428))
|
||||||
|
) {
|
||||||
|
Text("Connect Anyway", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeFingerprint(cert: X509Certificate, algorithm: String): String {
|
||||||
|
return try {
|
||||||
|
val md = MessageDigest.getInstance(algorithm)
|
||||||
|
val digest = md.digest(cert.encoded)
|
||||||
|
digest.joinToString(":") { "%02X".format(it.toInt() and 0xFF) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
"Unable to compute fingerprint: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package org.pubvm.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
|
||||||
|
|
||||||
|
class HardwareKeyboardInputTest {
|
||||||
|
|
||||||
|
private lateinit var screenBuffer: ScreenBuffer
|
||||||
|
private lateinit var translator: EbcdicTranslator
|
||||||
|
private lateinit var inputProcessor: InputProcessor
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
val model = TerminalModel.IBM_3279_2 // 24x80
|
||||||
|
translator = EbcdicTranslator()
|
||||||
|
screenBuffer = ScreenBuffer(model, translator)
|
||||||
|
inputProcessor = InputProcessor(screenBuffer, translator, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUnformattedTypingAndNavigation() {
|
||||||
|
assertEquals(0, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
// Type alphanumeric string
|
||||||
|
val text = "LOGON APPLID(TSO)"
|
||||||
|
for (ch in text) {
|
||||||
|
inputProcessor.typeCharacter(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||||
|
for (i in text.indices) {
|
||||||
|
assertEquals(text[i], screenBuffer.getCell(i).ucs4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
inputProcessor.cursorLeft()
|
||||||
|
assertEquals(text.length - 1, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
inputProcessor.cursorRight()
|
||||||
|
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
inputProcessor.cursorDown()
|
||||||
|
assertEquals(80 + text.length, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
inputProcessor.cursorUp()
|
||||||
|
assertEquals(text.length, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
inputProcessor.cursorHome()
|
||||||
|
assertEquals(0, screenBuffer.cursorAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBackspaceAndForwardDelete() {
|
||||||
|
val text = "HELLO"
|
||||||
|
for (ch in text) {
|
||||||
|
inputProcessor.typeCharacter(ch)
|
||||||
|
}
|
||||||
|
assertEquals(5, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
// Backspace moves left and deletes char
|
||||||
|
inputProcessor.backspace()
|
||||||
|
assertEquals(4, screenBuffer.cursorAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormattedFieldInputAndTab() {
|
||||||
|
// Create formatted screen with protected label and unprotected input field
|
||||||
|
// Position 0: Protected FA
|
||||||
|
screenBuffer.setCellFA(0, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||||
|
// Set label "USERID:" at positions 1..7
|
||||||
|
val label = "USERID:"
|
||||||
|
for (i in label.indices) {
|
||||||
|
screenBuffer.getCell(i + 1).ucs4 = label[i]
|
||||||
|
screenBuffer.getCell(i + 1).ec = translator.unicodeToEbcdic(label[i]).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position 10: Unprotected FA
|
||||||
|
screenBuffer.setCellFA(10, FA_PRINTABLE.toByte())
|
||||||
|
|
||||||
|
// Position 20: Protected FA
|
||||||
|
screenBuffer.setCellFA(20, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||||
|
|
||||||
|
// Position 30: Unprotected FA
|
||||||
|
screenBuffer.setCellFA(30, FA_PRINTABLE.toByte())
|
||||||
|
|
||||||
|
screenBuffer.cursorAddress = 11
|
||||||
|
|
||||||
|
// Type into first field
|
||||||
|
val userid = "IBMUSER"
|
||||||
|
for (ch in userid) {
|
||||||
|
inputProcessor.typeCharacter(ch)
|
||||||
|
}
|
||||||
|
assertEquals(18, screenBuffer.cursorAddress)
|
||||||
|
for (i in userid.indices) {
|
||||||
|
assertEquals(userid[i], screenBuffer.getCell(11 + i).ucs4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab should jump to position 31 (next unprotected field)
|
||||||
|
inputProcessor.tab()
|
||||||
|
assertEquals(31, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
// BackTab should jump back to position 11
|
||||||
|
inputProcessor.backTab()
|
||||||
|
assertEquals(11, screenBuffer.cursorAddress)
|
||||||
|
|
||||||
|
// Home should jump to first unprotected field (position 11)
|
||||||
|
screenBuffer.cursorAddress = 50
|
||||||
|
inputProcessor.cursorHome()
|
||||||
|
assertEquals(11, screenBuffer.cursorAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormattedDeleteCharAndEraseEof() {
|
||||||
|
// Position 0: Unprotected FA
|
||||||
|
screenBuffer.setCellFA(0, FA_PRINTABLE.toByte())
|
||||||
|
// Position 10: Protected FA
|
||||||
|
screenBuffer.setCellFA(10, (FA_PRINTABLE or FA_PROTECT).toByte())
|
||||||
|
|
||||||
|
screenBuffer.cursorAddress = 1
|
||||||
|
val text = "ABCDE"
|
||||||
|
for (ch in text) {
|
||||||
|
inputProcessor.typeCharacter(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move cursor to 'C' at pos 3
|
||||||
|
screenBuffer.cursorAddress = 3
|
||||||
|
inputProcessor.deleteChar()
|
||||||
|
|
||||||
|
// "ABDE "
|
||||||
|
assertEquals('A', screenBuffer.getCell(1).ucs4)
|
||||||
|
assertEquals('B', screenBuffer.getCell(2).ucs4)
|
||||||
|
assertEquals('D', screenBuffer.getCell(3).ucs4)
|
||||||
|
assertEquals('E', screenBuffer.getCell(4).ucs4)
|
||||||
|
assertEquals(0, screenBuffer.getCell(5).ec.toInt())
|
||||||
|
|
||||||
|
// Erase EOF from pos 3
|
||||||
|
inputProcessor.eraseEof()
|
||||||
|
assertEquals('A', screenBuffer.getCell(1).ucs4)
|
||||||
|
assertEquals('B', screenBuffer.getCell(2).ucs4)
|
||||||
|
assertEquals(0, screenBuffer.getCell(3).ec.toInt())
|
||||||
|
assertEquals(0, screenBuffer.getCell(4).ec.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testPfKeyAids() {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in 1..24) {
|
||||||
|
val aid = pfAids[i - 1]
|
||||||
|
inputProcessor.sendAid(aid)
|
||||||
|
assertEquals(aid, inputProcessor.lastAid)
|
||||||
|
assertTrue(inputProcessor.isKeyboardLocked)
|
||||||
|
inputProcessor.reset()
|
||||||
|
assertFalse(inputProcessor.isKeyboardLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user