From ea055609b9bb98bc1e042277c4578a4308bfeece Mon Sep 17 00:00:00 2001 From: Ross Tomsic Date: Sun, 27 Sep 2026 20:08:14 -0400 Subject: [PATCH 1/2] Connect saved hosts directly from Home --- PROJECT_SPEC.md | 10 +- .../threadline/ConnectionFormRetentionTest.kt | 10 +- .../threadline/HomeDirectConnectionTest.kt | 485 +++++++++++++++ .../EncryptedImportedPrivateKeyStoreTest.kt | 37 ++ .../java/dev/threadline/IdentityManagement.kt | 2 + .../main/java/dev/threadline/MainActivity.kt | 571 ++++++++++++------ .../key/EncryptedImportedPrivateKeyStore.kt | 1 + 7 files changed, 926 insertions(+), 190 deletions(-) create mode 100644 app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 55de1bd..26e316d 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -93,7 +93,9 @@ and it can be reopened from Help. It is not an account setup flow or a multi-pag After the first-run introduction, the user enters the focused connection editor directly. On later launches with no active session, Home shows saved connections and a primary action for starting a -new connection. Selecting either opens the focused connection editor; users do not cross transcript +new connection. Tapping a saved host with a ready preferred identity starts a connection; a saved +password adds device approval. Missing credentials open focused entry, while Edit and Change +identity are separate actions. New connection opens the editor. Users do not cross transcript history or trust-management content to reach credentials and the connect action. Only the standard SSH port `22` is prefilled for a new connection; development-fixture values are never production defaults. @@ -123,9 +125,11 @@ A saved host profile contains: - Hostname or IP address - Port, default `22` - Username +- Preferred SSH identity, if one is chosen -Authentication method is selected for each connection and is not linked to the -saved profile. Credentials are never part of a host profile. +An identity supplies the authentication method for a connection. Choosing a different identity +for one attempt does not change the saved preference unless the profile is updated explicitly. +Credentials are never part of a host profile. Supported authentication for MVP: diff --git a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt index bb36b71..4d3b986 100644 --- a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt +++ b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt @@ -121,7 +121,7 @@ class ConnectionFormRetentionTest { } @Test - fun savedConnectionAndNewConnectionLeadDirectlyToFocusedEditorAtLargeFontScale() { + fun savedProfileEditAndNewConnectionLeadToFocusedEditorAtLargeFontScale() { val profile = SavedHostProfile( id = "saved-profile", displayName = "Barnabas", @@ -155,7 +155,7 @@ class ConnectionFormRetentionTest { } } - compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + compose.onNodeWithTag(ConnectionFormTags.EDIT_PROFILE_PREFIX + profile.id) .performScrollTo() .assertIsDisplayed() .performClick() @@ -795,7 +795,7 @@ class ConnectionFormRetentionTest { compose.onNodeWithTag(ConnectionFormTags.BACK_HOME) .performScrollTo() .performClick() - compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + original.id) + compose.onNodeWithTag(ConnectionFormTags.EDIT_PROFILE_PREFIX + original.id) .performScrollTo() .performClick() compose.onNodeWithTag(ConnectionFormTags.DISPLAY_NAME) @@ -833,7 +833,7 @@ class ConnectionFormRetentionTest { compose.waitForIdle() assertEquals(original.id, deletedId) - compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + original.id) + compose.onNodeWithTag(ConnectionFormTags.EDIT_PROFILE_PREFIX + original.id) .assertDoesNotExist() compose.onNodeWithTag(ConnectionFormTags.NEW_CONNECTION).performClick() compose.onNodeWithTag(ConnectionFormTags.PASSWORD) @@ -893,7 +893,7 @@ class ConnectionFormRetentionTest { } } - compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + compose.onNodeWithTag(ConnectionFormTags.EDIT_PROFILE_PREFIX + profile.id) .performClick() compose.onNodeWithTag(ConnectionFormTags.USERNAME) .assertEditableTextEquals("operator") diff --git a/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt b/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt new file mode 100644 index 0000000..2ab9df6 --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt @@ -0,0 +1,485 @@ +package dev.threadline + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +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.unit.Density +import dev.threadline.core.model.ConnectionRequest +import dev.threadline.core.model.SessionCredential +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity +import dev.threadline.data.key.ImportedPrivateKeyMetadata +import dev.threadline.data.key.InvalidImportedPrivateKeyException +import dev.threadline.data.key.SavedSshPasswordAuthenticationCancelledException +import dev.threadline.data.profile.SavedHostProfile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class HomeDirectConnectionTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun readySavedKeyPreparesFromOneHomeTapAtLargeFontScale() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val identity = identity(IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY) + var prepared: ConnectionRequest? = null + var loadCount = 0 + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults().copy(ephemeral = true)) + + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(density.density, fontScale = 2f), + ) { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + sshIdentities = listOf(identity), + importedPrivateKeys = listOf(savedKey()), + onLoadPrivateKey = { id, passphrase -> + assertEquals(KEY_ID, id) + assertNull(passphrase) + loadCount += 1 + SessionCredential.PrivateKey.from(byteArrayOf(1, 2, 3), null) + }, + onPrepared = { + prepared = it + true + }, + ) + } + } + } + + compose.runOnIdle { assertNull(prepared) } + compose.onNodeWithText("operator@lab.example:22").assertExists() + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performScrollTo() + .assertIsDisplayed() + .performClick() + compose.waitForIdle() + + compose.runOnIdle { + assertEquals(1, loadCount) + assertEquals("operator", requireNotNull(prepared).profile.username) + assertEquals("lab.example", requireNotNull(prepared).profile.endpoint.hostname) + assertFalse(requireNotNull(prepared).ephemeral) + requireNotNull(prepared).credential.clear() + } + compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist() + } + + @Test + fun savedPasswordUnlocksFromHomeWithoutOpeningTheEditor() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + var unlockCount = 0 + var prepared: ConnectionRequest? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.emptyDefaults(), + onDraftChange = {}, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.PASSWORD, hasSavedPassword = true), + ), + savedPasswordSupported = true, + onLoadSavedSshPassword = { id -> + assertEquals(IDENTITY_ID, id) + unlockCount += 1 + SessionCredential.Password.from("fixture-password".toCharArray()) + }, + onPrepared = { + prepared = it + true + }, + ) + } + } + + compose.runOnIdle { assertNull(prepared) } + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.waitForIdle() + compose.runOnIdle { + assertEquals(1, unlockCount) + assertEquals("operator", requireNotNull(prepared).profile.username) + requireNotNull(prepared).credential.clear() + } + compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist() + } + + @Test + fun canceledSavedPasswordOpensManualEntryBeforeConnecting() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + var unlockCount = 0 + var prepared: ConnectionRequest? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.PASSWORD, hasSavedPassword = true), + ), + savedPasswordSupported = true, + onLoadSavedSshPassword = { + unlockCount += 1 + throw SavedSshPasswordAuthenticationCancelledException() + }, + onPrepared = { + prepared = it + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithText("Complete sign-in").assertExists() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertIsFocused() + compose.runOnIdle { + assertEquals(1, unlockCount) + assertNull(prepared) + } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performTextInput("fixture-password") + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitForIdle() + compose.runOnIdle { + assertEquals(1, unlockCount) + val credential = requireNotNull(prepared).credential as SessionCredential.Password + assertArrayEquals("fixture-password".toCharArray(), credential.characters) + credential.clear() + } + } + + @Test + fun encryptedKeyRequestsPassphraseBeforeStartingSsh() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + var prepared: ConnectionRequest? = null + var loadCount = 0 + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY), + ), + importedPrivateKeys = listOf(savedKey()), + onLoadPrivateKey = { _, passphrase -> + loadCount += 1 + if (passphrase == null) { + throw InvalidImportedPrivateKeyException( + IllegalArgumentException("fixture passphrase required"), + ) + } + assertArrayEquals("fixture-passphrase".toCharArray(), passphrase) + SessionCredential.PrivateKey.from(byteArrayOf(1, 2, 3), passphrase) + }, + onPrepared = { + prepared = it + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithText("Complete sign-in").assertExists() + compose.onNodeWithTag(ConnectionFormTags.KEY_PASSPHRASE).assertIsFocused() + compose.runOnIdle { + assertEquals(1, loadCount) + assertNull(prepared) + } + + compose.onNodeWithTag(ConnectionFormTags.KEY_PASSPHRASE) + .performTextInput("fixture-passphrase") + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitForIdle() + compose.runOnIdle { + assertEquals(2, loadCount) + assertEquals("operator", requireNotNull(prepared).profile.username) + requireNotNull(prepared).credential.clear() + } + } + + @Test + fun changeIdentityIsExplicitAndDoesNotUpdateTheSavedPreference() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val alternate = identity( + method = IdentityAuthenticationMethod.PASSWORD, + id = "alternate-id", + username = "alternate", + ) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + var prepared: ConnectionRequest? = null + var updateCount = 0 + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.UNCONFIGURED), + alternate, + ), + onUpdateHostProfile = { _, _, _ -> updateCount += 1 }, + onPrepared = { + prepared = it + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.CHANGE_IDENTITY_PREFIX + profile.id) + .performClick() + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsFocused() + compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE).assertDoesNotExist() + compose.runOnIdle { assertNull(prepared) } + + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).performClick() + compose.onNodeWithText("Alternate identity").performClick() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).performTextInput("one-time") + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitForIdle() + compose.runOnIdle { + assertEquals("alternate", requireNotNull(prepared).profile.username) + assertEquals(0, updateCount) + assertEquals(IDENTITY_ID, profile.preferredIdentityId) + requireNotNull(prepared).credential.clear() + } + } + + @Test + fun missingSavedKeyOpensRepairWithoutLoadingOrConnecting() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY), + ), + onLoadPrivateKey = { _, _ -> + error("A missing key must not be loaded.") + }, + onPrepared = { error("A missing key must not start SSH.") }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithText("Complete sign-in").assertExists() + compose.onNodeWithText("The saved private key is unavailable", substring = true) + .assertExists() + compose.onNodeWithTag(ConnectionFormTags.CHOOSE_PRIVATE_KEY) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun unconfiguredIdentityOpensTheIdentityPickerPath() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf(identity(IdentityAuthenticationMethod.UNCONFIGURED)), + onPrepared = { error("An unconfigured identity must not start SSH.") }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithText("Choose a configured SSH identity for this connection.") + .assertExists() + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsFocused() + compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE).assertDoesNotExist() + } + + @Test + fun unavailableSavedPasswordOpensManualEntryWithoutPrompting() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.PASSWORD, hasSavedPassword = true), + ), + savedPasswordSupported = false, + onLoadSavedSshPassword = { + error("Unsupported devices must not request saved-password approval.") + }, + onPrepared = { error("Manual entry is required first.") }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithText("The saved password is unavailable here", substring = true) + .assertExists() + compose.onNodeWithTag(ConnectionFormTags.PASSWORD).assertIsFocused() + } + + @Test + fun homeTapWaitsForRequiredNotificationPermissionBeforeLoadingCredentials() { + val profile = profile(preferredIdentityId = IDENTITY_ID) + val selectedProfileId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedProfileId.value, + onSelectedHostProfileChange = { selectedProfileId.value = it }, + sshIdentities = listOf( + identity(IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY), + ), + importedPrivateKeys = listOf(savedKey()), + notificationPermissionState = SessionNotificationPermissionState.REQUESTABLE, + onLoadPrivateKey = { _, _ -> + error("Credentials must wait for notification permission.") + }, + onPrepared = { error("SSH must wait for notification permission.") }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithTag(ConnectionFormTags.NOTIFICATION_PERMISSION) + .performScrollTo() + .assertIsDisplayed() + } + + private fun profile(preferredIdentityId: String) = SavedHostProfile( + id = "saved-host", + displayName = "Lab", + hostname = "lab.example", + port = 22, + username = "old-user", + createdAtMillis = 1, + updatedAtMillis = 1, + preferredIdentityId = preferredIdentityId, + ) + + private fun identity( + method: IdentityAuthenticationMethod, + id: String = IDENTITY_ID, + username: String = "operator", + hasSavedPassword: Boolean = false, + ) = SshIdentity( + id = id, + label = if (id == IDENTITY_ID) "Saved identity" else "Alternate identity", + username = username, + authenticationMethod = method, + importedPrivateKeyId = if (method == IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY) { + KEY_ID + } else { + null + }, + createdAtMillis = 1, + updatedAtMillis = 1, + hasSavedPassword = hasSavedPassword, + ) + + private fun savedKey() = ImportedPrivateKeyMetadata( + id = KEY_ID, + displayName = "Saved key", + format = "OpenSSH", + keyType = "ssh-rsa", + publicKeyFingerprint = "fixture fingerprint", + createdAtMillis = 1, + ) + + private companion object { + const val IDENTITY_ID = "saved-identity" + const val KEY_ID = "saved-key" + } +} diff --git a/app/src/androidTest/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStoreTest.kt b/app/src/androidTest/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStoreTest.kt index c030267..2513bfa 100644 --- a/app/src/androidTest/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStoreTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStoreTest.kt @@ -58,6 +58,43 @@ class EncryptedImportedPrivateKeyStoreTest { original.fill(0) } + @Test + fun encryptedKeyRequiresPassphraseBeforeAConnectionCredentialIsReturned() = runBlocking { + val store = EncryptedImportedPrivateKeyStore( + dao = FakeImportedPrivateKeyDao(), + cipher = RecordingPrivateKeyCipher(), + ioDispatcher = Dispatchers.Unconfined, + newId = { "encrypted-key" }, + ) + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(1024) } + .generateKeyPair() + val keyBytes = SshKeys.encodePemPrivateKey( + keyPair, + "fixture-passphrase", + ).encodeToByteArray() + try { + val metadata = store.save( + "Encrypted key", + keyBytes, + "fixture-passphrase".toCharArray(), + ) + + val missingPassphrase = runCatching { + store.credential(metadata.id, null) + }.exceptionOrNull() + assertTrue(missingPassphrase is InvalidImportedPrivateKeyException) + + val credential = store.credential( + metadata.id, + "fixture-passphrase".toCharArray(), + ) + assertArrayEquals(keyBytes, credential.keyBytes) + credential.clear() + } finally { + keyBytes.fill(0) + } + } + @Test fun renamePreservesCiphertextAndDeleteMakesCredentialUnavailable() = runBlocking { val dao = FakeImportedPrivateKeyDao() diff --git a/app/src/main/java/dev/threadline/IdentityManagement.kt b/app/src/main/java/dev/threadline/IdentityManagement.kt index c0fb1e4..8116b32 100644 --- a/app/src/main/java/dev/threadline/IdentityManagement.kt +++ b/app/src/main/java/dev/threadline/IdentityManagement.kt @@ -52,6 +52,7 @@ internal fun PreferredIdentitySelector( selectedIdentityId: String?, enabled: Boolean, onSelect: (SshIdentity?) -> Unit, + modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } val selectedIdentity = identities.firstOrNull { it.id == selectedIdentityId } @@ -64,6 +65,7 @@ internal fun PreferredIdentitySelector( enabled = enabled, modifier = Modifier .fillMaxWidth() + .then(modifier) .testTag(ConnectionFormTags.PREFERRED_IDENTITY), ) { Text( diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index 5cca617..c411cc4 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -99,6 +99,7 @@ import dev.threadline.data.host.KnownHostMetadata import dev.threadline.data.identity.IdentityAuthenticationMethod import dev.threadline.data.identity.SshIdentity import dev.threadline.data.key.ImportedPrivateKeyMetadata +import dev.threadline.data.key.InvalidImportedPrivateKeyException import dev.threadline.data.key.SavedSshPasswordPromptPurpose import dev.threadline.data.key.authenticateSavedSshPasswordUse import dev.threadline.data.key.supportsSavedSshPasswords @@ -249,6 +250,9 @@ internal object ConnectionFormTags { const val CONFIRM_RENAME_KEY = "connection-confirm-rename-key" const val CONFIRM_DELETE_KEY = "connection-confirm-delete-key" const val SAVED_PROFILE_PREFIX = "connection-saved-profile-" + const val EDIT_PROFILE_PREFIX = "connection-edit-profile-" + const val CHANGE_IDENTITY_PREFIX = "connection-change-identity-" + const val EDIT_DETAILS = "connection-edit-details" const val SAVE_PROFILE = "connection-save-profile" const val UPDATE_PROFILE = "connection-update-profile" const val USE_PROFILE_AS_NEW = "connection-use-profile-as-new" @@ -806,6 +810,8 @@ internal fun HostForm( var selectedKeyUri by rememberSaveable { mutableStateOf(null) } var selectedSavedKeyId by rememberSaveable { mutableStateOf(null) } var selectedPreferredIdentityId by rememberSaveable { mutableStateOf(null) } + var credentialOnly by rememberSaveable { mutableStateOf(false) } + var focusIdentityOnEntry by remember { mutableStateOf(false) } var savePrivateKey by rememberSaveable { mutableStateOf(false) } var formError by remember { mutableStateOf(null) } var validationError by remember { mutableStateOf(null) } @@ -825,9 +831,11 @@ internal fun HostForm( val portFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } + val identityFocusRequester = remember { FocusRequester() } val privateKeyBringIntoViewRequester = remember { BringIntoViewRequester() } val keyPassphraseFocusRequester = remember { FocusRequester() } val selectedHostProfile = hostProfiles.firstOrNull { it.id == selectedHostProfileId } + val isCredentialEntry = credentialOnly && selectedHostProfile != null val selectedPreferredIdentity = sshIdentities.firstOrNull { it.id == selectedPreferredIdentityId } @@ -848,6 +856,13 @@ internal fun HostForm( } } + LaunchedEffect(task, focusIdentityOnEntry) { + if (task == HomeTask.CONNECTION && focusIdentityOnEntry) { + identityFocusRequester.requestFocus() + focusIdentityOnEntry = false + } + } + fun clearSessionCredentialInputs() { passwordState = TextFieldState() keyPassphrase = "" @@ -888,12 +903,41 @@ internal fun HostForm( ConnectionValidationField.HOSTNAME -> hostnameFocusRequester.requestFocus() ConnectionValidationField.PORT -> portFocusRequester.requestFocus() ConnectionValidationField.USERNAME -> usernameFocusRequester.requestFocus() + ConnectionValidationField.IDENTITY -> identityFocusRequester.requestFocus() ConnectionValidationField.PASSWORD -> passwordFocusRequester.requestFocus() ConnectionValidationField.PRIVATE_KEY -> privateKeyBringIntoViewRequester.bringIntoView() + ConnectionValidationField.KEY_PASSPHRASE -> + keyPassphraseFocusRequester.requestFocus() } } + fun openProfile(profile: SavedHostProfile): SshIdentity? { + val identity = sshIdentities.firstOrNull { it.id == profile.preferredIdentityId } + onSelectedHostProfileChange(profile.id) + selectedPreferredIdentityId = identity?.id + onDraftChange( + draft.copy( + displayName = profile.displayName, + hostname = profile.hostname, + port = profile.port.toString(), + username = identity?.username ?: profile.username, + authenticationMode = identity?.authenticationMethod + ?.toAuthenticationMode() ?: draft.authenticationMode, + ), + ) + clearSessionCredentialInputs() + selectedSavedKeyId = identity?.importedPrivateKeyId + formError = null + return identity + } + + fun openCredentialEntry(field: ConnectionValidationField, message: String) { + credentialOnly = true + savedTask = HomeTask.CONNECTION.name + showValidationError(ConnectionValidationError(field, message)) + } + fun validationMessage(field: ConnectionValidationField): String? = validationError?.takeIf { it.field == field }?.message @@ -952,32 +996,130 @@ internal fun HostForm( HomeOverviewContent( activeSessionDisplayName = activeSessionDisplayName, hostProfiles = hostProfiles, + sshIdentities = sshIdentities, onReturnToActiveSession = onReturnToActiveSession, onDisconnectActiveSession = onDisconnectActiveSession, - onOpenProfile = { profile -> - onSelectedHostProfileChange(profile.id) - selectedPreferredIdentityId = profile.preferredIdentityId - val identity = sshIdentities.firstOrNull { - it.id == profile.preferredIdentityId + enabled = connectionEnabled && !isBusy, + onConnectProfile = { profile -> + val identity = openProfile(profile) + credentialOnly = false + when { + !notificationPermissionState.allowsCredentialEntry -> { + credentialOnly = true + savedTask = HomeTask.CONNECTION.name + } + + identity == null || + identity.authenticationMethod == + IdentityAuthenticationMethod.UNCONFIGURED -> + openCredentialEntry( + ConnectionValidationField.IDENTITY, + "Choose a configured SSH identity for this connection.", + ) + + identity.authenticationMethod == + IdentityAuthenticationMethod.PASSWORD && + (!identity.hasSavedPassword || !savedPasswordSupported) -> + openCredentialEntry( + ConnectionValidationField.PASSWORD, + if (identity.hasSavedPassword) { + "The saved password is unavailable here. Enter it " + + "for this connection." + } else { + "Enter the password for this connection." + }, + ) + + identity.authenticationMethod == + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY && + importedPrivateKeys.none { + it.id == identity.importedPrivateKeyId + } -> openCredentialEntry( + ConnectionValidationField.PRIVATE_KEY, + "The saved private key is unavailable. Choose another key " + + "or identity.", + ) + + else -> { + isPreparing = true + coroutineScope.launch { + var credential: SessionCredential? = null + var transferred = false + try { + credential = when (identity.authenticationMethod) { + IdentityAuthenticationMethod.PASSWORD -> + onLoadSavedSshPassword(identity.id) + + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY -> + onLoadPrivateKey( + requireNotNull( + identity.importedPrivateKeyId, + ), + null, + ) + + IdentityAuthenticationMethod.UNCONFIGURED -> + error("Choose a configured SSH identity.") + } + val request = ConnectionRequest( + profile = profile.toHostProfile().copy( + username = identity.username, + ), + credential = credential, + ephemeral = false, + ) + if (onPrepared(request)) { + transferred = true + } else { + credentialOnly = true + savedTask = HomeTask.CONNECTION.name + connectionPreparationError = + "Another SSH session is already active." + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + val field = when (identity.authenticationMethod) { + IdentityAuthenticationMethod.PASSWORD -> + ConnectionValidationField.PASSWORD + + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY -> + if (failure is InvalidImportedPrivateKeyException) { + ConnectionValidationField.KEY_PASSPHRASE + } else { + ConnectionValidationField.PRIVATE_KEY + } + + IdentityAuthenticationMethod.UNCONFIGURED -> + ConnectionValidationField.IDENTITY + } + openCredentialEntry( + field, + failure.message ?: "Enter credentials to connect.", + ) + } finally { + if (!transferred) credential?.clear() + isPreparing = false + } + } + } } - onDraftChange( - draft.copy( - displayName = profile.displayName, - hostname = profile.hostname, - port = profile.port.toString(), - username = identity?.username ?: profile.username, - authenticationMode = identity?.authenticationMethod - ?.toAuthenticationMode() ?: draft.authenticationMode, - ), - ) - clearSessionCredentialInputs() - selectedSavedKeyId = identity?.importedPrivateKeyId - formError = null + }, + onEditProfile = { profile -> + openProfile(profile) + credentialOnly = false + savedTask = HomeTask.CONNECTION.name + }, + onChangeIdentity = { profile -> + openProfile(profile) + credentialOnly = true savedTask = HomeTask.CONNECTION.name + focusIdentityOnEntry = true }, onNewConnection = { onSelectedHostProfileChange(null) selectedPreferredIdentityId = null + credentialOnly = false onDraftChange(ConnectionFormDraft.emptyDefaults()) clearSessionCredentialInputs() formError = null @@ -1054,15 +1196,29 @@ internal fun HostForm( }, ) Text( - "Connect to a server", + if (isCredentialEntry) "Complete sign-in" else "Connect to a server", style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }, ) Text( - "Threadline connects directly to the SSH endpoint you enter and verifies " + - "the server before signing in.", + if (isCredentialEntry) { + val profile = requireNotNull(selectedHostProfile) + "${selectedPreferredIdentity?.username ?: profile.username}@" + + "${profile.hostname}:${profile.port}" + } else { + "Threadline connects directly to the SSH endpoint you enter and verifies " + + "the server before signing in." + }, style = MaterialTheme.typography.bodyMedium, ) + if (isCredentialEntry) { + TextButton( + onClick = { credentialOnly = false }, + modifier = Modifier.testTag(ConnectionFormTags.EDIT_DETAILS), + ) { + Text("Edit host details") + } + } sessionError?.let { error -> ErrorCard( @@ -1104,87 +1260,91 @@ internal fun HostForm( } } - OutlinedTextField( - value = draft.displayName, - onValueChange = { - clearValidationError(ConnectionValidationField.DISPLAY_NAME) - onDraftChange(draft.copy(displayName = it)) - }, - label = { Text("Display name") }, - supportingText = validationMessage(ConnectionValidationField.DISPLAY_NAME)?.let { - { ConnectionValidationMessage(it) } - }, - isError = validationMessage(ConnectionValidationField.DISPLAY_NAME) != null, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .focusRequester(displayNameFocusRequester) - .testTag(ConnectionFormTags.DISPLAY_NAME), - ) - OutlinedTextField( - value = draft.hostname, - onValueChange = { - clearValidationError(ConnectionValidationField.HOSTNAME) - onDraftChange(draft.copy(hostname = it)) - }, - label = { Text("Hostname or IP") }, - supportingText = validationMessage(ConnectionValidationField.HOSTNAME)?.let { - { ConnectionValidationMessage(it) } - }, - isError = validationMessage(ConnectionValidationField.HOSTNAME) != null, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .focusRequester(hostnameFocusRequester) - .testTag(ConnectionFormTags.HOSTNAME), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { + if (!isCredentialEntry) { OutlinedTextField( - value = draft.port, + value = draft.displayName, onValueChange = { - clearValidationError(ConnectionValidationField.PORT) - onDraftChange(draft.copy(port = it.filter(Char::isDigit))) + clearValidationError(ConnectionValidationField.DISPLAY_NAME) + onDraftChange(draft.copy(displayName = it)) }, - label = { Text("Port") }, - supportingText = validationMessage(ConnectionValidationField.PORT)?.let { + label = { Text("Display name") }, + supportingText = validationMessage(ConnectionValidationField.DISPLAY_NAME)?.let { { ConnectionValidationMessage(it) } }, - isError = validationMessage(ConnectionValidationField.PORT) != null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = validationMessage(ConnectionValidationField.DISPLAY_NAME) != null, singleLine = true, modifier = Modifier - .weight(0.35f) - .focusRequester(portFocusRequester) - .testTag(ConnectionFormTags.PORT), + .fillMaxWidth() + .focusRequester(displayNameFocusRequester) + .testTag(ConnectionFormTags.DISPLAY_NAME), ) OutlinedTextField( - value = draft.username, + value = draft.hostname, onValueChange = { - clearValidationError(ConnectionValidationField.USERNAME) - selectedPreferredIdentityId = null - onDraftChange(draft.copy(username = it)) + clearValidationError(ConnectionValidationField.HOSTNAME) + onDraftChange(draft.copy(hostname = it)) }, - label = { Text("Username") }, - supportingText = validationMessage(ConnectionValidationField.USERNAME)?.let { + label = { Text("Hostname or IP") }, + supportingText = validationMessage(ConnectionValidationField.HOSTNAME)?.let { { ConnectionValidationMessage(it) } }, - isError = validationMessage(ConnectionValidationField.USERNAME) != null, + isError = validationMessage(ConnectionValidationField.HOSTNAME) != null, singleLine = true, modifier = Modifier - .weight(0.65f) - .focusRequester(usernameFocusRequester) - .testTag(ConnectionFormTags.USERNAME), + .fillMaxWidth() + .focusRequester(hostnameFocusRequester) + .testTag(ConnectionFormTags.HOSTNAME), ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = draft.port, + onValueChange = { + clearValidationError(ConnectionValidationField.PORT) + onDraftChange(draft.copy(port = it.filter(Char::isDigit))) + }, + label = { Text("Port") }, + supportingText = validationMessage(ConnectionValidationField.PORT)?.let { + { ConnectionValidationMessage(it) } + }, + isError = validationMessage(ConnectionValidationField.PORT) != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier + .weight(0.35f) + .focusRequester(portFocusRequester) + .testTag(ConnectionFormTags.PORT), + ) + OutlinedTextField( + value = draft.username, + onValueChange = { + clearValidationError(ConnectionValidationField.USERNAME) + selectedPreferredIdentityId = null + onDraftChange(draft.copy(username = it)) + }, + label = { Text("Username") }, + supportingText = validationMessage(ConnectionValidationField.USERNAME)?.let { + { ConnectionValidationMessage(it) } + }, + isError = validationMessage(ConnectionValidationField.USERNAME) != null, + singleLine = true, + modifier = Modifier + .weight(0.65f) + .focusRequester(usernameFocusRequester) + .testTag(ConnectionFormTags.USERNAME), + ) + } } PreferredIdentitySelector( identities = sshIdentities, selectedIdentityId = selectedPreferredIdentityId, enabled = !isBusy, + modifier = Modifier.focusRequester(identityFocusRequester), onSelect = { identity -> + clearValidationError(ConnectionValidationField.IDENTITY) selectedPreferredIdentityId = identity?.id if (identity != null) { clearSessionCredentialInputs() @@ -1199,101 +1359,106 @@ internal fun HostForm( } }, ) - - Button( - onClick = { - if (isBusy) return@Button - val invalidField = draft.validationErrorOrNull() - if (invalidField != null) { - showValidationError(invalidField) - return@Button - } - val profile = draft.toHostProfile() - formError = null - isManagingProfile = true - coroutineScope.launch { - try { - val selectedId = selectedHostProfile?.id - val preferredIdentityId = selectedPreferredIdentityId - ?.takeIf { id -> sshIdentities.any { it.id == id } } - ?: onCreateDefaultSshIdentity( - profile, - draft.authenticationMode, - selectedSavedKeyId.takeIf { - draft.authenticationMode == AuthenticationMode.PRIVATE_KEY - }, - ).id - if (selectedId == null) { - val saved = onSaveHostProfile( - profile, - preferredIdentityId, - ) - onSelectedHostProfileChange(saved.id) - } else { - onUpdateHostProfile( - selectedId, - profile, - preferredIdentityId, - ) - } - selectedPreferredIdentityId = preferredIdentityId - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - formError = failure.message - ?: "The host profile could not be saved." - } finally { - isManagingProfile = false - } - } - }, - enabled = !isBusy, - modifier = Modifier - .fillMaxWidth() - .testTag( - if (selectedHostProfile == null) { - ConnectionFormTags.SAVE_PROFILE - } else { - ConnectionFormTags.UPDATE_PROFILE - }, - ), - ) { - Text(if (selectedHostProfile == null) "Save profile" else "Update profile") + validationMessage(ConnectionValidationField.IDENTITY)?.let { + ConnectionValidationMessage(it) } - selectedHostProfile?.let { profile -> - OutlinedButton( + + if (!isCredentialEntry) { + Button( onClick = { - onSelectedHostProfileChange(null) - selectedPreferredIdentityId = null - clearSessionCredentialInputs() + if (isBusy) return@Button + val invalidField = draft.validationErrorOrNull() + if (invalidField != null) { + showValidationError(invalidField) + return@Button + } + val profile = draft.toHostProfile() formError = null + isManagingProfile = true + coroutineScope.launch { + try { + val selectedId = selectedHostProfile?.id + val preferredIdentityId = selectedPreferredIdentityId + ?.takeIf { id -> sshIdentities.any { it.id == id } } + ?: onCreateDefaultSshIdentity( + profile, + draft.authenticationMode, + selectedSavedKeyId.takeIf { + draft.authenticationMode == AuthenticationMode.PRIVATE_KEY + }, + ).id + if (selectedId == null) { + val saved = onSaveHostProfile( + profile, + preferredIdentityId, + ) + onSelectedHostProfileChange(saved.id) + } else { + onUpdateHostProfile( + selectedId, + profile, + preferredIdentityId, + ) + } + selectedPreferredIdentityId = preferredIdentityId + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + formError = failure.message + ?: "The host profile could not be saved." + } finally { + isManagingProfile = false + } + } }, enabled = !isBusy, modifier = Modifier .fillMaxWidth() - .testTag(ConnectionFormTags.USE_PROFILE_AS_NEW), + .testTag( + if (selectedHostProfile == null) { + ConnectionFormTags.SAVE_PROFILE + } else { + ConnectionFormTags.UPDATE_PROFILE + }, + ), ) { - Text("Use details as new connection") + Text(if (selectedHostProfile == null) "Save profile" else "Update profile") } - TextButton( - onClick = { - profilePendingDeletion = profile - formError = null - }, - enabled = !isBusy, - modifier = Modifier - .fillMaxWidth() - .testTag(ConnectionFormTags.DELETE_PROFILE_PREFIX + profile.id), - ) { - Text("Delete saved profile") + selectedHostProfile?.let { profile -> + OutlinedButton( + onClick = { + onSelectedHostProfileChange(null) + selectedPreferredIdentityId = null + clearSessionCredentialInputs() + formError = null + }, + enabled = !isBusy, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.USE_PROFILE_AS_NEW), + ) { + Text("Use details as new connection") + } + TextButton( + onClick = { + profilePendingDeletion = profile + formError = null + }, + enabled = !isBusy, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.DELETE_PROFILE_PREFIX + profile.id), + ) { + Text("Delete saved profile") + } } + Text( + "Profiles save the server address and preferred identity. Passwords are " + + "entered for each connection unless you opt in to an encrypted saved copy. " + + "Private-key passphrases are entered for each connection.", + style = MaterialTheme.typography.bodySmall, + ) } - Text( - "Profiles save the server address and preferred identity. Passwords are " + - "entered for each connection unless you opt in to an encrypted saved copy. " + - "Private-key passphrases are entered for each connection.", - style = MaterialTheme.typography.bodySmall, - ) if (!notificationPermissionState.allowsCredentialEntry) { NotificationPermissionCard( @@ -1445,10 +1610,17 @@ internal fun HostForm( OutlinedTextField( value = keyPassphrase, onValueChange = { + clearValidationError(ConnectionValidationField.KEY_PASSPHRASE) connectionPreparationError = null keyPassphrase = it }, label = { Text("Key passphrase (optional)") }, + supportingText = validationMessage( + ConnectionValidationField.KEY_PASSPHRASE, + )?.let { { ConnectionValidationMessage(it) } }, + isError = validationMessage( + ConnectionValidationField.KEY_PASSPHRASE, + ) != null, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), singleLine = true, @@ -1897,10 +2069,14 @@ internal fun HostForm( private fun HomeOverviewContent( activeSessionDisplayName: String?, hostProfiles: List, + sshIdentities: List, transcriptCount: Int, + enabled: Boolean, onReturnToActiveSession: () -> Unit, onDisconnectActiveSession: () -> Unit, - onOpenProfile: (SavedHostProfile) -> Unit, + onConnectProfile: (SavedHostProfile) -> Unit, + onEditProfile: (SavedHostProfile) -> Unit, + onChangeIdentity: (SavedHostProfile) -> Unit, onNewConnection: () -> Unit, onOpenHistory: () -> Unit, onOpenSecurity: () -> Unit, @@ -1962,30 +2138,59 @@ private fun HomeOverviewContent( } else { Text("Choose a saved connection. Credentials are entered or unlocked each time.") hostProfiles.forEach { profile -> - OutlinedButton( - onClick = { onOpenProfile(profile) }, - modifier = Modifier - .fillMaxWidth() - .testTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id), - contentPadding = PaddingValues(14.dp), - ) { - Column(modifier = Modifier.fillMaxWidth()) { - Text(profile.displayName) - Text( - if (profile.preferredIdentityId == null) { - "Choose identity · ${profile.hostname}:${profile.port}" - } else { - "${profile.username}@${profile.hostname}:${profile.port}" - }, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - ) + val preferredIdentity = sshIdentities.firstOrNull { + it.id == profile.preferredIdentityId + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + OutlinedButton( + onClick = { onConnectProfile(profile) }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id), + contentPadding = PaddingValues(14.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(profile.displayName) + Text( + if ( + preferredIdentity == null || + preferredIdentity.authenticationMethod == + IdentityAuthenticationMethod.UNCONFIGURED + ) { + "Choose identity · ${profile.hostname}:${profile.port}" + } else { + "${preferredIdentity.username}@${profile.hostname}:${profile.port}" + }, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + TextButton( + onClick = { onChangeIdentity(profile) }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.CHANGE_IDENTITY_PREFIX + profile.id), + ) { + Text("Change identity") + } + TextButton( + onClick = { onEditProfile(profile) }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.EDIT_PROFILE_PREFIX + profile.id), + ) { + Text("Edit") } } } } Button( onClick = onNewConnection, + enabled = enabled, modifier = Modifier .fillMaxWidth() .testTag(ConnectionFormTags.NEW_CONNECTION), @@ -2440,8 +2645,10 @@ private enum class ConnectionValidationField { HOSTNAME, PORT, USERNAME, + IDENTITY, PASSWORD, PRIVATE_KEY, + KEY_PASSPHRASE, } private data class ConnectionValidationError( diff --git a/app/src/main/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStore.kt b/app/src/main/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStore.kt index a871508..adeaa56 100644 --- a/app/src/main/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStore.kt +++ b/app/src/main/java/dev/threadline/data/key/EncryptedImportedPrivateKeyStore.kt @@ -110,6 +110,7 @@ internal class EncryptedImportedPrivateKeyStore( aad.fill(0) } try { + inspectPrivateKey(plaintext, passphrase) SessionCredential.PrivateKey.from(plaintext, passphrase) } finally { plaintext.fill(0) From 8f106543c050c11445924e1537c57fb15c224002 Mon Sep 17 00:00:00 2001 From: Ross Tomsic Date: Sun, 27 Sep 2026 20:27:29 -0400 Subject: [PATCH 2/2] Scroll identity repair into view --- .../threadline/HomeDirectConnectionTest.kt | 4 ++-- .../main/java/dev/threadline/MainActivity.kt | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt b/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt index 2ab9df6..4fa260e 100644 --- a/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt +++ b/app/src/androidTest/java/dev/threadline/HomeDirectConnectionTest.kt @@ -283,7 +283,7 @@ class HomeDirectConnectionTest { compose.onNodeWithTag(ConnectionFormTags.CHANGE_IDENTITY_PREFIX + profile.id) .performClick() - compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsFocused() + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsDisplayed() compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE).assertDoesNotExist() compose.runOnIdle { assertNull(prepared) } @@ -363,7 +363,7 @@ class HomeDirectConnectionTest { .performClick() compose.onNodeWithText("Choose a configured SSH identity for this connection.") .assertExists() - compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsFocused() + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).assertIsDisplayed() compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE).assertDoesNotExist() } diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index c411cc4..9de8a7e 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -811,7 +811,7 @@ internal fun HostForm( var selectedSavedKeyId by rememberSaveable { mutableStateOf(null) } var selectedPreferredIdentityId by rememberSaveable { mutableStateOf(null) } var credentialOnly by rememberSaveable { mutableStateOf(false) } - var focusIdentityOnEntry by remember { mutableStateOf(false) } + var scrollIdentityOnEntry by remember { mutableStateOf(false) } var savePrivateKey by rememberSaveable { mutableStateOf(false) } var formError by remember { mutableStateOf(null) } var validationError by remember { mutableStateOf(null) } @@ -831,7 +831,7 @@ internal fun HostForm( val portFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - val identityFocusRequester = remember { FocusRequester() } + val identityBringIntoViewRequester = remember { BringIntoViewRequester() } val privateKeyBringIntoViewRequester = remember { BringIntoViewRequester() } val keyPassphraseFocusRequester = remember { FocusRequester() } val selectedHostProfile = hostProfiles.firstOrNull { it.id == selectedHostProfileId } @@ -856,10 +856,10 @@ internal fun HostForm( } } - LaunchedEffect(task, focusIdentityOnEntry) { - if (task == HomeTask.CONNECTION && focusIdentityOnEntry) { - identityFocusRequester.requestFocus() - focusIdentityOnEntry = false + LaunchedEffect(task, scrollIdentityOnEntry) { + if (task == HomeTask.CONNECTION && scrollIdentityOnEntry) { + identityBringIntoViewRequester.bringIntoView() + scrollIdentityOnEntry = false } } @@ -903,7 +903,8 @@ internal fun HostForm( ConnectionValidationField.HOSTNAME -> hostnameFocusRequester.requestFocus() ConnectionValidationField.PORT -> portFocusRequester.requestFocus() ConnectionValidationField.USERNAME -> usernameFocusRequester.requestFocus() - ConnectionValidationField.IDENTITY -> identityFocusRequester.requestFocus() + ConnectionValidationField.IDENTITY -> + identityBringIntoViewRequester.bringIntoView() ConnectionValidationField.PASSWORD -> passwordFocusRequester.requestFocus() ConnectionValidationField.PRIVATE_KEY -> privateKeyBringIntoViewRequester.bringIntoView() @@ -1114,7 +1115,7 @@ internal fun HostForm( openProfile(profile) credentialOnly = true savedTask = HomeTask.CONNECTION.name - focusIdentityOnEntry = true + scrollIdentityOnEntry = true }, onNewConnection = { onSelectedHostProfileChange(null) @@ -1342,7 +1343,7 @@ internal fun HostForm( identities = sshIdentities, selectedIdentityId = selectedPreferredIdentityId, enabled = !isBusy, - modifier = Modifier.focusRequester(identityFocusRequester), + modifier = Modifier.bringIntoViewRequester(identityBringIntoViewRequester), onSelect = { identity -> clearValidationError(ConnectionValidationField.IDENTITY) selectedPreferredIdentityId = identity?.id