From 245506c451b1b587d1af5185bb1d32893d0fc727 Mon Sep 17 00:00:00 2001 From: Sam Breed Date: Wed, 4 Nov 2026 22:50:25 +0000 Subject: [PATCH 01/27] fix(android): improve pairing and reconnect feedback Agent-Id: agent-8b9aee76-7bdc-4d92-9460-8a4b8413cf00 --- .../dev/intent/app/smoke/CoreFlowsTest.kt | 26 ++++- .../app/connection/ConnectionController.kt | 23 +++- .../connection/ConnectionRecoveryBanner.kt | 36 ++++++ .../intent/app/connection/ConnectionRoot.kt | 14 ++- .../intent/app/connection/ConnectionScreen.kt | 8 +- .../app/connection/ManualPairingErrors.kt | 11 +- .../app/connection/PairingCertificateProbe.kt | 29 +++++ .../app/connection/PairingScannerActivity.kt | 6 + .../intent/app/connection/PairingScreen.kt | 104 +++++++++++------- app/src/main/res/layout/pairing_scanner.xml | 3 +- .../connection/ConnectionControllerTest.kt | 37 ++++++- .../app/connection/ManualPairingErrorsTest.kt | 14 +++ .../connection/PairingCertificateProbeTest.kt | 86 +++++++++++++++ .../pairing/ManualConnectionTarget.kt | 22 ++++ .../core/protocol/pairing/PairingPayload.kt | 26 +++-- .../pairing/ManualConnectionTargetTest.kt | 54 +++++++++ 16 files changed, 430 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt create mode 100644 app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt create mode 100644 app/src/test/java/dev/intent/app/connection/PairingCertificateProbeTest.kt create mode 100644 core/protocol/src/main/kotlin/dev/intent/core/protocol/pairing/ManualConnectionTarget.kt create mode 100644 core/protocol/src/test/kotlin/dev/intent/core/protocol/pairing/ManualConnectionTargetTest.kt diff --git a/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt index 5e4145e..719c540 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.comments.CommentScreen @@ -63,24 +64,30 @@ class CoreFlowsTest { fun manualPairingConnectsToWorkspaceList() { val repository = MemoryConnections() lateinit var server: SmokeServer + lateinit var connectionState: (SessionState) -> Unit ConnectionController(repository, { saved, callback -> + connectionState = callback createConnectionSession(saved, callback, transport = { listener -> SmokeServer(listener).also { server = it } }) }).use { controller -> content { ConnectionRoot(controller, {}) } compose.onNodeWithText("Scan QR code").assertIsDisplayed() + screenshots("pairing-landing") compose.onNodeWithText("Enter connection details").performClick() compose.onNodeWithText("Review connection").assertIsNotEnabled() - compose.onNodeWithText("Host").performTextInput("desktop.test") - compose.onNodeWithText("Port").performTextInput("443") - compose.onNodeWithText("Pairing token").performTextInput("smoke-token") + compose.onNodeWithText("Host / Tailcat address").performTextInput("desktop.test") + compose.onNodeWithText("Port").performTextReplacement("443") + compose.onNodeWithText("Pairing token").performScrollTo().performTextInput("smoke-token") + compose.onNodeWithText("Show token").performClick() + compose.onNodeWithText("Hide token").assertIsDisplayed().performClick() compose.onNodeWithText("Certificate fingerprint (optional)") .performScrollTo().performTextInput("AB".repeat(32)) screenshots("pairing") compose.onNodeWithText("Review connection").performScrollTo().performClick() compose.waitUntil(TIMEOUT) { controller.state.value.pending != null } compose.onNodeWithText("Trust and connect?").assertIsDisplayed() + screenshots("pairing-trust") assertTrue(repository.list().isEmpty()) compose.onNodeWithText("Trust and connect").performClick() compose.waitUntil(TIMEOUT) { controller.state.value.session != null } @@ -97,6 +104,19 @@ class CoreFlowsTest { } ) screenshots("paired-workspaces") + val admitted = controller.state.value.session + connectionState(SessionState.DISCONNECTED) + compose.waitUntil(TIMEOUT) { controller.state.value.status == "Connection lost. Retrying." } + compose.onNodeWithText("Reconnecting…").assertIsDisplayed() + compose.onNodeWithText("Android smoke workspace").assertIsDisplayed() + screenshots("connection-reconnecting") + compose.onNodeWithText("Saved connections").performClick() + compose.onNodeWithText("Return to workspace").performClick() + compose.onNodeWithText("Android smoke workspace").assertIsDisplayed() + assertEquals(admitted, controller.state.value.session) + connectionState(SessionState.READY) + compose.waitUntil(TIMEOUT) { controller.state.value.status == "Connected" } + compose.onNodeWithText("Reconnecting…").assertDoesNotExist() } } diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionController.kt b/app/src/main/java/dev/intent/app/connection/ConnectionController.kt index 60af8cf..8ff2a1e 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionController.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionController.kt @@ -5,6 +5,9 @@ import dev.intent.core.protocol.pairing.ManualPairingInput import dev.intent.core.protocol.pairing.PairingPayload import dev.intent.core.protocol.session.RpcSession import dev.intent.core.protocol.session.SessionState +import dev.intent.tailcat.Tailcat +import dev.intent.tailcat.TunnelClientFactory +import dev.intent.tailcat.TunnelUnavailableException import java.io.Closeable import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -30,6 +33,7 @@ data class ConnectionState( class ConnectionController( private val repository: ConnectionRepository, private val sessionFactory: ((SavedConnection, (SessionState) -> Unit) -> RpcSession)? = null, + private val tunnels: TunnelClientFactory = Tailcat, private val probe: ( String, Int @@ -45,6 +49,7 @@ class ConnectionController( private var selectedPath: ConnectionPath? = null private var namedAdmission = -1L val diagnostics = ConnectionDiagnostics() + val tunnelAvailable: Boolean get() = tunnels.available fun restore() = submit { val rows = repository.list() @@ -60,7 +65,7 @@ class ConnectionController( private fun prepare(payload: PairingPayload) { mutableState.value = state.value.copy(pending = null, busy = true, error = null) - require(payload.hosts.isNotEmpty()) { "A direct host is required" } + checkTunnelSupport(payload) val fingerprint = payload.certFingerprint?.let(CertificateFingerprint::normalize) mutableState.value = state.value.copy( pending = payload.copy(certFingerprint = fingerprint), @@ -71,7 +76,7 @@ class ConnectionController( fun probeCertificate() = submit { val payload = state.value.pending ?: return@submit mutableState.value = state.value.copy(busy = true) - val fingerprint = probe(payload.hosts.first().removeSurrounding("[", "]"), payload.port) + val fingerprint = probePairingCertificate(payload, tunnels, probe) mutableState.value = state.value.copy( pending = payload.copy(certFingerprint = fingerprint), busy = false @@ -107,6 +112,7 @@ class ConnectionController( } private fun activate(connection: SavedConnection) { + checkTunnelSupport(connection.payload) repository.select(connection.id) stopSession() val ticket = generation @@ -124,6 +130,7 @@ class ConnectionController( repository.list().firstOrNull { it.id == connection.id }?.payload ?: connection.payload }, + tunnels = tunnels, onRoute = { path -> submit { if (ticket == generation) selectedPath = path } } ) current?.connect() @@ -231,17 +238,25 @@ class ConnectionController( ) } + private fun checkTunnelSupport(payload: PairingPayload) { + if (payload.hosts.isEmpty() && !tunnels.available) throw TunnelUnavailableException() + } + @Synchronized private fun submit(action: () -> Unit) { if (closed.get()) return executor.execute { try { action() - } catch (_: Exception) { + } catch (error: Exception) { mutableState.value = state.value.copy( busy = false, pending = null, - error = "Cannot complete this action. Check the connection details." + error = if (error is TunnelUnavailableException) { + TUNNEL_UNAVAILABLE_MESSAGE + } else { + "Cannot complete this action. Check the connection details." + } ) } } diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt b/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt new file mode 100644 index 0000000..0427002 --- /dev/null +++ b/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt @@ -0,0 +1,36 @@ +package dev.intent.app.connection + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentTextButton + +internal val ConnectionState.isRecovering: Boolean + get() = session != null && status != "Connected" + +@Composable +internal fun ConnectionRecoveryBanner(state: ConnectionState, onConnections: () -> Unit) { + if (!state.isRecovering) return + Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Column( + Modifier.fillMaxWidth().padding(IntentScreen.itemSpacing) + .semantics { liveRegion = LiveRegionMode.Polite } + ) { + Text("Reconnecting…", style = MaterialTheme.typography.titleSmall) + Text( + "Connection lost. Your screen is kept while we retry. Check your network and desktop.", + style = MaterialTheme.typography.bodySmall + ) + IntentTextButton(onClick = onConnections) { Text("Saved connections") } + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt b/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt index 13606fa..b4e429f 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt @@ -127,12 +127,13 @@ fun ConnectionRoot( state.busy, onScan, controller::prepareManual, - Modifier.weight(1f) + Modifier.weight(1f), + tunnelAvailable = controller.tunnelAvailable ) utility == "Connections" || state.session == null -> ConnectionScreen( state, onSelect = { - controller.select(it) + if (it != state.activeId || state.session == null) controller.select(it) utilities = emptyList() }, onForget = controller::forget, @@ -141,7 +142,12 @@ fun ConnectionRoot( ) else -> key(state.session, state.cacheScope) { contentState.SaveableStateProvider(state.cacheScope ?: "connected") { - ConnectedContent(state, Modifier.weight(1f), notificationWorkspace) + Column(Modifier.weight(1f)) { + ConnectionRecoveryBanner(state) { + utilities = utilities.openUtility("Connections") + } + ConnectedContent(state, Modifier.weight(1f), notificationWorkspace) + } } } } @@ -151,7 +157,7 @@ fun ConnectionRoot( PairingConfirmation(payload, state.busy, controller::probeCertificate, { controller.confirmPairing() utilities = emptyList() - }, controller::cancelPairing) + }, controller::cancelPairing, controller.tunnelAvailable) } } diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt b/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt index 6e4810c..8f33059 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt @@ -107,7 +107,13 @@ private fun SavedConnectionSection( ConnectionPathBadge(state, connection.id) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { TextButton(onClick = { onSelect(connection.id) }, enabled = !state.busy) { - Text(if (state.activeId == connection.id) "Reconnect" else "Connect") + Text( + when { + state.activeId == connection.id && state.session != null -> "Return to workspace" + state.activeId == connection.id -> "Reconnect" + else -> "Connect" + } + ) } Box { TextButton(onClick = { menuOpen = true }) { Text("Manage") } diff --git a/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt b/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt index 95f7c8d..f445bff 100644 --- a/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt +++ b/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt @@ -12,13 +12,20 @@ internal data class ManualPairingErrors( val isValid: Boolean get() = listOf(host, port, token, fingerprint).all { it == null } } -internal fun manualPairingErrors(input: ManualPairingInput): ManualPairingErrors = +internal fun manualPairingErrors( + input: ManualPairingInput, + tunnelAvailable: Boolean = true +): ManualPairingErrors = ManualPairingErrors( host = if (runCatching { input.copy(port = "443", token = "validation").toPayload() }.isFailure ) { - "Enter a host name or IP address." + "Enter a host name, IP address, or Tailcat address." + } else if (!tunnelAvailable && + input.copy(port = "443", token = "validation").toPayload().hosts.isEmpty() + ) { + TUNNEL_UNAVAILABLE_MESSAGE } else { null }, diff --git a/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt b/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt new file mode 100644 index 0000000..cb61794 --- /dev/null +++ b/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt @@ -0,0 +1,29 @@ +package dev.intent.app.connection + +import dev.intent.core.protocol.pairing.PairingPayload +import dev.intent.tailcat.TunnelClientFactory +import dev.intent.tailcat.TunnelUnavailableException + +private const val PING_TIMEOUT_MILLIS = 5000 +private const val MAX_PORT = 65535 + +internal const val TUNNEL_UNAVAILABLE_MESSAGE = + "Tailcat is unavailable in this build. Use a host name or IP address from your desktop instead." + +/** Fetches only the certificate: no bearer credentials are sent before the user confirms trust. */ +internal fun probePairingCertificate( + payload: PairingPayload, + tunnels: TunnelClientFactory, + probe: (String, Int) -> String +): String { + val host = payload.hosts.firstOrNull() + if (host != null) return probe(host.removeSurrounding("[", "]"), payload.port) + if (!tunnels.available) throw TunnelUnavailableException() + return tunnels.createClient(requireNotNull(payload.tcAddress)).use { client -> + client.ping(PING_TIMEOUT_MILLIS) + client.forward(payload.port).use { forwarder -> + require(forwarder.localPort in 1..MAX_PORT) + probe("127.0.0.1", forwarder.localPort) + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt b/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt index 7f60be2..8e87935 100644 --- a/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt +++ b/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt @@ -15,4 +15,10 @@ class PairingScannerActivity : CaptureActivity() { } return findViewById(R.id.zxing_barcode_scanner) } + + override fun onResume() { + super.onResume() + findViewById(R.id.zxing_barcode_scanner) + .setStatusText("On your desktop, open Settings → Mobile. Point the camera at the QR code.") + } } diff --git a/app/src/main/java/dev/intent/app/connection/PairingScreen.kt b/app/src/main/java/dev/intent/app/connection/PairingScreen.kt index 9bf2f36..2512e11 100644 --- a/app/src/main/java/dev/intent/app/connection/PairingScreen.kt +++ b/app/src/main/java/dev/intent/app/connection/PairingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import dev.intent.app.design.IntentButton as Button import dev.intent.app.design.IntentScreen @@ -28,13 +29,15 @@ import dev.intent.app.design.IntentTextField as OutlinedTextField import dev.intent.app.design.intentPageInsets import dev.intent.core.protocol.pairing.ManualPairingInput import dev.intent.core.protocol.pairing.PairingPayload +import dev.intent.tailcat.Tailcat @Composable fun PairingScreen( busy: Boolean, onScan: () -> Unit, onManual: (ManualPairingInput) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + tunnelAvailable: Boolean = Tailcat.available ) { var manual by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = manual && !busy) { manual = false } @@ -46,79 +49,64 @@ fun PairingScreen( TextButton(onClick = { manual = false }, enabled = !busy) { Text("Back to pairing options") } - ManualPairingForm(busy, onManual) + ManualPairingForm(busy, onManual, tunnelAvailable) } else { Text("Connect to Intent", style = MaterialTheme.typography.headlineSmall) Text( "On your desktop, open Settings → Mobile to get the pairing code and connection details.", color = IntentScreen.secondaryText ) - IntentSection("Scan a QR code") { - Text("Use your camera to scan the code on your desktop.") - Button(onClick = onScan, enabled = !busy, modifier = Modifier.fillMaxWidth()) { - Text("Scan QR code") - } - } - IntentSection("Manual connection") { - Text("Enter the host, port, and pairing token from your desktop.") - TextButton( - onClick = { manual = true }, - enabled = !busy, - modifier = Modifier.fillMaxWidth() - ) { Text("Enter connection details") } + Button(onClick = onScan, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text("Scan QR code") } + TextButton( + onClick = { manual = true }, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) { Text("Enter connection details") } Text("The Android companion is under development.", color = IntentScreen.secondaryText) } } } @Composable -private fun ManualPairingForm(busy: Boolean, onManual: (ManualPairingInput) -> Unit) { +private fun ManualPairingForm( + busy: Boolean, + onManual: (ManualPairingInput) -> Unit, + tunnelAvailable: Boolean +) { var host by rememberSaveable { mutableStateOf("") } - var port by rememberSaveable { mutableStateOf("") } + var port by rememberSaveable { mutableStateOf("5181") } var token by remember { mutableStateOf("") } var fingerprint by rememberSaveable { mutableStateOf("") } val input = ManualPairingInput(host, port, token, fingerprint) - val errors = manualPairingErrors(input) + val errors = manualPairingErrors(input, tunnelAvailable) IntentSection("Manual connection") { OutlinedTextField( host, { host = it }, - label = { Text("Host") }, + label = { Text("Host / Tailcat address") }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !busy, isError = host.isNotEmpty() && errors.host != null, supportingText = { - Text(errors.host.takeIf { host.isNotEmpty() } ?: "Host name or IP address") + Text(errors.host.takeIf { host.isNotEmpty() } ?: "Host name, IP, or Tailcat address") } ) + if (!tunnelAvailable) { + Text(TUNNEL_UNAVAILABLE_MESSAGE, color = IntentScreen.secondaryText) + } OutlinedTextField( port, { port = it }, label = { Text("Port") }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !busy, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = port.isNotEmpty() && errors.port != null, supportingText = { - Text(errors.port.takeIf { port.isNotEmpty() } ?: "Port from your desktop") - } - ) - OutlinedTextField( - token, { token = it }, label = { Text("Pairing token") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), enabled = !busy, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - isError = token.isNotEmpty() && errors.token != null, - supportingText = { - Text( - if (token.isNotEmpty()) { - errors.token - ?: "Token from your desktop" - } else { - "Token from your desktop" - } - ) + Text(errors.port.takeIf { port.isNotEmpty() } ?: "Default 5181; use the port on your desktop") } ) + PairingTokenField(token, { token = it }, busy, errors.token) FingerprintField(fingerprint, { fingerprint = it }, busy, errors.fingerprint) Button( onClick = { onManual(input) }, @@ -128,6 +116,28 @@ private fun ManualPairingForm(busy: Boolean, onManual: (ManualPairingInput) -> U } } +@Composable +private fun PairingTokenField(value: String, onChange: (String) -> Unit, busy: Boolean, error: String?) { + var visible by remember { mutableStateOf(false) } + OutlinedTextField( + value, + onChange, + label = { Text("Pairing token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + TextButton(onClick = { visible = !visible }, enabled = !busy) { + Text(if (visible) "Hide token" else "Show token") + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = value.isNotEmpty() && error != null, + supportingText = { Text(error.takeIf { value.isNotEmpty() } ?: "Token from your desktop") } + ) +} + @Composable private fun FingerprintField( value: String, @@ -156,14 +166,24 @@ fun PairingConfirmation( busy: Boolean, onProbe: () -> Unit, onConfirm: () -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, + tunnelAvailable: Boolean = Tailcat.available ) { AlertDialog( onDismissRequest = onCancel, title = { Text("Trust and connect?") }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { - Text("Server: ${payload.hosts.joinToString()}:${payload.port}") + payload.hosts.forEach { host -> + Text("Direct: ${connectionEndpoint(host, payload.port).removeSuffix("/ws")}") + } + payload.tcAddress?.let { address -> + Text("Tailcat: $address") + Text("Port: ${payload.port}") + if (!tunnelAvailable) { + Text("Tailcat is unavailable in this build. Only direct routes will be used.") + } + } Text("Compare this certificate fingerprint with the fingerprint on your desktop.") Text( payload.certFingerprint ?: "Fetch the certificate before you trust this server." @@ -176,7 +196,9 @@ fun PairingConfirmation( enabled = !busy ) { Text( - if (payload.certFingerprint == + if (busy) { + "Checking certificate…" + } else if (payload.certFingerprint == null ) { "Fetch certificate" diff --git a/app/src/main/res/layout/pairing_scanner.xml b/app/src/main/res/layout/pairing_scanner.xml index 81b2430..ea6d66b 100644 --- a/app/src/main/res/layout/pairing_scanner.xml +++ b/app/src/main/res/layout/pairing_scanner.xml @@ -8,8 +8,9 @@