Mouse tap-only, ProxyJump probe fix, Ed25519 key loading, jump tunnel monitor

Mouse mode: only taps forwarded as clicks, scroll/select/fling always work.
Active pane zIndex ensures touch events reach correct session.
SSHKeyLoader: auto-detect key format, Ed25519 PKCS#8 via net.i2p.crypto.eddsa.
ProxyJump probe: tunneled connections trust isConnected (no raw socket).
Jump monitor: watches tunnel state, force-disconnects on tunnel death.
Copy Private Key works without biometric lock enabled.
KeyLoadingTest: 9 tests covering all key formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-02 22:55:29 +02:00
parent 9c980bbea7
commit 2fd8308056
11 changed files with 374 additions and 99 deletions

View file

@ -120,6 +120,7 @@ class TerminalService : Service() {
var onBufferReplaced: ((ScreenBuffer) -> Unit)? = null,
var outputJob: Job? = null,
var stateJob: Job? = null,
var jumpMonitorJob: Job? = null,
/** Per-session font size (zoom level). 0 = use default. Survives Activity destruction. */
var fontSizeSp: Float = 0f,
/** Per-session label override (null = use connection name). Survives Activity destruction. */
@ -588,6 +589,31 @@ class TerminalService : Service() {
proxyClient = proxyClient
)
session.connect(config)
// Monitor jump host sessions — if any jump tunnel dies,
// force disconnect the dependent session immediately.
// Single parent job so all collectors cancel together on cleanup.
if (entry.jumpHostSessions.isNotEmpty()) {
entry.jumpMonitorJob = serviceScope.launch {
for (jumpSession in entry.jumpHostSessions) {
launch {
jumpSession.state.collect { jumpState ->
if (jumpState is SessionState.Disconnected || jumpState is SessionState.Error) {
val mainState = session.state.value
if (mainState is SessionState.Connected) {
val reason = when (jumpState) {
is SessionState.Disconnected -> jumpState.reason
is SessionState.Error -> jumpState.message
else -> "unknown"
}
FileLogger.log(TAG, "SSH[$sessionId] jump tunnel died: $reason — forcing disconnect")
session.forceDisconnect(getString(R.string.disconnect_jump_host_lost))
}
}
}
}
}
}
}
} catch (e: Exception) {
FileLogger.log(TAG, "connectSSH[$sessionId] jump chain failed: ${e.message}")
updateSessionState(sessionId, SessionState.Error("Jump host failed: ${e.message}", e))
@ -1050,6 +1076,7 @@ class TerminalService : Service() {
entry.password = null
entry.outputJob?.cancel()
entry.stateJob?.cancel()
entry.jumpMonitorJob?.cancel()
entry.sshSession?.disconnect()
entry.localShellSession?.stop()
entry.telnetSession?.disconnect()
@ -1197,6 +1224,7 @@ class TerminalService : Service() {
val savedFontSize = entry.fontSizeSp
entry.outputJob?.cancel()
entry.stateJob?.cancel()
entry.jumpMonitorJob?.cancel()
// Disconnect old SSH session without removing from sessions map —
// connectSSHInternal will replace the entry. Keeping it in the map ensures
// the service never sees sessions.isEmpty() during the reconnect window.

View file

@ -44,6 +44,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.zIndex
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView
@ -367,6 +368,9 @@ class MainActivity : AppCompatActivity() {
}
// Session surfaces — terminal panes only (no keyboard per pane)
// Active session gets zIndex(1f) so it receives touch events
// regardless of iteration order (INVISIBLE AndroidViews in Compose
// can still intercept pointer events from composables below them).
Box(Modifier.weight(1f)) {
for (sid in tabOrder) {
val isVisible = showTerminal && appState.activeSessionId == sid
@ -376,7 +380,8 @@ class MainActivity : AppCompatActivity() {
val info = sftpTabs[sid]
if (info != null) {
// Always composed (preserves SAF launchers across picker lifecycle)
Box(modifier = if (isVisible) Modifier.fillMaxSize() else Modifier.fillMaxSize().alpha(0f)) {
val sftpMod = if (isVisible) Modifier.fillMaxSize().zIndex(1f) else Modifier.fillMaxSize().alpha(0f)
Box(modifier = sftpMod) {
SftpScreen(
connectionId = info.connectionId,
sftpSessionId = info.sftpSessionId,
@ -423,7 +428,8 @@ class MainActivity : AppCompatActivity() {
mainViewModel.setSessionFontSize(sid, fontSize)
},
onShowKeyboard = { ckbHidden = false },
themeOverride = sessionThemes[sid] ?: ""
themeOverride = sessionThemes[sid] ?: "",
modifier = if (isVisible) Modifier.zIndex(1f) else Modifier
)
}
}

View file

@ -173,23 +173,23 @@ fun KeyManagerScreen(
key = key,
onDelete = { keyToDelete = key },
onCopyPrivateKey = {
if (biometricAuth != null && biometricAuth.isEnabled() && activity != null) {
if (activity == null) return@KeyItem
val doCopy = {
val privateKey = viewModel.getPrivateKey(key.id)
if (privateKey != null) {
val cm = activity.getSystemService(android.content.ClipboardManager::class.java)
cm.setPrimaryClip(android.content.ClipData.newPlainText("SSH Private Key", privateKey))
android.widget.Toast.makeText(activity, activity.getString(R.string.key_manager_private_copied), android.widget.Toast.LENGTH_SHORT).show()
}
}
if (biometricAuth != null && biometricAuth.isEnabled()) {
biometricAuth.promptForSensitiveAction(
activity = activity,
reason = activity.getString(R.string.key_manager_export_reason),
onSuccess = {
val privateKey = viewModel.getPrivateKey(key.id)
if (privateKey != null) {
val cm = activity.getSystemService(android.content.ClipboardManager::class.java)
cm.setPrimaryClip(android.content.ClipData.newPlainText("SSH Private Key", privateKey))
android.widget.Toast.makeText(activity, activity.getString(R.string.key_manager_private_copied), android.widget.Toast.LENGTH_SHORT).show()
}
}
onSuccess = doCopy
)
} else if (activity != null) {
android.widget.Toast.makeText(activity,
activity.getString(R.string.key_manager_auth_required),
android.widget.Toast.LENGTH_LONG).show()
} else {
doCopy()
}
}
)

View file

@ -203,7 +203,7 @@ Feature flags for free/pro tiers. Two implementations in `free/` and `pro/` sour
#### `MainActivity`
Single Activity host for the entire app. Three-layer Box architecture:
- **Layer 1**: Terminal surfaces (`TerminalPane` per session) + SFTP tabs (`SftpScreen`) + `SessionTabBar` — always in tree, visibility toggled via `alpha(0f)`. Iterates `tabOrder`, renders `TerminalPane` or `SftpScreen` based on `tabTypes`. `key(sid)` for stable Compose identity, `View.INVISIBLE` preserves SurfaceView surfaces.
- **Layer 1**: Terminal surfaces (`TerminalPane` per session) + SFTP tabs (`SftpScreen`) + `SessionTabBar` — always in tree, visibility toggled via `alpha(0f)`. Iterates `tabOrder`, renders `TerminalPane` or `SftpScreen` based on `tabTypes`. `key(sid)` for stable Compose identity, `View.INVISIBLE` preserves SurfaceView surfaces. Active session gets `zIndex(1f)` to ensure it receives touch events regardless of Compose stacking order (INVISIBLE `AndroidView` wrappers can intercept pointer events).
- **Layer 2**: NavHost (AnimatedVisibility) — ConnectionListScreen, EditConnectionScreen, SettingsScreen, KeyManagerScreen
- Binds to TerminalService, delegates all state management to MainViewModel
@ -510,8 +510,14 @@ Active SSH session.
- `disconnect()` — graceful close
- `debugLogBuffer` — synchronized compound operations (check-then-add) for thread safety
**Key loading** — delegated to `SSHKeyLoader`:
- Auto-detects format from PEM header: PKCS#8, OpenSSH v1, legacy PEM, PuTTY PPK
- Ed25519 PKCS#8 (OID 1.3.101.112): parsed via BC ASN.1, seed extracted, `net.i2p.crypto.eddsa` keys built (SSHJ's wire encoder requires these, not BC key types)
- RSA/ECDSA PKCS#8: delegated to SSHJ's `PKCS8KeyFile`
- OpenSSH v1: `OpenSSHKeyV1KeyFile`, legacy PEM: `OpenSSHKeyFile`, PuTTY: `PuTTYKeyFile`
**Auth cascade** (`authenticateAll`):
1. `publickey` (KeyString/KeyFile) if provided
1. `publickey` via `SSHKeyLoader.load()` if key configured
2. `password` if provided
3. `keyboard-interactive` via `AuthPromptCallback` — skipped entirely when no callback is set (no empty-password fallback)
4. If all fail → `UserAuthException`
@ -519,8 +525,9 @@ Active SSH session.
**Connection health**:
- Keepalive: 15s interval, 3 missed = 45s cutoff
- Read silence monitor: detects zombie TCP (write succeeds but no read)
- TCP probe: `sendUrgentData(0)` to verify socket
- TCP probe: `sendUrgentData(0)` for direct connections; tunneled connections (`connectVia`) have no raw socket — probe trusts `client.isConnected` and SSHJ's KeepAliveRunner
- Transport `DisconnectListener`: catches SSHJ reader thread crashes (e.g., WiFi off)
- **Jump session monitor**: coroutine watches each jump session's `state` flow — if a jump tunnel dies, the dependent session is force-disconnected immediately (SSHJ's `DirectConnection` channel doesn't propagate errors to the inner transport)
### `SSHConnectionConfig`
```kotlin
@ -641,9 +648,10 @@ SurfaceView with dedicated render thread.
- **Snap-to-content on drop**: when a handle is dropped on trailing whitespace, it snaps to the nearest content. `snapStartToContent()` snaps forward, `snapEndToContent()` snaps backward. Uses `firstNonSpaceCol()`/`lastNonSpaceCol()` for whitespace detection (handles all whitespace types, not just space). Snapping only occurs on `ACTION_UP`, not during drag.
**Mouse reporting** (`MouseReporter`):
- Handles X10/Normal/Button-Event/Any-Event mouse tracking
- SGR and URXVT encoding
- Extracted to `MouseReporter.kt` — receives `onTerminalInput` callback
- Only **taps** are forwarded as mouse clicks when mouse mode is active
- Scroll, long-press (select), double-tap, and fling always bypass mouse mode — scrollback and text selection always work
- SGR, URXVT, and X10 coordinate encoding
- Extracted to `MouseReporter.kt``sendClick()` sends press+release at pixel coordinates
**URL detection** (`TerminalUrlDetector`):
- Regex matching on visible viewport

View file

@ -26,6 +26,7 @@ android {
dependencies {
implementation(libs.kotlinx.coroutines.core)
api(libs.sshj)
implementation("net.i2p.crypto:eddsa:0.3.0") // SSHJ's Ed25519 key types
implementation(libs.bouncycastle.prov)
implementation(libs.bouncycastle.pkix)
implementation(libs.slf4j.simple)

View file

@ -0,0 +1,119 @@
package com.roundingmobile.ssh
import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
import net.i2p.crypto.eddsa.EdDSAPrivateKey
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile
import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile
import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile
import net.schmizz.sshj.userauth.password.PasswordFinder
import org.bouncycastle.asn1.ASN1OctetString
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
import java.io.StringReader
import java.security.PrivateKey
import java.security.PublicKey
/**
* Loads SSH private keys from PEM strings into SSHJ [KeyProvider] instances.
*
* Handles all formats:
* - PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`) our KeyGenerator output
* - OpenSSH v1 (`-----BEGIN OPENSSH PRIVATE KEY-----`) ssh-keygen default since 7.8
* - Legacy PEM (`-----BEGIN RSA/DSA/EC PRIVATE KEY-----`) older ssh-keygen, `-m PEM`
* - PuTTY PPK (`PuTTY-User-Key-File-`) PuTTYgen
*
* Ed25519 PKCS#8 keys are handled specially: the 32-byte seed is extracted via
* BouncyCastle ASN.1, then [net.i2p.crypto.eddsa] keys are created (the type
* SSHJ's wire encoder expects). Other PKCS#8 keys (RSA, ECDSA) go through
* SSHJ's built-in [PKCS8KeyFile].
*/
object SSHKeyLoader {
private const val ED25519_OID = "1.3.101.112"
/**
* Load a PEM-encoded private key string into an SSHJ [KeyProvider].
* Auto-detects the format from the PEM header.
*/
fun load(pem: String, pw: PasswordFinder? = null): KeyProvider {
val trimmed = pem.trimStart()
return when {
trimmed.startsWith("-----BEGIN PRIVATE KEY-----") -> loadPkcs8(pem, pw)
trimmed.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") -> loadOpenSshV1(pem, pw)
trimmed.startsWith("PuTTY-User-Key-File-") -> loadPutty(pem, pw)
else -> loadLegacyPem(pem, pw)
}
}
/**
* Load a PKCS#8 PEM key. Ed25519 keys are parsed via BC ASN.1 into
* [net.i2p.crypto.eddsa] types; other algorithms use SSHJ's [PKCS8KeyFile].
*/
fun loadPkcs8(pem: String, pw: PasswordFinder? = null): KeyProvider {
val b64 = pem.lines()
.filter { !it.startsWith("-----") && it.isNotBlank() }
.joinToString("")
val pkcs8Bytes = java.util.Base64.getDecoder().decode(b64)
val info = PrivateKeyInfo.getInstance(pkcs8Bytes)
val oid = info.privateKeyAlgorithm.algorithm.id
if (oid == ED25519_OID) {
return loadEd25519FromPkcs8(info)
}
// RSA, ECDSA, DSA — SSHJ's built-in handler
val kf = PKCS8KeyFile()
kf.init(StringReader(pem), pw)
return kf
}
/** Load an OpenSSH v1 format key (`-----BEGIN OPENSSH PRIVATE KEY-----`). */
fun loadOpenSshV1(pem: String, pw: PasswordFinder? = null): KeyProvider {
val kf = OpenSSHKeyV1KeyFile()
kf.init(StringReader(pem), pw)
return kf
}
/** Load a legacy PEM key (`-----BEGIN RSA/DSA/EC PRIVATE KEY-----`). */
fun loadLegacyPem(pem: String, pw: PasswordFinder? = null): KeyProvider {
val kf = OpenSSHKeyFile()
kf.init(StringReader(pem), null, pw)
return kf
}
/** Load a PuTTY PPK format key. */
fun loadPutty(pem: String, pw: PasswordFinder? = null): KeyProvider {
val kf = PuTTYKeyFile()
kf.init(StringReader(pem), pw)
return kf
}
/**
* Extract 32-byte Ed25519 seed from PKCS#8 [PrivateKeyInfo] and build
* [net.i2p.crypto.eddsa] key objects that SSHJ can wire-encode.
*/
private fun loadEd25519FromPkcs8(info: PrivateKeyInfo): KeyProvider {
val seed = ASN1OctetString.getInstance(info.parsePrivateKey()).octets
val edSpec = EdDSANamedCurveTable.getByName("Ed25519")
val privSpec = EdDSAPrivateKeySpec(seed, edSpec)
val privKey = EdDSAPrivateKey(privSpec)
val pubSpec = EdDSAPublicKeySpec(privSpec.a, edSpec)
val pubKey = com.hierynomus.sshj.signature.Ed25519PublicKey(pubSpec)
return SimpleKeyProvider(privKey, pubKey, KeyType.fromKey(pubKey))
}
/** Minimal [KeyProvider] wrapping a pre-built key pair. */
private class SimpleKeyProvider(
private val priv: PrivateKey,
private val pub: PublicKey,
private val type: KeyType
) : KeyProvider {
override fun getPrivate() = priv
override fun getPublic() = pub
override fun getType() = type
}
}

View file

@ -99,8 +99,9 @@ class SSHSession {
init {
// Register BouncyCastle as LAST provider (fallback only).
// Android's Conscrypt handles most crypto natively (fast, already loaded).
// BC is only needed for X25519 key exchange and Ed25519.
// Android's Conscrypt handles most crypto natively (fast).
// BC is needed for X25519 key exchange. Ed25519 user keys are handled
// via net.i2p.crypto.eddsa (SSHJ's bundled EdDSA library).
Security.removeProvider("BC")
Security.addProvider(BouncyCastleProvider())
// Tell SSHJ not to try its own BC registration (we already did it)
@ -395,12 +396,10 @@ class SSHSession {
else client.loadKeys(auth.path)
}
is SSHAuth.KeyString -> {
val kf = net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile()
val pw = if (auth.passphrase != null)
net.schmizz.sshj.userauth.password.PasswordUtils.createOneOff(auth.passphrase.toCharArray())
else null
kf.init(java.io.StringReader(auth.key), null, pw)
kf
SSHKeyLoader.load(auth.key, pw)
}
else -> null
}
@ -572,10 +571,17 @@ class SSHSession {
*/
private fun probeConnection(client: SSHClient): Boolean {
return try {
if (!client.isConnected) {
log("probe: client reports not connected")
return false
}
val socket = client.socket
if (socket == null) {
log("probe: socket is null")
return false
// Tunneled connection (connectVia) — no raw TCP socket.
// SSHJ's KeepAliveRunner handles liveness detection via the tunnel.
// isConnected already confirmed the transport is alive.
log("probe: tunneled connection, relying on keepalive")
return true
}
if (socket.isClosed || socket.isInputShutdown || socket.isOutputShutdown) {
log("probe: socket dead (closed=${socket.isClosed} inShut=${socket.isInputShutdown} outShut=${socket.isOutputShutdown})")
@ -597,7 +603,7 @@ class SSHSession {
/**
* Force disconnect and mark session as dead with the given reason.
*/
private fun forceDisconnect(reason: String) {
fun forceDisconnect(reason: String) {
if (_state.value is SessionState.Connected) {
_state.value = SessionState.Disconnected(reason)
}

View file

@ -0,0 +1,155 @@
package com.roundingmobile.ssh
import net.schmizz.sshj.common.KeyType
import net.schmizz.sshj.common.SecurityUtils
import net.schmizz.sshj.userauth.password.PasswordUtils
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.generators.RSAKeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.params.RSAKeyGenerationParameters
import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters
import org.bouncycastle.crypto.util.PrivateKeyInfoFactory
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.util.io.pem.PemObject
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.BeforeClass
import org.junit.Test
import java.io.File
import java.io.StringWriter
import java.math.BigInteger
import java.security.SecureRandom
import java.security.Security
class KeyLoadingTest {
companion object {
private lateinit var ed25519Pem: String
private lateinit var rsaPem: String
private var sshKeygen = false
private var opensshEd25519: String? = null
private var opensshRsa: String? = null
private var opensshEcdsa256: String? = null
private var legacyPemRsa: String? = null
private var legacyPemEcdsa: String? = null
private var opensshEd25519Pass: String? = null
private var opensshRsaPass: String? = null
private val tmpDir = File(System.getProperty("java.io.tmpdir"), "sshkeytest")
private fun keygen(name: String, vararg args: String, passphrase: String = ""): String? {
val f = File(tmpDir, name)
if (f.exists()) return f.readText()
return try {
val cmd = mutableListOf("ssh-keygen", "-q", "-f", f.absolutePath, "-N", passphrase)
cmd.addAll(args)
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
p.inputStream.readBytes()
if (p.waitFor() == 0) f.readText() else null
} catch (_: Exception) { null }
}
private fun bcPem(key: org.bouncycastle.crypto.params.AsymmetricKeyParameter): String {
val info = PrivateKeyInfoFactory.createPrivateKeyInfo(key)
val sw = StringWriter()
org.bouncycastle.openssl.jcajce.JcaPEMWriter(sw).use {
it.writeObject(PemObject("PRIVATE KEY", info.encoded))
}
return sw.toString()
}
@BeforeClass
@JvmStatic
fun setup() {
Security.removeProvider("BC")
Security.addProvider(BouncyCastleProvider())
SecurityUtils.setRegisterBouncyCastle(false)
val edGen = Ed25519KeyPairGenerator()
edGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
ed25519Pem = bcPem(edGen.generateKeyPair().private as Ed25519PrivateKeyParameters)
val rsaGen = RSAKeyPairGenerator()
rsaGen.init(RSAKeyGenerationParameters(BigInteger.valueOf(65537), SecureRandom(), 2048, 80))
rsaPem = bcPem(rsaGen.generateKeyPair().private as RSAPrivateCrtKeyParameters)
// ssh-keygen keys (saved to disk, reused across runs)
tmpDir.mkdirs()
sshKeygen = try { Runtime.getRuntime().exec(arrayOf("ssh-keygen", "--help")).waitFor(); true } catch (_: Exception) { false }
if (sshKeygen) {
opensshEd25519 = keygen("ed25519_v1", "-t", "ed25519")
opensshRsa = keygen("rsa_v1", "-t", "rsa", "-b", "2048")
opensshEcdsa256 = keygen("ecdsa256_v1", "-t", "ecdsa", "-b", "256")
legacyPemRsa = keygen("rsa_legacy", "-t", "rsa", "-b", "2048", "-m", "PEM")
legacyPemEcdsa = keygen("ecdsa_legacy", "-t", "ecdsa", "-b", "256", "-m", "PEM")
opensshEd25519Pass = keygen("ed25519_pass", "-t", "ed25519", passphrase = "test123")
opensshRsaPass = keygen("rsa_pass", "-t", "rsa", "-b", "2048", passphrase = "test123")
}
}
}
@Test
fun `pkcs8 Ed25519 loads with correct key type`() {
val kp = SSHKeyLoader.loadPkcs8(ed25519Pem)
assertEquals(KeyType.ED25519, kp.type)
assertNotNull(kp.private)
assertNotNull(kp.public)
}
@Test
fun `pkcs8 RSA-2048 loads with correct key type`() {
val kp = SSHKeyLoader.loadPkcs8(rsaPem)
assertEquals(KeyType.RSA, kp.type)
assertNotNull(kp.private)
assertNotNull(kp.public)
}
@Test
fun `openssh-v1 Ed25519 loads correctly`() {
assumeTrue("ssh-keygen required", opensshEd25519 != null)
val kp = SSHKeyLoader.load(opensshEd25519!!)
assertEquals(KeyType.ED25519, kp.type)
}
@Test
fun `openssh-v1 RSA loads correctly`() {
assumeTrue("ssh-keygen required", opensshRsa != null)
val kp = SSHKeyLoader.load(opensshRsa!!)
assertEquals(KeyType.RSA, kp.type)
}
@Test
fun `openssh-v1 ECDSA P-256 loads correctly`() {
assumeTrue("ssh-keygen required", opensshEcdsa256 != null)
val kp = SSHKeyLoader.load(opensshEcdsa256!!)
assertTrue("Expected ECDSA, got ${kp.type}", kp.type.toString().contains("ecdsa"))
}
@Test
fun `legacy PEM RSA loads correctly`() {
assumeTrue("ssh-keygen required", legacyPemRsa != null)
val kp = SSHKeyLoader.load(legacyPemRsa!!)
assertEquals(KeyType.RSA, kp.type)
}
@Test
fun `legacy PEM ECDSA loads correctly`() {
assumeTrue("ssh-keygen required", legacyPemEcdsa != null)
val kp = SSHKeyLoader.load(legacyPemEcdsa!!)
assertTrue("Expected ECDSA, got ${kp.type}", kp.type.toString().contains("ecdsa"))
}
@Test
fun `openssh-v1 Ed25519 with passphrase loads correctly`() {
assumeTrue("ssh-keygen required", opensshEd25519Pass != null)
val kp = SSHKeyLoader.load(opensshEd25519Pass!!, PasswordUtils.createOneOff("test123".toCharArray()))
assertEquals(KeyType.ED25519, kp.type)
}
@Test
fun `openssh-v1 RSA with passphrase loads correctly`() {
assumeTrue("ssh-keygen required", opensshRsaPass != null)
val kp = SSHKeyLoader.load(opensshRsaPass!!, PasswordUtils.createOneOff("test123".toCharArray()))
assertEquals(KeyType.RSA, kp.type)
}
}

View file

@ -1,61 +1,32 @@
package com.roundingmobile.terminalview
import android.view.MotionEvent
import com.roundingmobile.terminalview.engine.MouseEncoding
import com.roundingmobile.terminalview.engine.MouseMode
import com.roundingmobile.terminalview.engine.ScreenBuffer
/**
* Converts Android touch events into terminal mouse escape sequences.
* Sends terminal mouse escape sequences.
*
* Supports X10, Normal, Button-Event and Any-Event mouse modes with
* SGR, URXVT and legacy X10 coordinate encodings.
* Only tap (click) events are forwarded as mouse reports scroll, long-press,
* and double-tap always go to the gesture handler so scrollback and text
* selection work even when mouse mode is active.
*/
class MouseReporter(private val onTerminalInput: (ByteArray) -> Unit) {
private var mouseButtonDown = false
/**
* Convert touch event to terminal mouse escape sequence and send to host.
* Returns true if the event was consumed (mouse mode is active for this event type).
* Send a mouse click (press + release) at pixel coordinates.
* Used when mouse mode is active but only taps are forwarded as mouse events
* (scroll and long-press bypass mouse mode for scrollback and text selection).
*/
fun handleMouseEvent(event: MotionEvent, screen: ScreenBuffer, charWidth: Float, charHeight: Float): Boolean {
if (charWidth <= 0f || charHeight <= 0f) return false
// Convert pixel coordinates to terminal cell (1-based for protocol)
val col = (event.x / charWidth).toInt().coerceIn(0, screen.cols - 1) + 1
val row = (event.y / charHeight).toInt().coerceIn(0, screen.rows - 1) + 1
val mode = screen.mouseMode
fun sendClick(x: Float, y: Float, screen: ScreenBuffer, charWidth: Float, charHeight: Float) {
if (charWidth <= 0f || charHeight <= 0f) return
val col = (x / charWidth).toInt().coerceIn(0, screen.cols - 1) + 1
val row = (y / charHeight).toInt().coerceIn(0, screen.rows - 1) + 1
val encoding = screen.mouseEncoding
when (event.action) {
MotionEvent.ACTION_DOWN -> {
mouseButtonDown = true
sendMouseReport(0, col, row, pressed = true, encoding)
return true
}
MotionEvent.ACTION_UP -> {
mouseButtonDown = false
if (mode != MouseMode.X10) {
sendMouseReport(0, col, row, pressed = false, encoding)
}
return true
}
MotionEvent.ACTION_MOVE -> {
if (mode == MouseMode.BUTTON_EVENT && mouseButtonDown) {
sendMouseReport(32, col, row, pressed = true, encoding) // 32 = motion flag
return true
}
if (mode == MouseMode.ANY_EVENT) {
val btn = if (mouseButtonDown) 32 else 35 // 35 = no button + motion
sendMouseReport(btn, col, row, pressed = true, encoding)
return true
}
return false
}
sendMouseReport(0, col, row, pressed = true, encoding)
if (screen.mouseMode != MouseMode.X10) {
sendMouseReport(0, col, row, pressed = false, encoding)
}
return false
}
/**

View file

@ -156,20 +156,6 @@ class TerminalGestureHandler(
}
})
/** Feed the scale detector only — used when mouse mode intercepts single-finger events. */
fun feedScaleDetector(event: MotionEvent) {
scaleGestureDetector.onTouchEvent(event)
// Commit pinch when last finger lifts (mirrors the check in onTouchEvent)
if (isPinching && event.actionMasked == MotionEvent.ACTION_UP) {
val finalSize = (fontSizeAtPinchStart * pinchScale).coerceIn(minFontSizeSp, maxFontSizeSp)
pinchScale = 1.0f
isPinching = false
listener.onZoomEnd(finalSize)
listener.onRequestRender()
pinchConsumed = true
}
}
fun onTouchEvent(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)

View file

@ -188,8 +188,15 @@ class TerminalSurfaceView @JvmOverloads constructor(
clearSelection()
return
}
// Check if tap is on a URL
// Mouse mode: send click to host (scroll/long-press/double-tap bypass mouse mode)
val screen = screenBuffer
if (mouseReportingEnabled && screen != null && screen.mouseMode != MouseMode.NONE) {
mouseReporter.sendClick(x, y, screen, renderer.charWidth, renderer.charHeight)
screenBuffer?.scrollToBottom()
requestRender()
return
}
// Check if tap is on a URL
if (screen != null && renderer.charHeight > 0f && renderer.charWidth > 0f) {
val viewportRow = (y / renderer.charHeight).toInt().coerceIn(0, screen.rows - 1)
val col = (x / renderer.charWidth).toInt().coerceIn(0, screen.cols - 1)
@ -778,18 +785,6 @@ class TerminalSurfaceView @JvmOverloads constructor(
return true
}
// Always feed scale detector so pinch-zoom works even in mouse mode
gestureHandler.feedScaleDetector(event)
// Mouse reporting: when active, intercept single-finger touch and send escape sequences to host.
// Multi-finger gestures (pinch-zoom) bypass mouse reporting.
val screen = screenBuffer
if (mouseReportingEnabled && screen != null && screen.mouseMode != MouseMode.NONE) {
if (!gestureHandler.isPinching && event.pointerCount == 1) {
if (mouseReporter.handleMouseEvent(event, screen, renderer.charWidth, renderer.charHeight)) return true
}
}
// Release edge effects on touch down
if (event.action == MotionEvent.ACTION_DOWN) {
edgeEffectTop.onRelease()