first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
*.jar
|
||||||
|
build
|
||||||
|
.gradle
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'org.pubvm.a3270'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "org.pubvm.a3270"
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '11'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion '1.5.8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(':lib3270j')
|
||||||
|
|
||||||
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||||
|
|
||||||
|
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
implementation 'androidx.compose.material3:material3'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="a3270"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden|keyboard"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.TerminalService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="3270 Terminal TCP socket connection persistence" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package org.pubvm.a3270
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
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.core.view.WindowCompat
|
||||||
|
import org.lib3270j.protocol.DS3270Constants.AID_ENTER
|
||||||
|
import org.pubvm.a3270.service.TerminalService
|
||||||
|
import org.pubvm.a3270.storage.HostStorage
|
||||||
|
import org.pubvm.a3270.ui.ConnectDialog
|
||||||
|
import org.pubvm.a3270.ui.FileTransferDialog
|
||||||
|
import org.pubvm.a3270.ui.TwoRowKeyBar
|
||||||
|
import org.pubvm.a3270.ui.OiaStatusBar
|
||||||
|
import org.pubvm.a3270.ui.TerminalView
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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() {
|
||||||
|
|
||||||
|
private val viewModel: TerminalViewModel by viewModels()
|
||||||
|
private val isShiftPressedState = mutableStateOf(false)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
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 (checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 101)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = darkColorScheme(
|
||||||
|
background = Color(0xFF000000),
|
||||||
|
surface = Color(0xFF1E1E1E)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
MainScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
isShiftPressed = isShiftPressedState.value,
|
||||||
|
onClearShift = { isShiftPressedState.value = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
val shift = event.isShiftPressed || (event.metaState and KeyEvent.META_SHIFT_ON != 0)
|
||||||
|
if (isShiftPressedState.value != shift) {
|
||||||
|
isShiftPressedState.value = shift
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
val keyCode = event.keyCode
|
||||||
|
if (keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12) {
|
||||||
|
val fNum = keyCode - KeyEvent.KEYCODE_F1 + 1
|
||||||
|
val pfNum = if (shift) fNum + 12 else fNum
|
||||||
|
val pfAids = intArrayOf(
|
||||||
|
org.lib3270j.protocol.DS3270Constants.AID_PF1, org.lib3270j.protocol.DS3270Constants.AID_PF2,
|
||||||
|
org.lib3270j.protocol.DS3270Constants.AID_PF3, org.lib3270j.protocol.DS3270Constants.AID_PF4,
|
||||||
|
org.lib3270j.protocol.DS3270Constants.AID_PF5, org.lib3270j.protocol.DS3270Constants.AID_PF6,
|
||||||
|
org.lib3270j.protocol.DS3270Constants.AID_PF7, org.lib3270j.protocol.DS3270Constants.AID_PF8,
|
||||||
|
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])
|
||||||
|
if (shift) {
|
||||||
|
isShiftPressedState.value = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
||||||
|
viewModel.cursorLeft()
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
||||||
|
viewModel.cursorUp()
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
|
||||||
|
viewModel.cursorDown()
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
|
||||||
|
viewModel.cursorRight()
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
|
||||||
|
viewModel.sendAid(AID_ENTER)
|
||||||
|
return true
|
||||||
|
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
|
||||||
|
if (event.isShiftPressed) {
|
||||||
|
viewModel.backTab()
|
||||||
|
} else {
|
||||||
|
viewModel.tab()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
viewModel: TerminalViewModel,
|
||||||
|
isShiftPressed: Boolean = false,
|
||||||
|
onClearShift: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val connectionState by viewModel.connectionState.collectAsState()
|
||||||
|
val screenBuffer by viewModel.screenBuffer.collectAsState()
|
||||||
|
val rows by viewModel.rows.collectAsState()
|
||||||
|
val cols by viewModel.cols.collectAsState()
|
||||||
|
val cursorAddr by viewModel.cursorAddress.collectAsState()
|
||||||
|
val oiaText by viewModel.oiaText.collectAsState()
|
||||||
|
val isKeyboardLocked by viewModel.isKeyboardLocked.collectAsState()
|
||||||
|
val screenVersion by viewModel.screenVersion.collectAsState()
|
||||||
|
|
||||||
|
var showConnectDialog by remember { mutableStateOf(false) }
|
||||||
|
var showFtDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_STOP) {
|
||||||
|
viewModel.isAppInBackground = true
|
||||||
|
} else if (event == Lifecycle.Event.ON_START) {
|
||||||
|
viewModel.isAppInBackground = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val autoHost = HostStorage.getAutoConnectHost(context)
|
||||||
|
if (autoHost != null && !connectionState.isConnected()) {
|
||||||
|
viewModel.connect(autoHost.host, autoHost.port, autoHost.model, autoHost.luName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(connectionState) {
|
||||||
|
if (connectionState.isConnected()) {
|
||||||
|
val activeHost = viewModel.currentHost.ifBlank { "Mainframe" }
|
||||||
|
TerminalService.start(context, activeHost)
|
||||||
|
} else {
|
||||||
|
TerminalService.stop(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Main 3270 Terminal Screen View (100% clean top screen, zero overlays)
|
||||||
|
TerminalView(
|
||||||
|
screenBuffer = screenBuffer,
|
||||||
|
rows = rows,
|
||||||
|
cols = cols,
|
||||||
|
cursorAddr = cursorAddr,
|
||||||
|
screenVersion = screenVersion,
|
||||||
|
onTapAddress = { addr ->
|
||||||
|
viewModel.setCursor(addr)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
},
|
||||||
|
onPasteText = { text ->
|
||||||
|
viewModel.pasteString(text)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transparent BasicTextField maintaining active Android IME connection
|
||||||
|
BasicTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val text = newValue.text
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
for (ch in text) {
|
||||||
|
if (ch == '\n' || ch == '\r') {
|
||||||
|
viewModel.sendAid(AID_ENTER)
|
||||||
|
} else {
|
||||||
|
viewModel.typeChar(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textFieldValue = TextFieldValue("")
|
||||||
|
onClearShift()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textStyle = TextStyle(color = Color.Transparent),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
),
|
||||||
|
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()
|
||||||
|
onClearShift()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Two Button Rows at the bottom (Row 1: System/Actions/Nav, Row 2: PF1-PF24)
|
||||||
|
TwoRowKeyBar(
|
||||||
|
connectionState = connectionState,
|
||||||
|
isShiftPressed = isShiftPressed,
|
||||||
|
onClearShift = onClearShift,
|
||||||
|
onConnectClick = { showConnectDialog = true },
|
||||||
|
onDisconnectClick = { viewModel.disconnect() },
|
||||||
|
onFtClick = { showFtDialog = true },
|
||||||
|
onSendAid = { aid ->
|
||||||
|
viewModel.sendAid(aid)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onReset = {
|
||||||
|
viewModel.resetKeyboard()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onTab = {
|
||||||
|
viewModel.tab()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onBackTab = {
|
||||||
|
viewModel.backTab()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onCursorLeft = {
|
||||||
|
viewModel.cursorLeft()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onCursorUp = {
|
||||||
|
viewModel.cursorUp()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onCursorDown = {
|
||||||
|
viewModel.cursorDown()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
},
|
||||||
|
onCursorRight = {
|
||||||
|
viewModel.cursorRight()
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIA Status Bar
|
||||||
|
OiaStatusBar(
|
||||||
|
connectionState = connectionState,
|
||||||
|
currentHost = viewModel.currentHost,
|
||||||
|
isKeyboardLocked = isKeyboardLocked,
|
||||||
|
oiaText = oiaText,
|
||||||
|
cursorAddr = cursorAddr,
|
||||||
|
rows = rows,
|
||||||
|
cols = cols
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showConnectDialog) {
|
||||||
|
ConnectDialog(
|
||||||
|
onDismiss = { showConnectDialog = false },
|
||||||
|
onConnect = { host, port, model, luName ->
|
||||||
|
showConnectDialog = false
|
||||||
|
viewModel.connect(host, port, model, luName)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFtDialog) {
|
||||||
|
FileTransferDialog(
|
||||||
|
onDismiss = { showFtDialog = false },
|
||||||
|
onStartTransfer = { config ->
|
||||||
|
showFtDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package org.pubvm.a3270
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.lib3270j.ConnectionConfig
|
||||||
|
import org.lib3270j.ConnectionState
|
||||||
|
import org.lib3270j.Telnet3270Client
|
||||||
|
import org.lib3270j.TerminalModel
|
||||||
|
import org.lib3270j.listener.ConnectionListener
|
||||||
|
import org.lib3270j.listener.ScreenUpdateListener
|
||||||
|
import org.lib3270j.protocol.DS3270Constants.faIsProtected
|
||||||
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
|
import org.pubvm.a3270.service.TerminalService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.logging.Logger
|
||||||
|
|
||||||
|
class TerminalViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(TerminalViewModel::class.java.name)
|
||||||
|
|
||||||
|
var isAppInBackground: Boolean = false
|
||||||
|
|
||||||
|
// Dedicated single-thread FIFO queue for all local keyboard and cursor actions
|
||||||
|
private val inputExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
private val inputDispatcher = inputExecutor.asCoroutineDispatcher()
|
||||||
|
|
||||||
|
private val _connectionState = MutableStateFlow(ConnectionState.NOT_CONNECTED)
|
||||||
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _screenBuffer = MutableStateFlow<ScreenBuffer?>(null)
|
||||||
|
val screenBuffer: StateFlow<ScreenBuffer?> = _screenBuffer.asStateFlow()
|
||||||
|
|
||||||
|
private val _rows = MutableStateFlow(24)
|
||||||
|
val rows: StateFlow<Int> = _rows.asStateFlow()
|
||||||
|
|
||||||
|
private val _cols = MutableStateFlow(80)
|
||||||
|
val cols: StateFlow<Int> = _cols.asStateFlow()
|
||||||
|
|
||||||
|
private val _cursorAddress = MutableStateFlow(0)
|
||||||
|
val cursorAddress: StateFlow<Int> = _cursorAddress.asStateFlow()
|
||||||
|
|
||||||
|
private val _oiaText = MutableStateFlow("Disconnected")
|
||||||
|
val oiaText: StateFlow<String> = _oiaText.asStateFlow()
|
||||||
|
|
||||||
|
private val _screenVersion = MutableStateFlow(0L)
|
||||||
|
val screenVersion: StateFlow<Long> = _screenVersion.asStateFlow()
|
||||||
|
|
||||||
|
private val _isKeyboardLocked = MutableStateFlow(false)
|
||||||
|
val isKeyboardLocked: StateFlow<Boolean> = _isKeyboardLocked.asStateFlow()
|
||||||
|
|
||||||
|
private var client: Telnet3270Client? = null
|
||||||
|
var currentHost: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var lastScreenContentHash: Int = 0
|
||||||
|
private var hasInitialScreenLoaded: Boolean = false
|
||||||
|
|
||||||
|
fun connect(host: String, port: Int = 23, modelNum: Int = 2, luName: String = "") {
|
||||||
|
if (_connectionState.value.isConnected()) return
|
||||||
|
currentHost = host
|
||||||
|
lastScreenContentHash = 0
|
||||||
|
hasInitialScreenLoaded = false
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
_oiaText.value = "Connecting to $host:$port..."
|
||||||
|
|
||||||
|
val model = when (modelNum) {
|
||||||
|
3 -> TerminalModel.IBM_3279_3
|
||||||
|
4 -> TerminalModel.IBM_3279_4
|
||||||
|
5 -> TerminalModel.IBM_3279_5
|
||||||
|
else -> TerminalModel.IBM_3279_2
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = ConnectionConfig(host, port, model).apply {
|
||||||
|
if (luName.isNotBlank()) {
|
||||||
|
setLuName(luName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newClient = Telnet3270Client(config)
|
||||||
|
this@TerminalViewModel.client = newClient
|
||||||
|
|
||||||
|
newClient.addConnectionListener(object : ConnectionListener {
|
||||||
|
override fun onConnectionStateChanged(oldState: ConnectionState, newState: ConnectionState) {
|
||||||
|
_connectionState.value = newState
|
||||||
|
_oiaText.value = if (newState.isFullSession()) {
|
||||||
|
"3270 Connected ($host)"
|
||||||
|
} else if (newState.isHalfConnected()) {
|
||||||
|
"Connecting..."
|
||||||
|
} else {
|
||||||
|
"Disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionError(message: String) {
|
||||||
|
log.warning("Connection error: $message")
|
||||||
|
_oiaText.value = "Error: $message"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
newClient.inputProcessor.setLockStateListener { locked ->
|
||||||
|
_isKeyboardLocked.value = locked
|
||||||
|
}
|
||||||
|
|
||||||
|
newClient.addScreenUpdateListener(object : ScreenUpdateListener {
|
||||||
|
override fun onScreenUpdated() {
|
||||||
|
val buf = newClient.screenBuffer
|
||||||
|
_screenBuffer.value = buf
|
||||||
|
if (buf != null) {
|
||||||
|
_rows.value = buf.rows
|
||||||
|
_cols.value = buf.cols
|
||||||
|
_cursorAddress.value = buf.cursorAddress
|
||||||
|
}
|
||||||
|
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val newHash = computeScreenContentHash(buf)
|
||||||
|
val contentChanged = (newHash != lastScreenContentHash)
|
||||||
|
|
||||||
|
if (!hasInitialScreenLoaded) {
|
||||||
|
if (buf != null && newHash != 0) {
|
||||||
|
hasInitialScreenLoaded = true
|
||||||
|
lastScreenContentHash = newHash
|
||||||
|
}
|
||||||
|
} else if (contentChanged) {
|
||||||
|
lastScreenContentHash = newHash
|
||||||
|
if (isAppInBackground) {
|
||||||
|
val snippet = extractScreenSnippet(buf)
|
||||||
|
val activeHost = currentHost.ifBlank { "Mainframe" }
|
||||||
|
TerminalService.notifyScreenUpdate(getApplication(), activeHost, snippet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSoundAlarm() {
|
||||||
|
log.fine("Sound Alarm")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_rows.value = model.defaultRows
|
||||||
|
_cols.value = model.defaultCols
|
||||||
|
_screenBuffer.value = newClient.screenBuffer
|
||||||
|
|
||||||
|
newClient.connect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.severe("Failed to connect: ${e.message}")
|
||||||
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
|
_oiaText.value = "Failed: ${e.localizedMessage ?: e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
client?.disconnect()
|
||||||
|
client = null
|
||||||
|
_connectionState.value = ConnectionState.NOT_CONNECTED
|
||||||
|
_oiaText.value = "Disconnected"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warning("Error disconnecting: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun typeChar(ch: Char) {
|
||||||
|
val c = client ?: return
|
||||||
|
viewModelScope.launch(inputDispatcher) {
|
||||||
|
try {
|
||||||
|
val ip = c.inputProcessor
|
||||||
|
ip.isKeyboardLocked = false // Ensure keyboard lock is cleared on user typing
|
||||||
|
val buf = c.screenBuffer
|
||||||
|
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)
|
||||||
|
_cursorAddress.value = c.screenBuffer.cursorAddress
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warning("Error typing char: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pasteString(text: String) {
|
||||||
|
val c = client ?: return
|
||||||
|
viewModelScope.launch(inputDispatcher) {
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
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 tab() {
|
||||||
|
val c = client ?: return
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
viewModelScope.launch(inputDispatcher) {
|
||||||
|
try {
|
||||||
|
c.inputProcessor.reset()
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warning("Error resetting keyboard: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendAid(aidCode: Int) {
|
||||||
|
val c = client ?: return
|
||||||
|
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) {
|
||||||
|
val buf = _screenBuffer.value ?: return
|
||||||
|
if (baddr in 0 until (buf.rows * buf.cols)) {
|
||||||
|
buf.cursorAddress = baddr
|
||||||
|
_cursorAddress.value = baddr
|
||||||
|
_screenVersion.value = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cursorLeft() {
|
||||||
|
val c = client ?: return
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
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() {
|
||||||
|
val c = client ?: return
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractScreenSnippet(buf: ScreenBuffer?): String {
|
||||||
|
if (buf == null) return "Mainframe screen update received"
|
||||||
|
val rows = buf.rows
|
||||||
|
val cols = buf.cols
|
||||||
|
if (rows <= 0 || cols <= 0) return "Mainframe screen update received"
|
||||||
|
|
||||||
|
val curAddr = _cursorAddress.value
|
||||||
|
val cursorRow = (curAddr / cols).coerceIn(0, rows - 1)
|
||||||
|
|
||||||
|
// 1. Check bottom 3 lines first (where 3270 status, errors, reader & I/S MSGs land)
|
||||||
|
val minBottomRow = (rows - 3).coerceAtLeast(1)
|
||||||
|
for (r in (rows - 1) downTo minBottomRow) {
|
||||||
|
val line = getLineText(buf, r, cols).trim()
|
||||||
|
if (line.length >= 4 && !line.startsWith("***") && !line.startsWith("===")) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check cursor row if not row 0
|
||||||
|
if (cursorRow > 0) {
|
||||||
|
val cursorLine = getLineText(buf, cursorRow, cols).trim()
|
||||||
|
if (cursorLine.length >= 4) {
|
||||||
|
return cursorLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scan rows from bottom to top, skipping static header row 0
|
||||||
|
for (r in (rows - 1) downTo 1) {
|
||||||
|
val line = getLineText(buf, r, cols).trim()
|
||||||
|
if (line.length >= 4) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLineText(buf, 0, cols).trim().ifBlank { "Mainframe screen update received" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (c in 0 until cols) {
|
||||||
|
val addr = row * cols + c
|
||||||
|
val cell = buf.getCell(addr)
|
||||||
|
if (cell != null && !cell.isFieldAttribute) {
|
||||||
|
val ch = cell.ucs4
|
||||||
|
if (ch in '!'..'~' || ch.code > 127) {
|
||||||
|
sb.append(ch)
|
||||||
|
} else {
|
||||||
|
sb.append(' ')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
|
||||||
|
if (buf == null) return 0
|
||||||
|
val sb = StringBuilder()
|
||||||
|
val total = buf.rows * buf.cols
|
||||||
|
for (i in 0 until total) {
|
||||||
|
val cell = buf.getCell(i)
|
||||||
|
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||||
|
sb.append(cell.ucs4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
client?.disconnect()
|
||||||
|
inputExecutor.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package org.pubvm.a3270.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import org.pubvm.a3270.MainActivity
|
||||||
|
import org.pubvm.a3270.R
|
||||||
|
|
||||||
|
class TerminalService : Service() {
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val action = intent?.action
|
||||||
|
if (action == ACTION_STOP) {
|
||||||
|
try {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
|
manager?.cancel(NOTIFICATION_ID)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val host = intent?.getStringExtra(EXTRA_HOST) ?: "Mainframe"
|
||||||
|
createNotificationChannels()
|
||||||
|
|
||||||
|
val notificationIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, notificationIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification: Notification = NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
|
||||||
|
.setContentTitle("a3270 Active Session")
|
||||||
|
.setContentText("Connected to $host (Socket Active)")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
|
manager?.notify(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
try {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
|
||||||
|
// Delete obsolete channels so system settings reset cleanly
|
||||||
|
try {
|
||||||
|
manager.deleteNotificationChannel("a3270_connection_channel")
|
||||||
|
manager.deleteNotificationChannel("a3270_connection_v2")
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
// 1. Service Persistent Channel
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
SERVICE_CHANNEL_ID,
|
||||||
|
"a3270 Terminal Connection",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply {
|
||||||
|
description = "Keeps the 3270 TCP socket connection alive in the background"
|
||||||
|
setShowBadge(true)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
|
|
||||||
|
// 2. Screen Update Notification Channel
|
||||||
|
val updateChannel = NotificationChannel(
|
||||||
|
UPDATE_CHANNEL_ID,
|
||||||
|
"a3270 Screen Updates",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Notifies when mainframe screen updates occur in the background"
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(updateChannel)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SERVICE_CHANNEL_ID = "a3270_connection_v3"
|
||||||
|
private const val UPDATE_CHANNEL_ID = "a3270_screen_updates"
|
||||||
|
private const val NOTIFICATION_ID = 32701
|
||||||
|
private const val UPDATE_NOTIFICATION_ID = 32702
|
||||||
|
const val EXTRA_HOST = "extra_host"
|
||||||
|
const val ACTION_STOP = "action_stop"
|
||||||
|
|
||||||
|
fun start(context: Context, host: String) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, TerminalService::class.java).apply {
|
||||||
|
putExtra(EXTRA_HOST, host)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(context, TerminalService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyScreenUpdate(context: Context, host: String, textSnippet: String) {
|
||||||
|
try {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
|
||||||
|
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayHost = if (host.isNotBlank()) host else "Mainframe"
|
||||||
|
val notification = NotificationCompat.Builder(context, UPDATE_CHANNEL_ID)
|
||||||
|
.setContentTitle("$displayHost has an alert.")
|
||||||
|
.setContentText(textSnippet)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(textSnippet))
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
manager.notify(UPDATE_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package org.pubvm.a3270.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class SavedHost(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int = 23,
|
||||||
|
val model: Int = 2,
|
||||||
|
val luName: String = "",
|
||||||
|
val autoConnect: Boolean = false
|
||||||
|
) {
|
||||||
|
fun toJson(): JSONObject {
|
||||||
|
return JSONObject().apply {
|
||||||
|
put("id", id)
|
||||||
|
put("name", name)
|
||||||
|
put("host", host)
|
||||||
|
put("port", port)
|
||||||
|
put("model", model)
|
||||||
|
put("luName", luName)
|
||||||
|
put("autoConnect", autoConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(json: JSONObject): SavedHost {
|
||||||
|
return SavedHost(
|
||||||
|
id = json.optString("id", UUID.randomUUID().toString()),
|
||||||
|
name = json.optString("name", "Mainframe"),
|
||||||
|
host = json.optString("host", "127.0.0.1"),
|
||||||
|
port = json.optInt("port", 23),
|
||||||
|
model = json.optInt("model", 2),
|
||||||
|
luName = json.optString("luName", ""),
|
||||||
|
autoConnect = json.optBoolean("autoConnect", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object HostStorage {
|
||||||
|
private const val PREFS_NAME = "a3270_host_prefs"
|
||||||
|
private const val KEY_SAVED_HOSTS = "saved_hosts"
|
||||||
|
|
||||||
|
private fun getPrefs(context: Context): SharedPreferences {
|
||||||
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSavedHosts(context: Context): List<SavedHost> {
|
||||||
|
val jsonStr = getPrefs(context).getString(KEY_SAVED_HOSTS, null) ?: return emptyList()
|
||||||
|
val list = mutableListOf<SavedHost>()
|
||||||
|
try {
|
||||||
|
val jsonArray = JSONArray(jsonStr)
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val obj = jsonArray.getJSONObject(i)
|
||||||
|
list.add(SavedHost.fromJson(obj))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveHost(context: Context, host: SavedHost) {
|
||||||
|
val currentHosts = getSavedHosts(context).toMutableList()
|
||||||
|
val index = currentHosts.indexOfFirst { it.id == host.id }
|
||||||
|
|
||||||
|
// If this host is set to autoConnect, unset autoConnect on all others
|
||||||
|
val updatedHost = if (host.autoConnect) {
|
||||||
|
currentHosts.indices.forEach { i ->
|
||||||
|
currentHosts[i] = currentHosts[i].copy(autoConnect = false)
|
||||||
|
}
|
||||||
|
host
|
||||||
|
} else {
|
||||||
|
host
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
currentHosts[index] = updatedHost
|
||||||
|
} else {
|
||||||
|
currentHosts.add(updatedHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAll(context, currentHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteHost(context: Context, hostId: String) {
|
||||||
|
val currentHosts = getSavedHosts(context).filter { it.id != hostId }
|
||||||
|
saveAll(context, currentHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAutoConnectHost(context: Context): SavedHost? {
|
||||||
|
return getSavedHosts(context).firstOrNull { it.autoConnect }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAll(context: Context, hosts: List<SavedHost>) {
|
||||||
|
val array = JSONArray()
|
||||||
|
hosts.forEach { array.put(it.toJson()) }
|
||||||
|
getPrefs(context).edit().putString(KEY_SAVED_HOSTS, array.toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.platform.LocalContext
|
||||||
|
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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import org.pubvm.a3270.storage.HostStorage
|
||||||
|
import org.pubvm.a3270.storage.SavedHost
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConnect: (host: String, port: Int, model: Int, luName: String) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var savedHosts by remember { mutableStateOf(HostStorage.getSavedHosts(context)) }
|
||||||
|
var selectedHostId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
var profileName by remember { mutableStateOf("") }
|
||||||
|
var host by remember { mutableStateOf("127.0.0.1") }
|
||||||
|
var portStr by remember { mutableStateOf("23") }
|
||||||
|
var modelNum by remember { mutableIntStateOf(2) }
|
||||||
|
var luName by remember { mutableStateOf("") }
|
||||||
|
var autoConnect by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun loadProfile(saved: SavedHost) {
|
||||||
|
selectedHostId = saved.id
|
||||||
|
profileName = saved.name
|
||||||
|
host = saved.host
|
||||||
|
portStr = saved.port.toString()
|
||||||
|
modelNum = saved.model
|
||||||
|
luName = saved.luName
|
||||||
|
autoConnect = saved.autoConnect
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFields() {
|
||||||
|
selectedHostId = null
|
||||||
|
profileName = ""
|
||||||
|
host = "127.0.0.1"
|
||||||
|
portStr = "23"
|
||||||
|
modelNum = 2
|
||||||
|
luName = ""
|
||||||
|
autoConnect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCurrentProfile(): SavedHost? {
|
||||||
|
val port = portStr.toIntOrNull() ?: 23
|
||||||
|
if (host.isBlank()) return null
|
||||||
|
val nameToSave = profileName.ifBlank { "${host.trim()}:$port" }
|
||||||
|
val hostToSave = SavedHost(
|
||||||
|
id = selectedHostId ?: java.util.UUID.randomUUID().toString(),
|
||||||
|
name = nameToSave,
|
||||||
|
host = host.trim(),
|
||||||
|
port = port,
|
||||||
|
model = modelNum,
|
||||||
|
luName = luName.trim(),
|
||||||
|
autoConnect = autoConnect
|
||||||
|
)
|
||||||
|
HostStorage.saveHost(context, hostToSave)
|
||||||
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
|
selectedHostId = hostToSave.id
|
||||||
|
return hostToSave
|
||||||
|
}
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(150)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = Color(0xFF1E1E1E),
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.96f)
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Title Row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Connect / Manage Hosts", fontWeight = FontWeight.Bold, fontSize = 15.sp, color = Color.White)
|
||||||
|
TextButton(
|
||||||
|
onClick = { clearFields() },
|
||||||
|
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text("+ New Profile", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable Content Container
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 300.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
// Saved Profiles List
|
||||||
|
if (savedHosts.isNotEmpty()) {
|
||||||
|
Text("Saved Host Profiles:", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.Gray)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 95.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
savedHosts.forEach { profile ->
|
||||||
|
val isSelected = profile.id == selectedHostId
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
color = if (isSelected) Color(0xFF2C2D30) else Color(0xFF161719),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(
|
||||||
|
width = if (isSelected) 1.dp else 0.dp,
|
||||||
|
color = if (isSelected) Color(0xFF4DABF7) else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(6.dp)
|
||||||
|
)
|
||||||
|
.clickable { loadProfile(profile) }
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
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(
|
||||||
|
"AUTO",
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = {
|
||||||
|
HostStorage.deleteHost(context, profile.id)
|
||||||
|
savedHosts = HostStorage.getSavedHosts(context)
|
||||||
|
if (selectedHostId == profile.id) {
|
||||||
|
clearFields()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||||
|
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFFF6B6B))
|
||||||
|
) {
|
||||||
|
Text("Delete", fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (selectedHostId != null) "Editing Profile:" else "New Profile Details:",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = profileName,
|
||||||
|
onValueChange = { profileName = it },
|
||||||
|
label = { Text("Profile Name (Optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = host,
|
||||||
|
onValueChange = { host = it },
|
||||||
|
label = { Text("Host / IP Address") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = portStr,
|
||||||
|
onValueChange = { portStr = it },
|
||||||
|
label = { Text("Port (default 23)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Model:", fontSize = 11.sp, color = Color.Gray)
|
||||||
|
listOf(2, 3, 4, 5).forEach { m ->
|
||||||
|
FilterChip(
|
||||||
|
selected = (modelNum == m),
|
||||||
|
onClick = { modelNum = m },
|
||||||
|
label = { Text("M$m", fontSize = 11.sp) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = luName,
|
||||||
|
onValueChange = { luName = it },
|
||||||
|
label = { Text("LU Name (Optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { autoConnect = !autoConnect }
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = autoConnect,
|
||||||
|
onCheckedChange = { autoConnect = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Auto-connect on app startup", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Buttons Row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
OutlinedButton(onClick = { saveCurrentProfile() }) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val saved = saveCurrentProfile()
|
||||||
|
if (saved != null) {
|
||||||
|
onConnect(saved.host, saved.port, saved.model, saved.luName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.lib3270j.ft.FTConfig
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileTransferDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onStartTransfer: (FTConfig) -> Unit
|
||||||
|
) {
|
||||||
|
var hostFile by remember { mutableStateOf("") }
|
||||||
|
var localFile by remember { mutableStateOf("") }
|
||||||
|
var isReceive by remember { mutableStateOf(true) }
|
||||||
|
var isAscii by remember { mutableStateOf(true) }
|
||||||
|
var hostType by remember { mutableStateOf(FTConfig.HostType.TSO) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(150)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("IND\$FILE File Transfer") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = hostFile,
|
||||||
|
onValueChange = { hostFile = it },
|
||||||
|
label = { Text("Host File Name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = localFile,
|
||||||
|
onValueChange = { localFile = it },
|
||||||
|
label = { Text("Local File Path") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Direction:")
|
||||||
|
Row {
|
||||||
|
FilterChip(
|
||||||
|
selected = isReceive,
|
||||||
|
onClick = { isReceive = true },
|
||||||
|
label = { Text("Receive (GET)") }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
FilterChip(
|
||||||
|
selected = !isReceive,
|
||||||
|
onClick = { isReceive = false },
|
||||||
|
label = { Text("Send (PUT)") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Mode:")
|
||||||
|
Row {
|
||||||
|
FilterChip(
|
||||||
|
selected = isAscii,
|
||||||
|
onClick = { isAscii = true },
|
||||||
|
label = { Text("ASCII") }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
FilterChip(
|
||||||
|
selected = !isAscii,
|
||||||
|
onClick = { isAscii = false },
|
||||||
|
label = { Text("Binary") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Host:")
|
||||||
|
Row {
|
||||||
|
FTConfig.HostType.values().forEach { ht ->
|
||||||
|
FilterChip(
|
||||||
|
selected = (hostType == ht),
|
||||||
|
onClick = { hostType = ht },
|
||||||
|
label = { Text(ht.name) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (hostFile.isNotBlank() && localFile.isNotBlank()) {
|
||||||
|
val config = FTConfig().apply {
|
||||||
|
setHostFilename(hostFile.trim())
|
||||||
|
setLocalFilename(localFile.trim())
|
||||||
|
setReceive(isReceive)
|
||||||
|
setAscii(isAscii)
|
||||||
|
setHostType(hostType)
|
||||||
|
}
|
||||||
|
onStartTransfer(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Start Transfer")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
import org.lib3270j.ConnectionState
|
||||||
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TwoRowKeyBar(
|
||||||
|
connectionState: ConnectionState,
|
||||||
|
isShiftPressed: Boolean = false,
|
||||||
|
onClearShift: () -> Unit = {},
|
||||||
|
onConnectClick: () -> Unit,
|
||||||
|
onDisconnectClick: () -> Unit,
|
||||||
|
onFtClick: () -> Unit,
|
||||||
|
onSendAid: (Int) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
onTab: () -> Unit,
|
||||||
|
onBackTab: () -> Unit,
|
||||||
|
onCursorLeft: () -> Unit = {},
|
||||||
|
onCursorUp: () -> Unit = {},
|
||||||
|
onCursorDown: () -> Unit = {},
|
||||||
|
onCursorRight: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color(0xFF161719))
|
||||||
|
) {
|
||||||
|
// Row 1: System & Quick Action Keys (Static 12-key row matching Row 2 columns)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(35.dp)
|
||||||
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// 1. Menu Dropdown Button (Connect/Disconnect/FT)
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
KeyButton(
|
||||||
|
label = "⋮",
|
||||||
|
color = Color(0xFF343A40),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
innerPaddingHorizontal = 0.dp,
|
||||||
|
onClick = { menuExpanded = true }
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false },
|
||||||
|
modifier = Modifier.background(Color(0xFF2C2D30))
|
||||||
|
) {
|
||||||
|
if (!connectionState.isConnected()) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Connect", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onConnectClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Disconnect", color = Color(0xFFFF6B6B), fontWeight = FontWeight.SemiBold) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onDisconnectClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("File Transfer (FT)", color = Color.White, fontWeight = FontWeight.SemiBold) },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onFtClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TAB / BTAB depending on Shift state
|
||||||
|
val tabLabel = if (isShiftPressed) "BTAB" else "TAB"
|
||||||
|
KeyButton(
|
||||||
|
label = tabLabel,
|
||||||
|
color = Color(0xFF1C7ED6),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
innerPaddingHorizontal = 0.dp,
|
||||||
|
onClick = {
|
||||||
|
if (isShiftPressed) {
|
||||||
|
onBackTab()
|
||||||
|
onClearShift()
|
||||||
|
} else {
|
||||||
|
onTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. RESET
|
||||||
|
KeyButton("RESET", Color(0xFFE67700), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onReset)
|
||||||
|
|
||||||
|
// 4. ENTER
|
||||||
|
KeyButton("ENTER", Color(0xFF2B8A3E), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_ENTER) })
|
||||||
|
|
||||||
|
// 5. CLEAR
|
||||||
|
KeyButton("CLEAR", Color(0xFFC92A2A), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_CLEAR) })
|
||||||
|
|
||||||
|
// 6. PA1
|
||||||
|
KeyButton("PA1", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA1) })
|
||||||
|
|
||||||
|
// 7. PA2
|
||||||
|
KeyButton("PA2", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA2) })
|
||||||
|
|
||||||
|
// 8. PA3
|
||||||
|
KeyButton("PA3", Color(0xFF495057), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = { onSendAid(AID_PA3) })
|
||||||
|
|
||||||
|
// 9. Left Navigation
|
||||||
|
KeyButton("←", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorLeft)
|
||||||
|
|
||||||
|
// 10. Up Navigation
|
||||||
|
KeyButton("↑", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorUp)
|
||||||
|
|
||||||
|
// 11. Down Navigation
|
||||||
|
KeyButton("↓", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorDown)
|
||||||
|
|
||||||
|
// 12. Right Navigation
|
||||||
|
KeyButton("→", Color(0xFF343A40), modifier = Modifier.weight(1f), innerPaddingHorizontal = 0.dp, onClick = onCursorRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(Color(0xFF2C2D30)))
|
||||||
|
|
||||||
|
// Row 2: PF Keys (F1-F12 when Shift is off; F13-F24 when Shift is down) - Non-scrolling, full width
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(35.dp)
|
||||||
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
val pfAids = intArrayOf(
|
||||||
|
AID_PF1, AID_PF2, AID_PF3, AID_PF4, AID_PF5, AID_PF6,
|
||||||
|
AID_PF7, AID_PF8, AID_PF9, AID_PF10, AID_PF11, AID_PF12,
|
||||||
|
AID_PF13, AID_PF14, AID_PF15, AID_PF16, AID_PF17, AID_PF18,
|
||||||
|
AID_PF19, AID_PF20, AID_PF21, AID_PF22, AID_PF23, AID_PF24
|
||||||
|
)
|
||||||
|
|
||||||
|
val startPf = if (isShiftPressed) 13 else 1
|
||||||
|
val endPf = if (isShiftPressed) 24 else 12
|
||||||
|
|
||||||
|
for (i in startPf..endPf) {
|
||||||
|
val aid = pfAids[i - 1]
|
||||||
|
KeyButton(
|
||||||
|
label = "F$i",
|
||||||
|
color = Color(0xFF364FC7),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
innerPaddingHorizontal = 0.dp,
|
||||||
|
onClick = {
|
||||||
|
onSendAid(aid)
|
||||||
|
if (isShiftPressed) {
|
||||||
|
onClearShift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun KeyButton(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
innerPaddingHorizontal: androidx.compose.ui.unit.Dp = 0.dp
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = color,
|
||||||
|
shadowElevation = 1.dp,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = 1.dp, vertical = 1.dp)
|
||||||
|
.height(31.dp)
|
||||||
|
.focusProperties { canFocus = false }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = innerPaddingHorizontal)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.lib3270j.ConnectionState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OiaStatusBar(
|
||||||
|
connectionState: ConnectionState,
|
||||||
|
currentHost: String,
|
||||||
|
isKeyboardLocked: Boolean,
|
||||||
|
oiaText: String,
|
||||||
|
cursorAddr: Int,
|
||||||
|
rows: Int,
|
||||||
|
cols: Int,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val row = if (cols > 0) (cursorAddr / cols) + 1 else 1
|
||||||
|
val col = if (cols > 0) (cursorAddr % cols) + 1 else 1
|
||||||
|
val posStr = String.format("%02d/%02d", row, col)
|
||||||
|
|
||||||
|
val hostText = if (currentHost.isNotBlank()) currentHost else oiaText
|
||||||
|
|
||||||
|
val lockStatusText = if (!connectionState.isConnected()) {
|
||||||
|
"OFFLINE"
|
||||||
|
} else if (isKeyboardLocked) {
|
||||||
|
"X SYSTEM"
|
||||||
|
} else {
|
||||||
|
"READY"
|
||||||
|
}
|
||||||
|
|
||||||
|
val lockStatusColor = if (!connectionState.isConnected()) {
|
||||||
|
Color(0xFF868E96)
|
||||||
|
} else if (isKeyboardLocked) {
|
||||||
|
Color(0xFFFF6B6B) // Red for locked
|
||||||
|
} else {
|
||||||
|
Color(0xFF51CF66) // Green for ready
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(26.dp)
|
||||||
|
.background(Color(0xFF161719))
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
// Connected Host & Status Indicator
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(if (connectionState.isConnected()) Color(0xFF51CF66) else Color(0xFF868E96))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = hostText,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal Keyboard Lock Status Badge (X SYSTEM vs READY)
|
||||||
|
Surface(
|
||||||
|
color = lockStatusColor.copy(alpha = 0.2f),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = lockStatusText,
|
||||||
|
color = lockStatusColor,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor Position
|
||||||
|
Text(
|
||||||
|
text = "R$posStr",
|
||||||
|
color = Color(0xFF22B8CF),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package org.pubvm.a3270.ui
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.lib3270j.protocol.DS3270Constants.*
|
||||||
|
import org.lib3270j.screen.ExtendedAttribute
|
||||||
|
import org.lib3270j.screen.ScreenBuffer
|
||||||
|
|
||||||
|
// Standard 3279 Host Colors (16-color palette)
|
||||||
|
private val HOST_COLORS = arrayOf(
|
||||||
|
Color(0xFF000000), // 0: Neutral Black
|
||||||
|
Color(0xFF5078FF), // 1: Blue
|
||||||
|
Color(0xFFFF3232), // 2: Red
|
||||||
|
Color(0xFFFF82B4), // 3: Pink
|
||||||
|
Color(0xFF32CD32), // 4: Green
|
||||||
|
Color(0xFF40E0D0), // 5: Turquoise
|
||||||
|
Color(0xFFFFFF50), // 6: Yellow
|
||||||
|
Color(0xFFFFFFFF), // 7: Neutral White
|
||||||
|
Color(0xFF000000), // 8: Black
|
||||||
|
Color(0xFF1E3CB4), // 9: Deep Blue
|
||||||
|
Color(0xFFFFA500), // 10: Orange
|
||||||
|
Color(0xFFB482FF), // 11: Purple
|
||||||
|
Color(0xFF90EE90), // 12: Pale Green
|
||||||
|
Color(0xFFAFEEEE), // 13: Pale Turquoise
|
||||||
|
Color(0xFFAAAAAA), // 14: Grey
|
||||||
|
Color(0xFFFFFFFF) // 15: White
|
||||||
|
)
|
||||||
|
|
||||||
|
private val COLOR_BLACK = Color(0xFF0A0A0A)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TerminalView(
|
||||||
|
screenBuffer: ScreenBuffer?,
|
||||||
|
rows: Int,
|
||||||
|
cols: Int,
|
||||||
|
cursorAddr: Int,
|
||||||
|
screenVersion: Long,
|
||||||
|
onTapAddress: (Int) -> Unit,
|
||||||
|
onPasteText: (String) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
var selectionStart by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
var selectionEnd by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
|
var contextMenuOffset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(COLOR_BLACK)
|
||||||
|
.pointerInput(cols, rows) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
val startPos = down.position
|
||||||
|
var currentPos = startPos
|
||||||
|
var isDragStarted = false
|
||||||
|
val slop = viewConfiguration.touchSlop
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!change.pressed) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentPos = change.position
|
||||||
|
val distance = (currentPos - startPos).getDistance()
|
||||||
|
|
||||||
|
if (distance > slop) {
|
||||||
|
if (!isDragStarted) {
|
||||||
|
isDragStarted = true
|
||||||
|
selectionStart = startPos
|
||||||
|
showContextMenu = false
|
||||||
|
}
|
||||||
|
selectionEnd = currentPos
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val duration = System.currentTimeMillis() - startTime
|
||||||
|
val totalDistance = (currentPos - startPos).getDistance()
|
||||||
|
|
||||||
|
if (isDragStarted) {
|
||||||
|
// Drag completed: Keep selection box highlighted, DO NOT open context menu
|
||||||
|
showContextMenu = false
|
||||||
|
} else if (duration >= 600L && totalDistance <= slop) {
|
||||||
|
// Stationary Long Press (held >= 600ms without dragging)
|
||||||
|
contextMenuOffset = startPos
|
||||||
|
showContextMenu = true
|
||||||
|
} else if (totalDistance <= slop) {
|
||||||
|
// Short Tap (Move Cursor)
|
||||||
|
showContextMenu = false
|
||||||
|
selectionStart = null
|
||||||
|
selectionEnd = null
|
||||||
|
val cellWidth = size.width / cols
|
||||||
|
val cellHeight = size.height / rows
|
||||||
|
if (cellWidth > 0 && cellHeight > 0) {
|
||||||
|
val col = (startPos.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
|
val row = (startPos.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
var addr = row * cols + col
|
||||||
|
|
||||||
|
val buf = screenBuffer
|
||||||
|
if (buf != null && buf.isFormatted) {
|
||||||
|
val faVal = buf.getFieldAttributeAt(addr)
|
||||||
|
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) {
|
||||||
|
addr = buf.findNextUnprotected(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onTapAddress(addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
val cellWidth = width / cols
|
||||||
|
val cellHeight = height / rows
|
||||||
|
|
||||||
|
if (cellWidth <= 0 || cellHeight <= 0) return@Canvas
|
||||||
|
|
||||||
|
val paint = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
typeface = Typeface.MONOSPACE
|
||||||
|
textSize = cellHeight * 0.85f
|
||||||
|
}
|
||||||
|
|
||||||
|
val buf = screenBuffer
|
||||||
|
val totalCells = rows * cols
|
||||||
|
|
||||||
|
var currentFA: Byte = 0
|
||||||
|
var currentFieldEa: ExtendedAttribute? = null
|
||||||
|
|
||||||
|
// Calculate selected cell rectangle range if drag selection active
|
||||||
|
var selMinRow = -1
|
||||||
|
var selMaxRow = -1
|
||||||
|
var selMinCol = -1
|
||||||
|
var selMaxCol = -1
|
||||||
|
|
||||||
|
val start = selectionStart
|
||||||
|
val end = selectionEnd
|
||||||
|
if (start != null && end != null) {
|
||||||
|
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
|
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
|
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
|
||||||
|
selMinRow = minOf(startRow, endRow)
|
||||||
|
selMaxRow = maxOf(startRow, endRow)
|
||||||
|
selMinCol = minOf(startCol, endCol)
|
||||||
|
selMaxCol = maxOf(startCol, endCol)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
for (c in 0 until cols) {
|
||||||
|
val addr = r * cols + c
|
||||||
|
if (addr >= totalCells) break
|
||||||
|
|
||||||
|
val left = c * cellWidth
|
||||||
|
val top = r * cellHeight
|
||||||
|
|
||||||
|
var charVal = ' '
|
||||||
|
var fgColor: Color
|
||||||
|
var bgColor = COLOR_BLACK
|
||||||
|
var isBold = false
|
||||||
|
var isUnderline = false
|
||||||
|
var isReverse = false
|
||||||
|
|
||||||
|
if (buf != null) {
|
||||||
|
val ea = buf.getCell(addr)
|
||||||
|
|
||||||
|
if (ea.isFieldAttribute) {
|
||||||
|
currentFA = ea.fa
|
||||||
|
currentFieldEa = ea
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute 3270 color and field intensity
|
||||||
|
fgColor = getFgColorForAttribute(ea, currentFieldEa, currentFA)
|
||||||
|
bgColor = getBgColorForAttribute(ea, currentFieldEa)
|
||||||
|
|
||||||
|
// Intensity
|
||||||
|
if (faIsHigh(currentFA.toInt() and 0xFF)) {
|
||||||
|
isBold = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible fields (passwords / hidden)
|
||||||
|
if (faIsZero(currentFA.toInt() and 0xFF)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended Graphic Rendition
|
||||||
|
val gr = if (ea.gr != 0.toByte()) ea.gr else (currentFieldEa?.gr ?: 0)
|
||||||
|
if (gr != 0.toByte()) {
|
||||||
|
val grVal = gr.toInt() and 0xFF
|
||||||
|
if ((grVal and GR_INTENSIFY) != 0) isBold = true
|
||||||
|
if ((grVal and GR_UNDERLINE) != 0) isUnderline = true
|
||||||
|
if ((grVal and GR_REVERSE) != 0) isReverse = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ea.ucs4 > ' ' && ea.ucs4.code != 0xFFFF) {
|
||||||
|
charVal = ea.ucs4
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fgColor = HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReverse) {
|
||||||
|
val tmp = fgColor
|
||||||
|
fgColor = bgColor
|
||||||
|
bgColor = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgColor != COLOR_BLACK) {
|
||||||
|
drawRect(
|
||||||
|
color = bgColor,
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(cellWidth, cellHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight selected block range
|
||||||
|
val isSelected = r in selMinRow..selMaxRow && c in selMinCol..selMaxCol
|
||||||
|
if (isSelected) {
|
||||||
|
drawRect(
|
||||||
|
color = Color(0x773399FF),
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(cellWidth, cellHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addr == cursorAddr && !isSelected) {
|
||||||
|
drawRect(
|
||||||
|
color = HOST_COLORS[HOST_COLOR_TURQUOISE].copy(alpha = 0.5f),
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(cellWidth, cellHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (charVal != ' ') {
|
||||||
|
paint.color = fgColor.toArgb()
|
||||||
|
paint.isFakeBoldText = isBold
|
||||||
|
val fontMetrics = paint.fontMetrics
|
||||||
|
val textY = top + (cellHeight - fontMetrics.bottom + fontMetrics.top) / 2 - fontMetrics.top
|
||||||
|
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
charVal.toString(),
|
||||||
|
left + (cellWidth / 4),
|
||||||
|
textY,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnderline) {
|
||||||
|
drawLine(
|
||||||
|
color = fgColor,
|
||||||
|
start = Offset(left, top + cellHeight - 2),
|
||||||
|
end = Offset(left + cellWidth, top + cellHeight - 2),
|
||||||
|
strokeWidth = 2f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context Menu Popup
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showContextMenu,
|
||||||
|
onDismissRequest = { showContextMenu = false },
|
||||||
|
offset = DpOffset(
|
||||||
|
x = (contextMenuOffset.x / density.density).dp,
|
||||||
|
y = (contextMenuOffset.y / density.density).dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val hasSelection = selectionStart != null && selectionEnd != null
|
||||||
|
if (hasSelection) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Copy Selection") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
copySelection(selectionStart, selectionEnd, screenBuffer, rows, cols, clipboardManager, context)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Clear Selection") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
selectionStart = null
|
||||||
|
selectionEnd = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Select All") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
selectionStart = Offset(0f, 0f)
|
||||||
|
selectionEnd = Offset(100000f, 100000f)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Paste") },
|
||||||
|
onClick = {
|
||||||
|
showContextMenu = false
|
||||||
|
val clipText = clipboardManager.getText()?.text
|
||||||
|
if (!clipText.isNullOrEmpty()) {
|
||||||
|
onPasteText(clipText)
|
||||||
|
Toast.makeText(context, "Pasted text from clipboard", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Clipboard is empty", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copySelection(
|
||||||
|
start: Offset?,
|
||||||
|
end: Offset?,
|
||||||
|
screenBuffer: ScreenBuffer?,
|
||||||
|
rows: Int,
|
||||||
|
cols: Int,
|
||||||
|
clipboardManager: androidx.compose.ui.platform.ClipboardManager,
|
||||||
|
context: android.content.Context
|
||||||
|
) {
|
||||||
|
if (start != null && end != null && screenBuffer != null && cols > 0 && rows > 0) {
|
||||||
|
val displayMetrics = context.resources.displayMetrics
|
||||||
|
val cellWidth = displayMetrics.widthPixels.toFloat() / cols
|
||||||
|
val cellHeight = displayMetrics.heightPixels.toFloat() / rows
|
||||||
|
|
||||||
|
val startCol = (start.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
|
val startRow = (start.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
val endCol = (end.x / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||||
|
val endRow = (end.y / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||||
|
|
||||||
|
val minRow = minOf(startRow, endRow)
|
||||||
|
val maxRow = maxOf(startRow, endRow)
|
||||||
|
val minCol = minOf(startCol, endCol)
|
||||||
|
val maxCol = maxOf(startCol, endCol)
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (r in minRow..maxRow) {
|
||||||
|
val line = StringBuilder()
|
||||||
|
for (c in minCol..maxCol) {
|
||||||
|
val addr = r * cols + c
|
||||||
|
if (addr < (rows * cols)) {
|
||||||
|
val cell = screenBuffer.getCell(addr)
|
||||||
|
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||||
|
line.append(cell.ucs4)
|
||||||
|
} else {
|
||||||
|
line.append(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append(line.toString().trimEnd()).append('\n')
|
||||||
|
}
|
||||||
|
val textToCopy = sb.toString().trimEnd()
|
||||||
|
if (textToCopy.isNotBlank()) {
|
||||||
|
clipboardManager.setText(AnnotatedString(textToCopy))
|
||||||
|
Toast.makeText(context, "Copied selected block to clipboard", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?, currentFA: Byte): Color {
|
||||||
|
val fg = if (ea.fg != 0.toByte()) (ea.fg.toInt() and 0xFF)
|
||||||
|
else if (currentFieldEa != null && currentFieldEa.fg != 0.toByte()) (currentFieldEa.fg.toInt() and 0xFF)
|
||||||
|
else 0
|
||||||
|
|
||||||
|
if (fg in 0xf0..0xff) {
|
||||||
|
return HOST_COLORS[fg - 0xf0]
|
||||||
|
}
|
||||||
|
val fa = currentFA.toInt() and 0xFF
|
||||||
|
return if (faIsProtected(fa)) {
|
||||||
|
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_WHITE] else HOST_COLORS[HOST_COLOR_BLUE]
|
||||||
|
} else {
|
||||||
|
if (faIsHigh(fa)) HOST_COLORS[HOST_COLOR_RED] else HOST_COLORS[HOST_COLOR_GREEN]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBgColorForAttribute(ea: ExtendedAttribute, currentFieldEa: ExtendedAttribute?): Color {
|
||||||
|
val bg = if (ea.bg != 0.toByte()) (ea.bg.toInt() and 0xFF)
|
||||||
|
else if (currentFieldEa != null && currentFieldEa.bg != 0.toByte()) (currentFieldEa.bg.toInt() and 0xFF)
|
||||||
|
else 0
|
||||||
|
|
||||||
|
if (bg in 0xf0..0xff) {
|
||||||
|
val idx = bg - 0xf0
|
||||||
|
if (idx == HOST_COLOR_NEUTRAL_BLACK || idx == HOST_COLOR_BLACK) {
|
||||||
|
return COLOR_BLACK
|
||||||
|
}
|
||||||
|
return HOST_COLORS[idx]
|
||||||
|
}
|
||||||
|
return COLOR_BLACK
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.toArgb(): Int {
|
||||||
|
return (alpha * 255).toInt() shl 24 or
|
||||||
|
((red * 255).toInt() shl 16) or
|
||||||
|
((green * 255).toInt() shl 8) or
|
||||||
|
(blue * 255).toInt()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M20,3H4C2.89,3 2,3.89 2,5v10c0,1.1 0.89,2 2,2h6v2H8v2h8v-2h-2v-2h6c1.1,0 2,-0.9 2,-2V5C22,3.89 21.1,3 20,3zM20,15H4V5h16V15zM7,7h2v2H7V7zM10,7h7v2h-7V7zM7,10h2v2H7V10zM10,10h5v2h-5V10z"/>
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user