commit f2a93277920c34c17de868dff458c7098c816738 parent 7541fc20eab2ad41ccd43654911533388d677422 Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 02:41:47 +0000 ui: preserve identity bootstrap in shared Compose - move reusable identity models and Compose screens into common source - keep the complete create, import, recovery, session, and removal behavior - inject secret clipboard handling through an explicit platform action boundary - relocate model and UI behavior coverage alongside the shared implementation Diffstat:
9 files changed, 913 insertions(+), 887 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleScreen import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.StartupFailureScreen @@ -53,7 +54,6 @@ fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = : editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft(it)) }, generateIdentity = { presenter.dispatch(HarvestCircleIntent.GenerateIdentity) }, importSecretKey = { presenter.dispatch(HarvestCircleIntent.ImportIdentity) }, - copyText = clipboard::copy, acknowledgeGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.AcknowledgeGeneratedRecovery) }, cancelGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.CancelGeneratedRecovery) }, selectIdentity = { presenter.dispatch(HarvestCircleIntent.SelectIdentity(IdentityId.fromPublicKeyHex(it))) }, @@ -69,6 +69,7 @@ fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = : showIdentityChooser = { presenter.dispatch(HarvestCircleIntent.ShowIdentityChooser) }, hideIdentityChooser = { presenter.dispatch(HarvestCircleIntent.HideIdentityChooser) }, ), + platformActions = HarvestCirclePlatformActions(copySecret = clipboard::copy), ) } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt @@ -1,495 +0,0 @@ -package org.harvestcircle.identities.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.password -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.IdentityEntryMode - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -data class HarvestCircleUiActions( - val chooseCreateIdentity: () -> Unit = {}, - val chooseImportIdentity: () -> Unit = {}, - val cancelIdentityEntry: () -> Unit = {}, - val editImportDraft: (String) -> Unit = {}, - val generateIdentity: () -> Unit = {}, - val importSecretKey: () -> Unit = {}, - val copyText: (String) -> Unit = {}, - val acknowledgeGeneratedKeyBackup: () -> Unit = {}, - val cancelGeneratedKeyBackup: () -> Unit = {}, - val selectIdentity: (String) -> Unit = {}, - val activateIdentity: (String) -> Unit = {}, - val requestIdentityRemoval: (String) -> Unit = {}, - val cancelIdentityRemoval: () -> Unit = {}, - val confirmIdentityRemoval: () -> Unit = {}, - val refreshActiveProfile: () -> Unit = {}, - val retryLastCommand: () -> Unit = {}, - val signOut: () -> Unit = {}, - val showIdentityChooser: () -> Unit = {}, - val hideIdentityChooser: () -> Unit = {}, -) - -@Composable -fun StartupFailureScreen(problem: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(problem, Modifier.testTag("startup-problem")) - } -} - -@Composable -fun HarvestCircleScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - model.generatedKeyBackup?.let { backup -> - GeneratedKeyRecoveryScreen(backup, actions) - return - } - when (model.route) { - HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local identity store", "lifecycle-opening") - HarvestCircleRoute.CHECKING_COMPATIBILITY -> - LifecycleScreen( - "Checking native compatibility", - "lifecycle-compatibility", - ) - HarvestCircleRoute.ACQUIRING_OWNERSHIP -> - LifecycleScreen( - "Acquiring local identity store", - "lifecycle-ownership", - ) - HarvestCircleRoute.MIGRATING -> - LifecycleScreen( - "Updating local identity store", - "lifecycle-migrating", - ) - HarvestCircleRoute.RECOVERING -> - LifecycleScreen( - "Recovering local identity state", - "lifecycle-recovering", - ) - HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") - HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") - HarvestCircleRoute.BLOCKED -> - LifecycleScreen( - model.problem ?: "Local identity access is blocked.", - "lifecycle-blocked", - ) - HarvestCircleRoute.FATAL -> - LifecycleScreen( - model.problem ?: "The application could not continue.", - "lifecycle-fatal", - ) - HarvestCircleRoute.DEGRADED -> InactiveIdentitiesScreen(model, actions, degraded = true) - HarvestCircleRoute.ACTIVE_IDENTITY -> { - if (model.activeIdentity != null && !model.identityChooserVisible) { - ActiveIdentityHome(model, model.activeIdentity, actions) - } else { - InactiveIdentitiesScreen(model, actions) - } - } - HarvestCircleRoute.IDENTITIES -> InactiveIdentitiesScreen(model, actions) - } -} - -@Composable -private fun LifecycleScreen( - message: String, - testTag: String, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag(testTag), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(message) - } -} - -@Composable -private fun ActiveIdentityHome( - model: HarvestCircleUiModel, - active: ActiveIdentityUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - BasicText("HarvestCircle") - BasicText(active.heading) - BasicText(active.identity.npub, Modifier.testTag("active-npub")) - BasicText(active.identity.publicKeyHex, Modifier.testTag("active-pubkey-hex")) - BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) - BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05 (unverified): ${active.profile.nip05}") - BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) - BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) - BasicText("Configured relays") - if (model.configuredRelays.isEmpty()) { - BasicText("None") - } else { - model.configuredRelays.forEach { relay -> BasicText(relay) } - } - TextAction( - text = "Switch identity", - testTag = "switch-identity", - contentDescription = "Choose another saved identity", - enabled = !model.busy, - onClick = actions.showIdentityChooser, - ) - TextAction( - text = "Refresh metadata", - testTag = "refresh-profile", - contentDescription = "Refresh active Nostr profile metadata", - enabled = !model.busy, - onClick = actions.refreshActiveProfile, - ) - TextAction( - text = "Sign out", - testTag = "sign-out", - contentDescription = "Sign out of the active identity", - enabled = !model.busy, - onClick = actions.signOut, - ) - model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } - RecoveryAction(model, actions) - } -} - -@Composable -private fun InactiveIdentitiesScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, - degraded: Boolean = false, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("identities-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText("Identities") - if (degraded) { - BasicText(model.problem ?: "Nostr relay access is unavailable. Local identities remain available.") - } - - if (model.activeIdentity != null) { - BasicText("Choose an identity to activate. The current identity remains active until replacement succeeds.") - TextAction( - text = "Back to active identity", - testTag = "return-home", - contentDescription = "Return to the active identity", - onClick = actions.hideIdentityChooser, - ) - } - - IdentityEntry(model, actions) - - model.problem?.let { - BasicText(it, Modifier.testTag("identities-problem")) - } - RecoveryAction(model, actions) - - if (model.identities.isEmpty()) { - BasicText("No saved identities.", Modifier.testTag("identities-empty")) - } else { - SavedIdentityList(model, actions) - } - } -} - -@Composable -private fun RecoveryAction( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - if (model.recoveryAction == org.harvestcircle.application.RecoveryAction.Retry) { - TextAction( - text = "Retry", - testTag = "retry-last-command", - contentDescription = "Retry the last failed action", - enabled = !model.busy, - onClick = actions.retryLastCommand, - ) - } -} - -@Composable -private fun IdentityEntry( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - when (model.identityEntryMode) { - IdentityEntryMode.CHOICE -> { - TextAction( - text = "Create identity", - testTag = "choose-create-identity", - contentDescription = "Create a new Nostr identity", - enabled = !model.busy, - onClick = actions.chooseCreateIdentity, - ) - TextAction( - text = "Import key", - testTag = "choose-import-identity", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy, - onClick = actions.chooseImportIdentity, - ) - } - IdentityEntryMode.CREATE -> { - TextAction( - text = "Back", - testTag = "cancel-identity-entry", - contentDescription = "Return to identity choices", - enabled = !model.busy, - onClick = actions.cancelIdentityEntry, - ) - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - onClick = actions.generateIdentity, - ) - } - IdentityEntryMode.IMPORT -> { - val importFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { importFocusRequester.requestFocus() } - TextAction( - text = "Back", - testTag = "cancel-identity-entry", - contentDescription = "Return to identity choices", - enabled = !model.busy, - onClick = actions.cancelIdentityEntry, - ) - BasicTextField( - value = model.importDraft, - onValueChange = actions.editImportDraft, - enabled = !model.busy, - visualTransformation = PasswordVisualTransformation(), - modifier = - Modifier - .fillMaxWidth() - .semantics { - contentDescription = "Nostr secret key" - password() - }.focusRequester(importFocusRequester) - .testTag("import-nsec-input") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") - innerTextField() - }, - ) - model.importGuidance?.let { guidance -> - BasicText(guidance, Modifier.testTag("import-guidance")) - } - TextAction( - text = "Add existing key", - testTag = "import-key", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy && model.importDraft.isNotBlank(), - onClick = actions.importSecretKey, - ) - } - } -} - -@Composable -private fun ColumnScope.SavedIdentityList( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-identity-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> - Column( - modifier = - Modifier - .fillMaxWidth() - .semantics { selected = identity.selected } - .testTag("identity-row:${identity.publicKeyHex}") - .background(InputBackgroundColor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - BasicText(identity.label) - BasicText(identity.npub) - BasicText("Key: ${identity.signerAvailability}") - if (identity.selected) BasicText("Selected") - if (identity.active) BasicText("Active") - TextAction( - text = if (identity.selected) "Selected identity" else "Select", - testTag = "select-identity:${identity.publicKeyHex}", - contentDescription = "Select ${identity.label}", - enabled = !model.busy && !identity.selected, - onClick = { actions.selectIdentity(identity.publicKeyHex) }, - ) - TextAction( - text = if (identity.active) "Active identity" else "Activate", - testTag = "activate-identity:${identity.publicKeyHex}", - contentDescription = "Activate ${identity.label}", - enabled = !model.busy && !identity.active, - onClick = { actions.activateIdentity(identity.publicKeyHex) }, - ) - TextAction( - text = "Remove", - testTag = "remove-identity:${identity.publicKeyHex}", - contentDescription = "Remove ${identity.label}", - enabled = !model.busy, - onClick = { actions.requestIdentityRemoval(identity.publicKeyHex) }, - ) - if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { - BasicText("Remove this saved identity?") - if (model.removalImpact?.deletesLocalCredential == true) { - BasicText("Its local credential will be deleted from the operating-system keyring.") - } - if (model.removalImpact?.signsOut == true) { - BasicText("The active session will be signed out before removal.") - } - TextAction( - text = "Cancel", - testTag = "remove-cancel", - contentDescription = "Cancel identity removal", - onClick = actions.cancelIdentityRemoval, - ) - TextAction( - text = "Confirm removal", - testTag = "remove-confirm", - contentDescription = "Confirm identity removal", - enabled = !model.busy, - onClick = actions.confirmIdentityRemoval, - ) - } - } - } - } -} - -@Composable -private fun GeneratedKeyRecoveryScreen( - backup: GeneratedKeyBackupUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Save this key") - BasicText("Losing this secret key means losing access to the identity.") - BasicText(backup.npub) - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) - TextAction( - text = "Copy", - testTag = "copy-generated-key", - contentDescription = "Copy generated Nostr secret key", - onClick = { actions.copyText(backup.nsec) }, - ) - TextAction( - text = "Cancel", - testTag = "cancel-generated-key", - contentDescription = "Cancel generated identity", - onClick = actions.cancelGeneratedKeyBackup, - ) - TextAction( - text = "I have saved this key", - testTag = "acknowledge-key-backup", - contentDescription = "Confirm generated key backup", - onClick = actions.acknowledgeGeneratedKeyBackup, - ) - } -} - -@Composable -internal fun TextAction( - text: String, - testTag: String, - contentDescription: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = - Modifier - .semantics { - role = Role.Button - this.contentDescription = contentDescription - if (!enabled) disabled() - }.testTag(testTag) - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt @@ -1,391 +0,0 @@ -package org.harvestcircle.identities.ui - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.IdentityEntryMode -import org.harvestcircle.application.IdentityId -import org.harvestcircle.application.RecoveryAction -import org.harvestcircle.application.RemovalImpactState -import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.application.SessionLifecycle -import org.harvestcircle.application.UnixSeconds -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalTestApi::class) -class HarvestCircleScreenTest { - @Test - fun rendersEveryNonReadyLifecycleRouteWithoutIdentityControls() = - runComposeUiTest { - var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) - setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } - - val routes = - listOf( - HarvestCircleRoute.OPENING to "lifecycle-opening", - HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", - HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", - HarvestCircleRoute.MIGRATING to "lifecycle-migrating", - HarvestCircleRoute.RECOVERING to "lifecycle-recovering", - HarvestCircleRoute.BLOCKED to "lifecycle-blocked", - HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", - HarvestCircleRoute.FATAL to "lifecycle-fatal", - HarvestCircleRoute.CLOSED to "lifecycle-closed", - ) - routes.forEach { (route, tag) -> - model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) - waitForIdle() - onNodeWithTag(tag).assertIsDisplayed() - onAllNodesWithTag("generate-key").assertCountEquals(0) - } - - model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) - waitForIdle() - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithTag("identities-problem").assertIsDisplayed() - } - - @Test - fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = - runComposeUiTest { - var importDraft by mutableStateOf("") - var identityEntryMode by mutableStateOf(IdentityEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel(importDraft = importDraft).copy(identityEntryMode = identityEntryMode), - actions = - HarvestCircleUiActions( - chooseCreateIdentity = { identityEntryMode = IdentityEntryMode.CREATE }, - chooseImportIdentity = { identityEntryMode = IdentityEntryMode.IMPORT }, - cancelIdentityEntry = { identityEntryMode = IdentityEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateIdentity = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("choose-create-identity").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-identity-entry").performClick() - onNodeWithTag("choose-import-identity").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() - - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue( - onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }, - ) - } - - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = - runComposeUiTest { - setContent { - HarvestCircleScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = HarvestCircleUiActions(), - ) - } - - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("identities-empty").assertIsDisplayed() - } - - @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - copyText = { copied = it }, - acknowledgeGeneratedKeyBackup = { backup = null }, - ), - ) - } - - onNodeWithTag("generated-key-backup").assertIsDisplayed() - onAllNodesWithTag("identities-screen").assertCountEquals(0) - onAllNodesWithTag("generate-key").assertCountEquals(0) - onNodeWithTag("generated-nsec").assertIsDisplayed() - onNodeWithTag("copy-generated-key").performClick() - assertEquals("nsec1generated", copied) - - onNodeWithTag("acknowledge-key-backup").performClick() - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - onAllNodesWithTag("generated-nsec").assertCountEquals(0) - } - - @Test - fun generatedKeyRecoveryCanBeCancelledWithoutExposingIdentityControls() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var cancelled = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - cancelGeneratedKeyBackup = { - cancelled += 1 - backup = null - }, - ), - ) - } - - onNodeWithTag("cancel-generated-key").performClick() - assertEquals(1, cancelled) - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - } - - @Test - fun savedIdentitiesSelectActivateAndRequireRemovalConfirmation() = - runComposeUiTest { - val first = identityUi("11".repeat(32), selected = true) - val second = identityUi("22".repeat(32), selected = false) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - identities = listOf(first, second), - pendingRemovalPublicKeyHex = pendingRemoval, - removalImpact = - pendingRemoval?.let { - RemovalImpactState( - IdentityId.fromPublicKeyHex(it), - deletesLocalCredential = true, - signsOut = true, - expiresAt = UnixSeconds(60), - ) - }, - ), - actions = - HarvestCircleUiActions( - selectIdentity = selected::add, - activateIdentity = activated::add, - requestIdentityRemoval = { pendingRemoval = it }, - cancelIdentityRemoval = { pendingRemoval = null }, - confirmIdentityRemoval = { confirmations += 1 }, - ), - ) - } - - onNodeWithTag("saved-identity-list").assertIsDisplayed() - onNodeWithTag("identity-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - assertEquals(listOf(second.publicKeyHex), activated) - - onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() - onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - assertEquals(null, pendingRemoval) - onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - assertEquals(1, confirmations) - } - - @Test - fun savedIdentityListRemainsReachableForLargeRegistries() = - runComposeUiTest { - val identities = - (0 until 100).map { index -> - identityUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(identities = identities), - actions = HarvestCircleUiActions(), - ) - } - - val lastTag = "identity-row:${identities.last().publicKeyHex}" - onNodeWithTag("saved-identity-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } - - @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = - runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val identity = identityUi("33".repeat(32), selected = true) - val active = - ActiveIdentityUiModel( - identity = identity, - heading = "Alice", - relayState = "connected", - profileState = "fresh", - profile = - ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_IDENTITY, - identities = listOf(identity), - activeIdentity = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionLifecycle.Active, - ), - actions = - HarvestCircleUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } - - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } - - @Test - fun activeIdentityCanOpenChooserWithoutDroppingCurrentSession() = - runComposeUiTest { - val first = identityUi("44".repeat(32), selected = true, active = true) - val second = identityUi("55".repeat(32), selected = false) - val active = - ActiveIdentityUiModel( - identity = first, - heading = first.label, - relayState = "connected", - profileState = "cached", - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_IDENTITY, - identities = listOf(first, second), - activeIdentity = active, - session = SessionLifecycle.Active, - identityChooserVisible = chooserVisible, - ), - actions = - HarvestCircleUiActions( - showIdentityChooser = { chooserVisible = true }, - hideIdentityChooser = { chooserVisible = false }, - activateIdentity = { activated = it }, - ), - ) - } - - onNodeWithTag("switch-identity").performClick() - onNodeWithTag("identities-screen").assertIsDisplayed() - onNodeWithTag("activate-identity:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() - onNodeWithText("Active").assertIsDisplayed() - onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(second.publicKeyHex, activated) - assertEquals( - SessionLifecycle.Active, - emptyUiModel() - .copy( - activeIdentity = active, - session = SessionLifecycle.Active, - ).session, - ) - onNodeWithTag("return-home").performClick() - onNodeWithTag("home-screen").assertIsDisplayed() - } -} - -private fun emptyUiModel( - importDraft: String = "", - problem: String? = null, - importGuidance: String? = null, - recoveryAction: RecoveryAction = RecoveryAction.None, -) = HarvestCircleUiModel( - route = HarvestCircleRoute.IDENTITIES, - identities = emptyList(), - activeIdentity = null, - configuredRelays = emptyList(), - importDraft = importDraft, - generatedKeyBackup = null, - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - lastRemovedPublicKeyHex = null, - identityChooserVisible = false, - identityEntryMode = IdentityEntryMode.CHOICE, - session = SessionLifecycle.SignedOut, - busy = false, - problem = problem, - importGuidance = importGuidance, - recoveryAction = recoveryAction, -) - -private fun identityUi( - publicKeyHex: String, - selected: Boolean, - active: Boolean = false, -) = IdentityUiModel( - publicKeyHex = publicKeyHex, - npub = "npub1${publicKeyHex.take(12)}", - shortNpub = "npub1${publicKeyHex.take(12)}", - label = "Identity ${publicKeyHex.take(2)}", - signerAvailability = "available", - selected = selected, - active = active, -) diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts @@ -4,10 +4,20 @@ import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType plugins { alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) alias(libs.plugins.detekt) alias(libs.plugins.ktlint) } +configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { + additionalEditorconfig.set( + mapOf( + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + ), + ) +} + providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { extBuildGradleRoot -> layout.buildDirectory.set(file(extBuildGradleRoot).resolve("app-shared")) } @@ -21,12 +31,20 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(libs.compose.foundation) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) } + getByName("desktopTest").dependencies { + implementation(compose.desktop.currentOs) + implementation(kotlin("test-junit")) + implementation(libs.compose.ui.test.junit4) + } } jvmToolchain(21) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreen.kt @@ -0,0 +1,500 @@ +package org.harvestcircle.identities.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.IdentityEntryMode + +private val WindowBackgroundColor = Color(0xFFF5F5F2) +private val ButtonBackgroundColor = Color(0xFFE7E7E2) +private val InputBackgroundColor = Color(0xFFFEFDF8) + +data class HarvestCircleUiActions( + val chooseCreateIdentity: () -> Unit = {}, + val chooseImportIdentity: () -> Unit = {}, + val cancelIdentityEntry: () -> Unit = {}, + val editImportDraft: (String) -> Unit = {}, + val generateIdentity: () -> Unit = {}, + val importSecretKey: () -> Unit = {}, + val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val cancelGeneratedKeyBackup: () -> Unit = {}, + val selectIdentity: (String) -> Unit = {}, + val activateIdentity: (String) -> Unit = {}, + val requestIdentityRemoval: (String) -> Unit = {}, + val cancelIdentityRemoval: () -> Unit = {}, + val confirmIdentityRemoval: () -> Unit = {}, + val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, + val signOut: () -> Unit = {}, + val showIdentityChooser: () -> Unit = {}, + val hideIdentityChooser: () -> Unit = {}, +) + +data class HarvestCirclePlatformActions( + val copySecret: (String) -> Unit = {}, +) + +@Composable +fun StartupFailureScreen(problem: String) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("startup-failure"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(problem, Modifier.testTag("startup-problem")) + } +} + +@Composable +fun HarvestCircleScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + platformActions: HarvestCirclePlatformActions = HarvestCirclePlatformActions(), +) { + model.generatedKeyBackup?.let { backup -> + GeneratedKeyRecoveryScreen(backup, actions, platformActions) + return + } + when (model.route) { + HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local identity store", "lifecycle-opening") + HarvestCircleRoute.CHECKING_COMPATIBILITY -> + LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + HarvestCircleRoute.ACQUIRING_OWNERSHIP -> + LifecycleScreen( + "Acquiring local identity store", + "lifecycle-ownership", + ) + HarvestCircleRoute.MIGRATING -> + LifecycleScreen( + "Updating local identity store", + "lifecycle-migrating", + ) + HarvestCircleRoute.RECOVERING -> + LifecycleScreen( + "Recovering local identity state", + "lifecycle-recovering", + ) + HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") + HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") + HarvestCircleRoute.BLOCKED -> + LifecycleScreen( + model.problem ?: "Local identity access is blocked.", + "lifecycle-blocked", + ) + HarvestCircleRoute.FATAL -> + LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) + HarvestCircleRoute.DEGRADED -> InactiveIdentitiesScreen(model, actions, degraded = true) + HarvestCircleRoute.ACTIVE_IDENTITY -> { + if (model.activeIdentity != null && !model.identityChooserVisible) { + ActiveIdentityHome(model, model.activeIdentity, actions) + } else { + InactiveIdentitiesScreen(model, actions) + } + } + HarvestCircleRoute.IDENTITIES -> InactiveIdentitiesScreen(model, actions) + } +} + +@Composable +private fun LifecycleScreen( + message: String, + testTag: String, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(message) + } +} + +@Composable +private fun ActiveIdentityHome( + model: HarvestCircleUiModel, + active: ActiveIdentityUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("home-screen"), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BasicText("HarvestCircle") + BasicText(active.heading) + BasicText(active.identity.npub, Modifier.testTag("active-npub")) + BasicText(active.identity.publicKeyHex, Modifier.testTag("active-pubkey-hex")) + BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) + BasicText("Display name: ${active.profile.displayName}") + BasicText("NIP-05 (unverified): ${active.profile.nip05}") + BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) + BasicText("Picture: ${active.profile.picture}") + BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) + BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) + BasicText("Configured relays") + if (model.configuredRelays.isEmpty()) { + BasicText("None") + } else { + model.configuredRelays.forEach { relay -> BasicText(relay) } + } + TextAction( + text = "Switch identity", + testTag = "switch-identity", + contentDescription = "Choose another saved identity", + enabled = !model.busy, + onClick = actions.showIdentityChooser, + ) + TextAction( + text = "Refresh metadata", + testTag = "refresh-profile", + contentDescription = "Refresh active Nostr profile metadata", + enabled = !model.busy, + onClick = actions.refreshActiveProfile, + ) + TextAction( + text = "Sign out", + testTag = "sign-out", + contentDescription = "Sign out of the active identity", + enabled = !model.busy, + onClick = actions.signOut, + ) + model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } + RecoveryAction(model, actions) + } +} + +@Composable +private fun InactiveIdentitiesScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + degraded: Boolean = false, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("identities-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText("Identities") + if (degraded) { + BasicText(model.problem ?: "Nostr relay access is unavailable. Local identities remain available.") + } + + if (model.activeIdentity != null) { + BasicText("Choose an identity to activate. The current identity remains active until replacement succeeds.") + TextAction( + text = "Back to active identity", + testTag = "return-home", + contentDescription = "Return to the active identity", + onClick = actions.hideIdentityChooser, + ) + } + + IdentityEntry(model, actions) + + model.problem?.let { + BasicText(it, Modifier.testTag("identities-problem")) + } + RecoveryAction(model, actions) + + if (model.identities.isEmpty()) { + BasicText("No saved identities.", Modifier.testTag("identities-empty")) + } else { + SavedIdentityList(model, actions) + } + } +} + +@Composable +private fun RecoveryAction( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + if (model.recoveryAction == org.harvestcircle.application.RecoveryAction.Retry) { + TextAction( + text = "Retry", + testTag = "retry-last-command", + contentDescription = "Retry the last failed action", + enabled = !model.busy, + onClick = actions.retryLastCommand, + ) + } +} + +@Composable +private fun IdentityEntry( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + when (model.identityEntryMode) { + IdentityEntryMode.CHOICE -> { + TextAction( + text = "Create identity", + testTag = "choose-create-identity", + contentDescription = "Create a new Nostr identity", + enabled = !model.busy, + onClick = actions.chooseCreateIdentity, + ) + TextAction( + text = "Import key", + testTag = "choose-import-identity", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy, + onClick = actions.chooseImportIdentity, + ) + } + IdentityEntryMode.CREATE -> { + TextAction( + text = "Back", + testTag = "cancel-identity-entry", + contentDescription = "Return to identity choices", + enabled = !model.busy, + onClick = actions.cancelIdentityEntry, + ) + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy && model.generatedKeyBackup == null, + onClick = actions.generateIdentity, + ) + } + IdentityEntryMode.IMPORT -> { + val importFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { importFocusRequester.requestFocus() } + TextAction( + text = "Back", + testTag = "cancel-identity-entry", + contentDescription = "Return to identity choices", + enabled = !model.busy, + onClick = actions.cancelIdentityEntry, + ) + BasicTextField( + value = model.importDraft, + onValueChange = actions.editImportDraft, + enabled = !model.busy, + visualTransformation = PasswordVisualTransformation(), + modifier = + Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Nostr secret key" + password() + }.focusRequester(importFocusRequester) + .testTag("import-nsec-input") + .background(InputBackgroundColor) + .padding(8.dp), + decorationBox = { innerTextField -> + if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") + innerTextField() + }, + ) + model.importGuidance?.let { guidance -> + BasicText(guidance, Modifier.testTag("import-guidance")) + } + TextAction( + text = "Add existing key", + testTag = "import-key", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy && model.importDraft.isNotBlank(), + onClick = actions.importSecretKey, + ) + } + } +} + +@Composable +private fun ColumnScope.SavedIdentityList( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .testTag("saved-identity-list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> + Column( + modifier = + Modifier + .fillMaxWidth() + .semantics { selected = identity.selected } + .testTag("identity-row:${identity.publicKeyHex}") + .background(InputBackgroundColor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(identity.label) + BasicText(identity.npub) + BasicText("Key: ${identity.signerAvailability}") + if (identity.selected) BasicText("Selected") + if (identity.active) BasicText("Active") + TextAction( + text = if (identity.selected) "Selected identity" else "Select", + testTag = "select-identity:${identity.publicKeyHex}", + contentDescription = "Select ${identity.label}", + enabled = !model.busy && !identity.selected, + onClick = { actions.selectIdentity(identity.publicKeyHex) }, + ) + TextAction( + text = if (identity.active) "Active identity" else "Activate", + testTag = "activate-identity:${identity.publicKeyHex}", + contentDescription = "Activate ${identity.label}", + enabled = !model.busy && !identity.active, + onClick = { actions.activateIdentity(identity.publicKeyHex) }, + ) + TextAction( + text = "Remove", + testTag = "remove-identity:${identity.publicKeyHex}", + contentDescription = "Remove ${identity.label}", + enabled = !model.busy, + onClick = { actions.requestIdentityRemoval(identity.publicKeyHex) }, + ) + if (model.pendingRemovalPublicKeyHex == identity.publicKeyHex) { + BasicText("Remove this saved identity?") + if (model.removalImpact?.deletesLocalCredential == true) { + BasicText("Its local credential will be deleted from the operating-system keyring.") + } + if (model.removalImpact?.signsOut == true) { + BasicText("The active session will be signed out before removal.") + } + TextAction( + text = "Cancel", + testTag = "remove-cancel", + contentDescription = "Cancel identity removal", + onClick = actions.cancelIdentityRemoval, + ) + TextAction( + text = "Confirm removal", + testTag = "remove-confirm", + contentDescription = "Confirm identity removal", + enabled = !model.busy, + onClick = actions.confirmIdentityRemoval, + ) + } + } + } + } +} + +@Composable +private fun GeneratedKeyRecoveryScreen( + backup: GeneratedKeyBackupUiModel, + actions: HarvestCircleUiActions, + platformActions: HarvestCirclePlatformActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("generated-key-backup"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText("Save this key") + BasicText("Losing this secret key means losing access to the identity.") + BasicText(backup.npub) + BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + TextAction( + text = "Copy", + testTag = "copy-generated-key", + contentDescription = "Copy generated Nostr secret key", + onClick = { platformActions.copySecret(backup.nsec) }, + ) + TextAction( + text = "Cancel", + testTag = "cancel-generated-key", + contentDescription = "Cancel generated identity", + onClick = actions.cancelGeneratedKeyBackup, + ) + TextAction( + text = "I have saved this key", + testTag = "acknowledge-key-backup", + contentDescription = "Confirm generated key backup", + onClick = actions.acknowledgeGeneratedKeyBackup, + ) + } +} + +@Composable +internal fun TextAction( + text: String, + testTag: String, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + BasicText( + text = text, + modifier = + Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + }.testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), + ) +} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/IdentityUiModel.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/identities/ui/IdentityUiModel.kt diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/IdentityUiModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/identities/ui/IdentityUiModelTest.kt diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/identities/ui/HarvestCircleScreenTest.kt @@ -0,0 +1,391 @@ +package org.harvestcircle.identities.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RemovalImpactState +import org.harvestcircle.application.RemovalStatus +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.UnixSeconds +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleScreenTest { + @Test + fun rendersEveryNonReadyLifecycleRouteWithoutIdentityControls() = + runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) + setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } + + val routes = + listOf( + HarvestCircleRoute.OPENING to "lifecycle-opening", + HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + HarvestCircleRoute.MIGRATING to "lifecycle-migrating", + HarvestCircleRoute.RECOVERING to "lifecycle-recovering", + HarvestCircleRoute.BLOCKED to "lifecycle-blocked", + HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + HarvestCircleRoute.FATAL to "lifecycle-fatal", + HarvestCircleRoute.CLOSED to "lifecycle-closed", + ) + routes.forEach { (route, tag) -> + model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + waitForIdle() + onNodeWithTag(tag).assertIsDisplayed() + onAllNodesWithTag("generate-key").assertCountEquals(0) + } + + model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) + waitForIdle() + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithTag("identities-problem").assertIsDisplayed() + } + + @Test + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = + runComposeUiTest { + var importDraft by mutableStateOf("") + var identityEntryMode by mutableStateOf(IdentityEntryMode.CHOICE) + var generateCalls = 0 + var importCalls = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel(importDraft = importDraft).copy(identityEntryMode = identityEntryMode), + actions = + HarvestCircleUiActions( + chooseCreateIdentity = { identityEntryMode = IdentityEntryMode.CREATE }, + chooseImportIdentity = { identityEntryMode = IdentityEntryMode.IMPORT }, + cancelIdentityEntry = { identityEntryMode = IdentityEntryMode.CHOICE }, + editImportDraft = { importDraft = it }, + generateIdentity = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } + + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithText("HarvestCircle").assertIsDisplayed() + onNodeWithTag("choose-create-identity").performClick() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-identity-entry").performClick() + onNodeWithTag("choose-import-identity").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() + + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue( + onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }, + ) + } + + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = + runComposeUiTest { + setContent { + HarvestCircleScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = HarvestCircleUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("identities-empty").assertIsDisplayed() + } + + @Test + fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var copied: String? = null + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + acknowledgeGeneratedKeyBackup = { backup = null }, + ), + platformActions = HarvestCirclePlatformActions(copySecret = { copied = it }), + ) + } + + onNodeWithTag("generated-key-backup").assertIsDisplayed() + onAllNodesWithTag("identities-screen").assertCountEquals(0) + onAllNodesWithTag("generate-key").assertCountEquals(0) + onNodeWithTag("generated-nsec").assertIsDisplayed() + onNodeWithTag("copy-generated-key").performClick() + assertEquals("nsec1generated", copied) + + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } + + @Test + fun generatedKeyRecoveryCanBeCancelledWithoutExposingIdentityControls() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var cancelled = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + cancelGeneratedKeyBackup = { + cancelled += 1 + backup = null + }, + ), + ) + } + + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + } + + @Test + fun savedIdentitiesSelectActivateAndRequireRemovalConfirmation() = + runComposeUiTest { + val first = identityUi("11".repeat(32), selected = true) + val second = identityUi("22".repeat(32), selected = false) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + identities = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = + pendingRemoval?.let { + RemovalImpactState( + IdentityId.fromPublicKeyHex(it), + deletesLocalCredential = true, + signsOut = true, + expiresAt = UnixSeconds(60), + ) + }, + ), + actions = + HarvestCircleUiActions( + selectIdentity = selected::add, + activateIdentity = activated::add, + requestIdentityRemoval = { pendingRemoval = it }, + cancelIdentityRemoval = { pendingRemoval = null }, + confirmIdentityRemoval = { confirmations += 1 }, + ), + ) + } + + onNodeWithTag("saved-identity-list").assertIsDisplayed() + onNodeWithTag("identity-row:${first.publicKeyHex}").assertIsSelected() + onNodeWithTag("select-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(listOf(second.publicKeyHex), selected) + assertEquals(listOf(second.publicKeyHex), activated) + + onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() + onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() + onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() + assertEquals(null, pendingRemoval) + onNodeWithTag("remove-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } + + @Test + fun savedIdentityListRemainsReachableForLargeRegistries() = + runComposeUiTest { + val identities = + (0 until 100).map { index -> + identityUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(identities = identities), + actions = HarvestCircleUiActions(), + ) + } + + val lastTag = "identity-row:${identities.last().publicKeyHex}" + onNodeWithTag("saved-identity-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } + + @Test + fun activeHomeShowsIdentityProfileRelayAndCommands() = + runComposeUiTest { + var refreshCalls = 0 + var signOutCalls = 0 + val identity = identityUi("33".repeat(32), selected = true) + val active = + ActiveIdentityUiModel( + identity = identity, + heading = "Alice", + relayState = "connected", + profileState = "fresh", + profile = + ProfileUiModel( + name = "alice", + displayName = "Alice", + nip05 = "alice@example.com", + about = "Local grower", + picture = "https://example.com/alice.png", + ), + ) + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_IDENTITY, + identities = listOf(identity), + activeIdentity = active, + configuredRelays = listOf("ws://localhost:8080"), + session = SessionLifecycle.Active, + ), + actions = + HarvestCircleUiActions( + refreshActiveProfile = { refreshCalls += 1 }, + signOut = { signOutCalls += 1 }, + ), + ) + } + + onNodeWithTag("home-screen").assertIsDisplayed() + onNodeWithTag("active-npub").assertIsDisplayed() + onNodeWithTag("active-pubkey-hex").assertIsDisplayed() + onNodeWithTag("active-profile-name").assertIsDisplayed() + onNodeWithTag("active-profile-about").assertIsDisplayed() + onNodeWithTag("relay-state").assertIsDisplayed() + onNodeWithTag("profile-state").assertIsDisplayed() + onNodeWithText("ws://localhost:8080").assertIsDisplayed() + onNodeWithTag("refresh-profile").performClick() + onNodeWithTag("sign-out").performClick() + assertEquals(1, refreshCalls) + assertEquals(1, signOutCalls) + } + + @Test + fun activeIdentityCanOpenChooserWithoutDroppingCurrentSession() = + runComposeUiTest { + val first = identityUi("44".repeat(32), selected = true, active = true) + val second = identityUi("55".repeat(32), selected = false) + val active = + ActiveIdentityUiModel( + identity = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_IDENTITY, + identities = listOf(first, second), + activeIdentity = active, + session = SessionLifecycle.Active, + identityChooserVisible = chooserVisible, + ), + actions = + HarvestCircleUiActions( + showIdentityChooser = { chooserVisible = true }, + hideIdentityChooser = { chooserVisible = false }, + activateIdentity = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-identity").performClick() + onNodeWithTag("identities-screen").assertIsDisplayed() + onNodeWithTag("activate-identity:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() + onNodeWithText("Active").assertIsDisplayed() + onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals( + SessionLifecycle.Active, + emptyUiModel() + .copy( + activeIdentity = active, + session = SessionLifecycle.Active, + ).session, + ) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() + } +} + +private fun emptyUiModel( + importDraft: String = "", + problem: String? = null, + importGuidance: String? = null, + recoveryAction: RecoveryAction = RecoveryAction.None, +) = HarvestCircleUiModel( + route = HarvestCircleRoute.IDENTITIES, + identities = emptyList(), + activeIdentity = null, + configuredRelays = emptyList(), + importDraft = importDraft, + generatedKeyBackup = null, + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + lastRemovedPublicKeyHex = null, + identityChooserVisible = false, + identityEntryMode = IdentityEntryMode.CHOICE, + session = SessionLifecycle.SignedOut, + busy = false, + problem = problem, + importGuidance = importGuidance, + recoveryAction = recoveryAction, +) + +private fun identityUi( + publicKeyHex: String, + selected: Boolean, + active: Boolean = false, +) = IdentityUiModel( + publicKeyHex = publicKeyHex, + npub = "npub1${publicKeyHex.take(12)}", + shortNpub = "npub1${publicKeyHex.take(12)}", + label = "Identity ${publicKeyHex.take(2)}", + signerAvailability = "available", + selected = selected, + active = active, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ owasp-dependency-check = "12.2.2" [libraries] compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }